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/// Private v2 baselines also contain the local stat/hash cache for large
130/// checkouts. They never cross the wire, but remain bounded before parsing and
131/// writing so a corrupt cache cannot cause unbounded allocation.
132const MAX_V2_BASELINE_BYTES: u64 = 64 * 1024 * 1024;
133/// Foreign registry cards are identity metadata only.
134const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
135
136/// Direct JSON pushes stay below the serverless request-body cap. Larger
137/// snapshots switch to the bounded object-store pack lane.
138const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
139/// Ceiling for a staged change manifest. Mirrors the hub's bound so an
140/// oversized push is refused before it stages anything.
141const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
142
143/// Canonical raw ZIP32 uses a u16 entry count and stores each UTF-8 name twice.
144const MAX_PUSH_FILES: usize = u16::MAX as usize;
145const MAX_STORE_PATH_BYTES: usize = 1_024;
146const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
147/// Assets travel outside the Markdown store/pack lane and have their own
148/// protocol ceiling. Keep this distinct from `MAX_STORE_BYTES`: applying the
149/// pack bound to a signed asset leaf makes an otherwise valid checkout unable
150/// to load its own saved sync baseline after the first commit.
151const MAX_ASSET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
152/// A durable pull journal covers both ordinary store files and out-of-band
153/// asset coordinates. Its per-file and total-preimage bounds therefore need
154/// to admit one maximum-size asset while remaining independently bounded.
155const MAX_PULL_TRANSACTION_BYTES: u64 = MAX_ASSET_BYTES;
156/// Exact worst case for the canonical STORED profile:
157/// payload + (local 30 + central 46 + name twice) per entry + EOCD 22.
158const MAX_PACK_BYTES: u64 =
159    MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
160/// An upload reservation body carries one entry per changed blob, so a real
161/// store's first push does not fit in a single request: the hub bounds the body
162/// and the hosting platform rejects an oversized payload before the route sees
163/// it. Reservations are therefore batched. The budget is deliberately well
164/// under the hub's own ceiling, and both bounds are enforced because a single
165/// hash can carry many coordinates and a path may be long.
166/// The count matches the reservation window a hub grants per request; batching
167/// above it only fails deeper in, after the body has already been accepted.
168const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
169const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
170
171/// A legitimate brain should rotate rarely. Bound adversarial identity
172/// histories before allocating and repeatedly verifying an unbounded chain.
173const MAX_IDENTITY_ROTATIONS: usize = 1_024;
174
175/// Split blob declarations into request-sized batches, by serialized size and
176/// by count. Order is preserved so a caller can concatenate the responses.
177fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
178    let mut batches: Vec<Vec<Value>> = Vec::new();
179    let mut current: Vec<Value> = Vec::new();
180    let mut current_bytes = 0usize;
181    for declaration in declarations {
182        let declared_bytes = serde_json::to_string(&declaration)
183            .map(|text| text.len())
184            .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
185            + 1;
186        if !current.is_empty()
187            && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
188                || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
189        {
190            batches.push(std::mem::take(&mut current));
191            current_bytes = 0;
192        }
193        current_bytes += declared_bytes;
194        current.push(declaration);
195    }
196    if !current.is_empty() {
197        batches.push(current);
198    }
199    batches
200}
201/// A client never needs to replay an unbounded feed in one invocation. Large
202/// histories are mirrored incrementally; a first mirror beyond this cap needs a
203/// checkpoint/export rather than allocating attacker-controlled metadata.
204const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
205const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
206const FEED_PAGE_LIMIT: usize = 100;
207
208/// The hub's inbox cap on one `propose` submission body, mirrored client-side
209/// so an oversized body fails before the upload, not after (the same
210/// fail-before-upload contract as the push caps). Public so the CLI can
211/// pre-check a `--body-file` from file metadata without reading it.
212pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
213
214/// Bounded connect so a dead hub fails fast; a generous read window so a
215/// large export on a slow link still completes.
216const CONNECT_TIMEOUT_SECS: u64 = 10;
217const READ_TIMEOUT_SECS: u64 = 120;
218/// Hard wall-clock budget for one HTTP attempt, spanning socket writes,
219/// response headers, and the complete response body. DNS performed for pinned
220/// agents has its own interruptible deadline below.
221const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
222/// A commit of a whole store is brain-sized work the hub is allowed to spend
223/// its full route budget on, so the client has to outwait the server rather
224/// than the other way round. At the default 120 seconds a first push of a real
225/// store abandoned a commit the hub was still completing and reported a
226/// transport failure for work that had not failed. This sits above the hub's
227/// own genesis-commit ceiling so the client always learns the outcome.
228const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
229/// A commit is idempotent by mutation coordinate, so a lost response is worth
230/// asking after — and the work it lost the receipt for may still be finishing,
231/// which is why the waits are long enough to let a brain-sized commit land.
232const COMMIT_ATTEMPTS: usize = 4;
233const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
234const CONNECT_ATTEMPTS: usize = 3;
235const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
236/// Safe reads are idempotent, including authenticated GETs and the explicitly
237/// marked read-only POSTs whose proof lists do not fit in a URL. Retry the
238/// whole read when a connection dies after headers or part-way through its
239/// body; connect-only retries cannot recover that common large-export failure.
240const SAFE_READ_ATTEMPTS: usize = 4;
241const SAFE_READ_RETRY_BACKOFF_MS: [u64; SAFE_READ_ATTEMPTS - 1] = [200, 1_000, 3_000];
242
243/// Staging a real store means tens of thousands of immutable object PUTs in a
244/// row. A single refusal under sustained load must not fail the migration, so
245/// the upload lane retries further and waits longer than a connect blip does.
246const UPLOAD_ATTEMPTS: usize = 6;
247const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
248/// Retries share one wall-clock budget. Without this outer deadline, one
249/// black-holed object can consume the per-attempt timeout six times, outlive
250/// its presigned capability, and make a large migration appear hung.
251const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
252
253fn upload_retry_backoff_ms(attempt: usize) -> u64 {
254    UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
255}
256
257fn upload_deadline_error() -> LinkError {
258    LinkError::Transport {
259        hub: "the object store".to_string(),
260        message: "network error (upload deadline exceeded)".to_string(),
261    }
262}
263
264fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
265    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
266    if remaining.is_zero() {
267        return Err(upload_deadline_error());
268    }
269    Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
270}
271
272fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
273    if attempt + 1 >= UPLOAD_ATTEMPTS {
274        return false;
275    }
276    let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
277    if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
278        return false;
279    }
280    std::thread::sleep(pause);
281    true
282}
283
284/// A migration opens dozens of staging windows back to back, so it is ordinary
285/// for a shared limiter or a briefly busy hub to answer one of them with 429 or
286/// 5xx. Failing the push there throws away every byte already staged, and the
287/// waits have to reach past a whole limiter window to be worth anything.
288const RESERVATION_ATTEMPTS: usize = 7;
289const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
290    [500, 2_000, 5_000, 15_000, 30_000, 60_000];
291
292/// Answers a busy or briefly unhealthy hub gives, as opposed to a refusal that
293/// would repeat identically. A 4xx other than these states something about the
294/// request itself and is never retried.
295fn is_retryable_hub_status(status: u16) -> bool {
296    matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
297}
298
299/// Statuses worth replaying an immutable, content-addressed upload for: edge
300/// and object-store refusals that are load-shaped rather than a statement about
301/// these bytes. A checksum or precondition failure is never retried.
302fn is_retryable_upload_status(status: u16) -> bool {
303    matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
304}
305/// Cold checkouts may need every visible blob, but short-lived direct object
306/// capabilities must not create an unbounded connection storm. Keep the
307/// verified downloads inside this fixed worker count.
308const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
309/// File durability barriers are independent inside the private pull stage.
310/// Keep enough in flight to hide storage flush latency without approaching
311/// ordinary process descriptor limits.
312#[cfg(unix)]
313const V2_PULL_INSTALL_WORKERS: usize = 16;
314/// One authenticated link.md v2 bulk frame remains small enough for bounded
315/// retries and serverless streaming while amortizing tens of thousands of
316/// immutable blobs over a few hundred requests instead of one request each.
317const V2_BULK_STREAM_FILES: usize = 256;
318const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
319const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
320const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
321/// A read capability lives for five minutes. Ask only for the files the
322/// bounded worker pool can start immediately; collecting hundreds of signed
323/// asset URLs before consuming the first one deterministically expires the
324/// oldest capabilities on a company-sized brain.
325const V2_DOWNLOAD_CAPABILITY_FILES: usize = V2_BLOB_DOWNLOAD_WORKERS;
326const V2_DOWNLOAD_CAPABILITY_BYTES: u64 = 512 * 1024 * 1024;
327const V2_DOWNLOAD_CAPABILITY_ATTEMPTS: usize = 4;
328const V2_DOWNLOAD_CAPABILITY_BACKOFF_MS: [u64; V2_DOWNLOAD_CAPABILITY_ATTEMPTS - 1] =
329    [200, 1_000, 3_000];
330
331/// Everything that can go wrong on the wire or at its edges. Each variant maps
332/// onto one stable CLI error code; messages are single-line and never echo the
333/// credential.
334#[derive(Debug, thiserror::Error)]
335pub enum LinkError {
336    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
337    #[error(
338        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
339    )]
340    NoHub,
341
342    /// The verb needs a credential and none was present.
343    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
344    NoCredential,
345
346    /// The credential contains whitespace / non-ASCII (a paste artifact). The
347    /// key is deliberately not echoed.
348    #[error(
349        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
350    )]
351    BadKey,
352
353    /// A store selected the destination while an ambient credential was
354    /// present, but the operator did not bind that credential to the same
355    /// origin. This is a hard refusal, not an anonymous fallback: silently
356    /// dropping a credential can turn an intended private operation into a
357    /// confusing public one.
358    #[error(
359        "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}"
360    )]
361    UnboundCredential,
362
363    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
364    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
365    /// echoed.
366    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
367    BadAgentKey {
368        /// What failed, without any key material.
369        message: String,
370    },
371
372    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
373    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
374    UnsafeHub {
375        /// The offending hub URL.
376        hub: String,
377    },
378
379    /// TCP/TLS-level failure: the hub never answered.
380    #[error("hub unreachable at {hub}: {message}")]
381    Transport {
382        /// The hub base URL.
383        hub: String,
384        /// The transport-layer error text.
385        message: String,
386    },
387
388    /// The hub answered with an HTTP error status.
389    #[error("{what} failed (HTTP {status}): {message}")]
390    Http {
391        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
392        what: &'static str,
393        /// The HTTP status code.
394        status: u16,
395        /// The hub's own `error` string when it sent one, else a placeholder.
396        message: String,
397        /// The hub's machine `code` field when it sent one.
398        code: Option<String>,
399        /// Permission-filtered structured refusal details, when supplied.
400        details: Option<Value>,
401    },
402
403    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
404    /// URL — refused here rather than deserializing into nothing downstream.
405    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
406    NotJson {
407        /// What the client was doing.
408        what: &'static str,
409        /// The (2xx) status that carried the non-JSON body.
410        status: u16,
411    },
412
413    /// The hub response exceeded the selected endpoint's byte cap.
414    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
415    ResponseTooLarge {
416        /// The endpoint-specific cap applied before JSON parsing.
417        limit_bytes: u64,
418    },
419
420    /// A malformed `@brain/id` address.
421    #[error("invalid address `{given}`: {reason}")]
422    BadAddress {
423        /// The raw address as typed.
424        given: String,
425        /// Why it did not parse.
426        reason: String,
427    },
428
429    /// A grant id whose shape cannot travel as a URL path segment.
430    #[error(
431        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
432    )]
433    BadGrantId {
434        /// The raw id as typed.
435        given: String,
436    },
437
438    /// An exported file path that would escape or pollute the destination
439    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
440    /// hub is not trusted with local path layout.
441    #[error("refusing unsafe path from the hub: `{path}`")]
442    UnsafePath {
443        /// The offending path as received.
444        path: String,
445    },
446
447    /// The store exceeds the hub's bounded whole-snapshot caps.
448    #[error(
449        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
450        MAX_STORE_BYTES / (1024 * 1024),
451        MAX_PACK_BYTES / (1024 * 1024)
452    )]
453    PushTooLarge {
454        /// Which cap was hit, human-readable.
455        detail: String,
456    },
457
458    /// The propose body exceeds the hub's inbox cap.
459    #[error(
460        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
461        MAX_PROPOSE_BYTES / 1024
462    )]
463    ProposeTooLarge {
464        /// The offending body size in bytes.
465        bytes: u64,
466    },
467
468    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
469    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
470    NotUtf8 {
471        /// The store-relative path of the offending file.
472        path: String,
473    },
474
475    /// A downloaded pack failed validation before any local write.
476    #[error("invalid store pack: {message}")]
477    InvalidPack {
478        /// Hash, ZIP, path, count, or expansion failure.
479        message: String,
480    },
481
482    /// A signed feed entry, hash chain, or advertised feed head did not verify.
483    #[error("invalid signed feed: {message}")]
484    InvalidFeed {
485        /// The failed integrity condition, without untrusted secret material.
486        message: String,
487    },
488
489    /// A mutable alias moved to another canonical brain. Ordinary sync fails
490    /// closed; only the exact old/new ids can authorize replacing that one
491    /// alias binding while preserving both canonical trust histories.
492    #[error(
493        "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}`"
494    )]
495    AliasRebindRequired {
496        alias: String,
497        from: String,
498        to: String,
499    },
500
501    /// Local and remote both changed one or more coordinates since the last
502    /// verified sync baseline. No side was overwritten.
503    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
504    Conflict {
505        /// Bounded, portable paths safe to show to the agent/operator.
506        paths: Vec<String>,
507    },
508
509    /// A readable v2 conflict was preserved as a private, exact-head bundle.
510    /// The bundle is local control state; none of its bytes ride with the
511    /// brain, and resolving it always creates a fresh explicit mutation.
512    #[error(
513        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
514    )]
515    ConflictBundle {
516        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
517        bundle: String,
518        /// Bounded, readable conflict paths.
519        paths: Vec<String>,
520    },
521
522    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
523    /// explicit adoption boundary, never an accidental side effect of editing
524    /// or removing a local policy file.
525    #[error(
526        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
527    )]
528    LocalPolicyTransition {
529        /// Bounded local-only paths. They are never sent in telemetry.
530        paths: Vec<String>,
531    },
532
533    /// A currently hosted asset would become withheld. That custody change
534    /// requires an exact coordinate and a bounded company audit reason; a
535    /// manifest edit or `.sevralocal` rule cannot imply it.
536    #[error(
537        "hosted assets require explicit withdrawal {paths:?} — retry with one --withdraw-from-hosting <path> per asset and a non-empty --withdraw-reason"
538    )]
539    AssetWithdrawalRequired {
540        /// Bounded asset paths. They are never sent in telemetry.
541        paths: Vec<String>,
542    },
543
544    /// The exact mutation crosses a permissioned bulk-impact boundary. The
545    /// hub has not committed it; `preview` is the bounded, permission-filtered
546    /// receipt an agent must inspect before explicitly confirming the same
547    /// request.
548    #[error(
549        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
550    )]
551    BulkPreviewRequired {
552        /// Stable structured preview returned by the hub.
553        preview: Value,
554    },
555
556    /// A scoped checkout's generated store marker was edited or removed.
557    /// It is local projection metadata and is never accepted as brain data.
558    #[error(
559        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
560    )]
561    ScopedProjectionModified,
562
563    /// The effective permission slice changed after this checkout was pinned.
564    /// Reusing the directory could conceal removals or accidentally adopt files
565    /// revealed by a wider grant, so a new checkout is required.
566    #[error(
567        "the checkout's permission scope changed — clone into a new directory to accept the new view"
568    )]
569    ScopedViewChanged,
570
571    /// A v2 identity/head was previously accepted for this ref, but the hub
572    /// now hides it. Never reinterpret that as a v1 downgrade.
573    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
574    BrainUnavailable,
575
576    /// The verified remote head advanced while a pull or post-commit barrier
577    /// was in flight. The old baseline is deliberately retained.
578    #[error(
579        "the remote brain advanced during sync — retry to converge from the new verified head"
580    )]
581    RemoteAdvancedDuringSync,
582
583    /// This build cannot provide the no-follow, directory-handle-relative
584    /// filesystem semantics required for trust/key/snapshot state.
585    #[error(
586        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
587    )]
588    UnsupportedPlatform {
589        /// The operation that requires hardened local filesystem primitives.
590        operation: &'static str,
591    },
592
593    /// Local filesystem failure while materializing a pull or reading a push.
594    #[error(transparent)]
595    Io(#[from] std::io::Error),
596
597    /// A store-level failure (walking the local store for a push).
598    #[error(transparent)]
599    Store(#[from] crate::StoreError),
600}
601
602/// Result alias for link.md client operations.
603pub type LinkResult<T> = std::result::Result<T, LinkError>;
604
605/// Exact approval token returned by a permissioned v2 bulk preview.
606#[derive(Debug, Clone, PartialEq, Eq)]
607pub struct V2BulkConfirmation {
608    /// Opaque preview receipt id.
609    pub id: String,
610    /// Digest binding the receipt to principal, head, control revision, and
611    /// the exact strict mutation.
612    pub digest: String,
613}
614
615impl V2BulkConfirmation {
616    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
617    /// outside the protocol's exact lowercase shapes before network I/O.
618    pub fn parse(value: &str) -> LinkResult<Self> {
619        let (id, digest) = value
620            .split_once(':')
621            .ok_or_else(|| LinkError::InvalidPack {
622                message: "bulk confirmation must be <id>:<digest>".to_string(),
623            })?;
624        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
625            return Err(LinkError::InvalidPack {
626                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
627                    .to_string(),
628            });
629        }
630        Ok(Self {
631            id: id.to_string(),
632            digest: digest.to_string(),
633        })
634    }
635}
636
637/// Security-sensitive link.md state and destination writes require a hardened
638/// descriptor/handle-relative filesystem implementation. Unix uses
639/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
640/// refusal, exclusive share modes, and write-through replacement.
641fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
642    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
643    {
644        let _ = operation;
645        Ok(())
646    }
647    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
648    {
649        Err(LinkError::UnsupportedPlatform { operation })
650    }
651}
652
653// ─────────────────────────────────────────────────────────────────────────────
654// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
655// ─────────────────────────────────────────────────────────────────────────────
656
657/// What the part after `@brain/` names.
658#[derive(Debug, Clone, PartialEq, Eq)]
659pub enum AddressTarget {
660    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
661    Id(String),
662    /// A store-relative `.md` path — a client-side convenience the hub's
663    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
664    /// shape; unambiguous because a ULID is never a path.
665    Path(String),
666}
667
668/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
669/// and the per-verb entry gates so the two surfaces never drift.
670const BAD_BRAIN_REASON: &str =
671    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
672
673/// Why an address target failed its shape check — shared by [`Address::parse`]
674/// and the [`resolve`] entry gate.
675const BAD_TARGET_REASON: &str =
676    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
677
678/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
679/// brain's ULID id (works for any caller, including cross-party on a public
680/// brain) or a slug (which a hub resolves only against the caller's own
681/// brains; slugs are unique per owner, not globally).
682#[derive(Debug, Clone, PartialEq, Eq)]
683pub struct Address {
684    /// The brain reference (leading `@` stripped).
685    pub brain: String,
686    /// The record target, when the address names one.
687    pub target: Option<AddressTarget>,
688}
689
690impl Address {
691    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
692    /// sigil is optional (an agent piping ids around should not have to quote
693    /// it back on). Whitespace and empty segments are malformed.
694    pub fn parse(raw: &str) -> LinkResult<Address> {
695        let bad = |reason: &str| LinkError::BadAddress {
696            given: raw.to_string(),
697            reason: reason.to_string(),
698        };
699
700        let trimmed = raw.trim();
701        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
702        if body.is_empty() {
703            return Err(bad("empty address"));
704        }
705
706        let (brain, rest) = match body.split_once('/') {
707            Some((b, r)) => (b, Some(r)),
708            None => (body, None),
709        };
710
711        if brain.is_empty() {
712            return Err(bad("missing brain reference before `/`"));
713        }
714        if !is_safe_ref(brain) {
715            return Err(bad(BAD_BRAIN_REASON));
716        }
717
718        let target = match rest {
719            None => None,
720            Some("") => return Err(bad("trailing `/` with no record id or path")),
721            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
722            Some(r) => {
723                if !safe_store_rel_path(r) || !r.ends_with(".md") {
724                    return Err(bad(BAD_TARGET_REASON));
725                }
726                Some(AddressTarget::Path(r.to_string()))
727            }
728        };
729
730        Ok(Address {
731            brain: brain.to_string(),
732            target,
733        })
734    }
735}
736
737/// A brain reference safe to embed in a URL path segment: the shapes a hub
738/// accepts (ULID id or slug), which are also exactly URL-path-clean.
739fn is_safe_ref(s: &str) -> bool {
740    !s.is_empty()
741        && s.len() <= 64
742        && s.bytes()
743            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
744}
745
746/// A published-site handle (the `propose` target). Same lexical shape as a
747/// slug.
748pub fn is_valid_handle(s: &str) -> bool {
749    is_safe_ref(s)
750}
751
752/// True when `p` is a store-relative path this client will read from or write
753/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
754/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
755/// every path an export hands us (the hub is not trusted with local layout)
756/// and to every path a push sends (mirroring the hub's own gate).
757pub fn safe_store_rel_path(p: &str) -> bool {
758    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
759        return false;
760    }
761    if !p
762        .bytes()
763        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
764    {
765        return false;
766    }
767    p.split('/')
768        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
769}
770
771/// Entry gate for every verb that embeds a caller-supplied brain reference in
772/// a URL path segment. `resolve` reaches the same check through
773/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
774/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
775/// URL-reshaping byte is refused before a request exists (the `url` crate
776/// normalizes dot segments, so an unvalidated ref would redirect the
777/// authenticated request to a different hub path).
778fn require_safe_ref(brain: &str) -> LinkResult<()> {
779    if is_safe_ref(brain) {
780        Ok(())
781    } else {
782        Err(LinkError::BadAddress {
783            given: brain.to_string(),
784            reason: BAD_BRAIN_REASON.to_string(),
785        })
786    }
787}
788
789/// Entry gate for the published-site handle `propose` embeds in its URL path.
790fn require_valid_handle(handle: &str) -> LinkResult<()> {
791    if is_valid_handle(handle) {
792        Ok(())
793    } else {
794        Err(LinkError::BadAddress {
795            given: handle.to_string(),
796            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
797        })
798    }
799}
800
801/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
802/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
803/// shape as a brain ref rather than pinning one mint scheme.
804fn require_safe_grant_id(id: &str) -> LinkResult<()> {
805    if is_safe_ref(id) {
806        Ok(())
807    } else {
808        Err(LinkError::BadGrantId {
809            given: id.to_string(),
810        })
811    }
812}
813
814// ─────────────────────────────────────────────────────────────────────────────
815// Configuration — flag > env > .dbmd/config; credential from env only
816// ─────────────────────────────────────────────────────────────────────────────
817
818/// The resolved client configuration for one invocation.
819#[derive(Debug, Clone)]
820pub struct HubConfig {
821    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
822    pub hub: String,
823    /// The bearer credential, when the environment carries one.
824    pub key: Option<String>,
825    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
826    /// over the bearer for authenticated requests (link.md §8).
827    pub agent_key: Option<AgentSigningKey>,
828    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
829    /// names one — `sync --push` then signs feed entries locally (§2.4).
830    pub brain_key: Option<AgentSigningKey>,
831    /// User-owned global toolkit state root. Identity pins and monotonic feed
832    /// checkpoints live below `<state_dir>/trust/`, never under a store.
833    pub state_dir: PathBuf,
834    /// True only when the origin came from untrusted store-local configuration.
835    /// Such origins are resolved and public-IP-pinned for every request.
836    store_selected: bool,
837}
838
839/// A loaded agent signing key: the PKCS#8 secret plus its derived public
840/// multikey. Debug never prints key material.
841#[derive(Clone)]
842pub struct AgentSigningKey {
843    pkcs8: Vec<u8>,
844    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
845    pub multikey: String,
846    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
847    pub public_key_spki: String,
848}
849
850impl std::fmt::Debug for AgentSigningKey {
851    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
852        f.debug_struct("AgentSigningKey")
853            .field("multikey", &self.multikey)
854            .field("pkcs8", &"<redacted>")
855            .finish()
856    }
857}
858
859impl HubConfig {
860    /// The credential, or the canonical "not configured" error. Verbs that
861    /// authenticate call this; `propose` never does.
862    pub fn require_key(&self) -> LinkResult<&str> {
863        self.key.as_deref().ok_or(LinkError::NoCredential)
864    }
865}
866
867/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
868/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
869/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
870/// header token (never echoed on failure).
871pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
872    let explicit_hub = flag_hub
873        .map(str::to_string)
874        .or_else(|| env_nonempty(HUB_URL_ENV));
875    let selected_by_store = explicit_hub.is_none();
876    let hub = explicit_hub
877        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
878        .ok_or(LinkError::NoHub)?;
879    let hub = hub.trim().trim_end_matches('/').to_string();
880    assert_safe_hub(&hub)?;
881    if selected_by_store {
882        let parsed =
883            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
884        // A cloned store is not an operator opt-in to local-network access.
885        // Local/private development hubs must be selected explicitly by flag or
886        // environment, never by bytes inside the store.
887        if !parsed.scheme().eq_ignore_ascii_case("https")
888            || (parsed.path() != "/" && !parsed.path().is_empty())
889        {
890            return Err(LinkError::UnsafeHub { hub });
891        }
892    }
893
894    let key = match env_nonempty(HUB_KEY_ENV) {
895        Some(raw) => Some(clean_key(&raw)?),
896        None => None,
897    };
898
899    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
900        Some(path) => Some(load_agent_key(Path::new(&path))?),
901        None => None,
902    };
903
904    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
905        Some(path) => Some(load_agent_key(Path::new(&path))?),
906        None => None,
907    };
908
909    // A cloned store controls `.dbmd/config`. It must never be able to point
910    // the process at an attacker origin and harvest the user's ambient account
911    // bearer, signed agent identity, or self-custodied brain signatures/content.
912    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
913    // in the invocation environment; a store-selected target additionally
914    // needs an exact origin binding.
915    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
916        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
917            .and_then(|value| normalized_origin(&value).ok());
918        let selected_origin = normalized_origin(&hub)?;
919        if bound.as_deref() != Some(selected_origin.as_str()) {
920            return Err(LinkError::UnboundCredential);
921        }
922    }
923
924    Ok(HubConfig {
925        hub,
926        key,
927        agent_key,
928        brain_key,
929        state_dir: toolkit_state_dir()?,
930        store_selected: selected_by_store,
931    })
932}
933
934fn toolkit_state_dir() -> LinkResult<PathBuf> {
935    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
936        let path = PathBuf::from(path);
937        if !path.is_absolute() {
938            return Err(LinkError::UnsafePath {
939                path: path.display().to_string(),
940            });
941        }
942        return Ok(path);
943    }
944    #[cfg(windows)]
945    if let Some(base) = env_nonempty("LOCALAPPDATA") {
946        let base = PathBuf::from(base);
947        if base.is_absolute() {
948            return Ok(base.join("dbmd").join("state"));
949        }
950    }
951    #[cfg(windows)]
952    {
953        Err(LinkError::Io(std::io::Error::new(
954            std::io::ErrorKind::NotFound,
955            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
956        )))
957    }
958    #[cfg(not(windows))]
959    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
960        let base = PathBuf::from(base);
961        if base.is_absolute() {
962            return Ok(base.join("dbmd"));
963        }
964    }
965    #[cfg(not(windows))]
966    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
967        LinkError::Io(std::io::Error::new(
968            std::io::ErrorKind::NotFound,
969            format!("cannot locate user state; set {STATE_DIR_ENV}"),
970        ))
971    })?);
972    #[cfg(not(windows))]
973    if !home.is_absolute() {
974        return Err(LinkError::UnsafePath {
975            path: home.display().to_string(),
976        });
977    }
978    #[cfg(target_os = "macos")]
979    {
980        Ok(home
981            .join("Library")
982            .join("Application Support")
983            .join("dbmd")
984            .join("state"))
985    }
986    #[cfg(all(not(target_os = "macos"), not(windows)))]
987    {
988        Ok(home.join(".local").join("state").join("dbmd"))
989    }
990}
991
992fn normalized_origin(value: &str) -> LinkResult<String> {
993    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
994        hub: value.to_string(),
995    })?;
996    if !(parsed.scheme().eq_ignore_ascii_case("https")
997        || parsed.scheme().eq_ignore_ascii_case("http"))
998        || !parsed.username().is_empty()
999        || parsed.password().is_some()
1000        || (parsed.path() != "/" && !parsed.path().is_empty())
1001        || parsed.query().is_some()
1002        || parsed.fragment().is_some()
1003    {
1004        return Err(LinkError::UnsafeHub {
1005            hub: value.to_string(),
1006        });
1007    }
1008    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
1009        hub: value.to_string(),
1010    })?;
1011    let host = if host.contains(':') {
1012        format!("[{host}]")
1013    } else {
1014        host.to_ascii_lowercase()
1015    };
1016    let port = parsed
1017        .port_or_known_default()
1018        .ok_or_else(|| LinkError::UnsafeHub {
1019            hub: value.to_string(),
1020        })?;
1021    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
1022        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
1023    Ok(format!(
1024        "{}://{}{}",
1025        parsed.scheme().to_ascii_lowercase(),
1026        host,
1027        if default {
1028            String::new()
1029        } else {
1030            format!(":{port}")
1031        }
1032    ))
1033}
1034
1035// ─────────────────────────────────────────────────────────────────────────────
1036// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
1037// ─────────────────────────────────────────────────────────────────────────────
1038
1039/// The DER prefix that wraps a raw Ed25519 public key into a
1040/// SubjectPublicKeyInfo (RFC 8410).
1041const ED25519_SPKI_PREFIX: [u8; 12] = [
1042    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1043];
1044
1045fn bad_agent_key(message: &str) -> LinkError {
1046    LinkError::BadAgentKey {
1047        message: message.to_string(),
1048    }
1049}
1050
1051fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1052    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
1053    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
1054    // deriving the public half itself.
1055    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1056        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1057        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1058}
1059
1060/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
1061fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1062    use ring::signature::KeyPair as _;
1063    let mut spki = Vec::with_capacity(44);
1064    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1065    spki.extend_from_slice(pair.public_key().as_ref());
1066    (
1067        URL_SAFE_NO_PAD.encode(&spki),
1068        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1069    )
1070}
1071
1072/// Load and validate a signing-key file (agent or brain — same format):
1073/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
1074/// old key explicitly.
1075pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1076    load_agent_key(path)
1077}
1078
1079/// Load and validate the agent key file: one base64url line of PKCS#8.
1080fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1081    #[cfg(unix)]
1082    let file = {
1083        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1084        use std::os::unix::ffi::OsStrExt as _;
1085        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1086            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1087        let leaf = path
1088            .file_name()
1089            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1090        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1091        let fd = unsafe {
1092            libc::openat(
1093                parent.as_raw_fd(),
1094                leaf.as_ptr(),
1095                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1096            )
1097        };
1098        if fd < 0 {
1099            return Err(bad_agent_key(
1100                "the key path must be an existing regular file without symlink ancestors",
1101            ));
1102        }
1103        unsafe { std::fs::File::from_raw_fd(fd) }
1104    };
1105    #[cfg(not(unix))]
1106    let file = std::fs::File::open(path)
1107        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1108    let metadata = file
1109        .metadata()
1110        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1111    if !metadata.is_file() {
1112        return Err(bad_agent_key("the key path must be a regular file"));
1113    }
1114    #[cfg(unix)]
1115    {
1116        use std::os::unix::fs::PermissionsExt as _;
1117        if metadata.permissions().mode() & 0o077 != 0 {
1118            return Err(bad_agent_key(
1119                "the key file is accessible to group/other; set mode 0600",
1120            ));
1121        }
1122    }
1123    let mut text = String::new();
1124    file.take(1024 * 1024 + 1)
1125        .read_to_string(&mut text)
1126        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1127    if text.len() > 1024 * 1024 {
1128        return Err(bad_agent_key("the key file exceeds the size limit"));
1129    }
1130    let pkcs8 = URL_SAFE_NO_PAD
1131        .decode(text.trim())
1132        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1133    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1134    Ok(AgentSigningKey {
1135        pkcs8,
1136        multikey,
1137        public_key_spki,
1138    })
1139}
1140
1141/// Durably create a new secret file without an exists→write race and without
1142/// ever exposing a default-mode (commonly 0644) key between write and chmod.
1143/// `create_new` also refuses a planted symlink. The file and its parent
1144/// directory are synced before the caller can publish the corresponding
1145/// public identity remotely.
1146fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1147    #[cfg(unix)]
1148    let (mut file, parent, leaf) = {
1149        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1150        use std::os::unix::ffi::OsStrExt as _;
1151        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1152        let leaf_name = path
1153            .file_name()
1154            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1155        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1156        let fd = unsafe {
1157            libc::openat(
1158                parent.as_raw_fd(),
1159                leaf.as_ptr(),
1160                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1161                0o600,
1162            )
1163        };
1164        if fd < 0 {
1165            let error = std::io::Error::last_os_error();
1166            if error.kind() == std::io::ErrorKind::AlreadyExists {
1167                return Err(bad_agent_key(
1168                    "the output file already exists — refusing to overwrite a key",
1169                ));
1170            }
1171            return Err(error.into());
1172        }
1173        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1174    };
1175    #[cfg(not(unix))]
1176    let mut file = std::fs::OpenOptions::new()
1177        .write(true)
1178        .create_new(true)
1179        .open(path)
1180        .map_err(|error| {
1181            if error.kind() == std::io::ErrorKind::AlreadyExists {
1182                bad_agent_key("the output file already exists — refusing to overwrite a key")
1183            } else {
1184                LinkError::Io(error)
1185            }
1186        })?;
1187    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1188        drop(file);
1189        #[cfg(unix)]
1190        let _ =
1191            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1192        #[cfg(not(unix))]
1193        let _ = std::fs::remove_file(path);
1194        return Err(LinkError::Io(error));
1195    }
1196    drop(file);
1197    #[cfg(unix)]
1198    parent.sync_all()?;
1199    Ok(())
1200}
1201
1202/// What `dbmd key generate` returns: the public identity to register plus
1203/// where the secret landed.
1204#[derive(Debug, Serialize)]
1205pub struct GeneratedAgentKey {
1206    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1207    pub multikey: String,
1208    /// base64url SPKI DER — what a hub's register endpoint takes.
1209    #[serde(rename = "publicKeySpki")]
1210    pub public_key_spki: String,
1211    /// Where the PKCS#8 secret was written (mode 0600).
1212    #[serde(rename = "keyFile")]
1213    pub key_file: String,
1214}
1215
1216/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1217/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1218/// identity is returned. The private key never enters a store and never
1219/// travels — requests carry per-request signatures instead (link.md §8).
1220pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1221    require_hardened_filesystem("key generation")?;
1222    let rng = ring::rand::SystemRandom::new();
1223    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1224        .map_err(|_| bad_agent_key("key generation failed"))?;
1225    let pair = agent_keypair(pkcs8.as_ref())?;
1226    let (spki_b64u, multikey) = public_identity_for(&pair);
1227
1228    write_secret_new(
1229        out,
1230        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1231    )?;
1232
1233    Ok(GeneratedAgentKey {
1234        multikey,
1235        public_key_spki: spki_b64u,
1236        key_file: out.display().to_string(),
1237    })
1238}
1239
1240/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1241/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1242/// (sha256hex(body) | "-")`.
1243///
1244/// The origin is derived from the already-validated hub URL, never from
1245/// attacker-controlled request metadata. Binding it closes the v1 replay
1246/// class where a proof captured by one hub could be replayed to another hub
1247/// serving the same path inside the timestamp window.
1248fn linkmd_sig_header(
1249    key: &AgentSigningKey,
1250    origin: &str,
1251    method: &str,
1252    path: &str,
1253    body: Option<&str>,
1254) -> LinkResult<String> {
1255    let ts = std::time::SystemTime::now()
1256        .duration_since(std::time::UNIX_EPOCH)
1257        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1258        .as_secs();
1259    let body_hash = match body {
1260        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1261        None => "-".to_string(),
1262    };
1263    let canonical = format!(
1264        "v2\n{}\n{}\n{}\n{}\n{}",
1265        origin,
1266        method.to_uppercase(),
1267        path,
1268        ts,
1269        body_hash
1270    );
1271    let pair = agent_keypair(&key.pkcs8)?;
1272    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1273    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1274    Ok(format!(
1275        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1276    ))
1277}
1278
1279// ─────────────────────────────────────────────────────────────────────────────
1280// Self-custody feed entries — the client signs what the hub only verifies
1281// ─────────────────────────────────────────────────────────────────────────────
1282
1283/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1284/// exactly this order).
1285#[derive(Serialize)]
1286struct WireFeedFile {
1287    path: String,
1288    sha256: String,
1289    bytes: u64,
1290}
1291
1292/// The unsigned entry in the normative §5.1 field order — serde serializes
1293/// struct fields in declaration order, which IS the wire contract.
1294#[derive(Serialize)]
1295struct UnsignedWireEntry<'a> {
1296    v: u8,
1297    seq: u64,
1298    ts: String,
1299    brain: &'a str,
1300    public_key: &'a str,
1301    kind: &'a str,
1302    op: &'a str,
1303    pack_sha256: &'a str,
1304    files: &'a [WireFeedFile],
1305    removed: &'a [String],
1306    prev_entry_hash: Option<&'a str>,
1307}
1308
1309/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1310/// brain key: serialize the unsigned entry compactly in the normative order,
1311/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1312/// returned string is the exact serialization the hub stores verbatim (plus
1313/// one trailing newline) and every independent reader re-derives.
1314fn self_custody_entry(
1315    key: &AgentSigningKey,
1316    seq: u64,
1317    ts: String,
1318    pack_sha256: &str,
1319    files: &[WireFeedFile],
1320    prev_entry_hash: Option<&str>,
1321) -> LinkResult<String> {
1322    let removed: [String; 0] = [];
1323    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1324        v: 1,
1325        seq,
1326        ts,
1327        brain: &key.multikey,
1328        public_key: &key.public_key_spki,
1329        kind: "push",
1330        op: "snapshot",
1331        pack_sha256,
1332        files,
1333        removed: &removed,
1334        prev_entry_hash,
1335    })
1336    .expect("serialize feed entry");
1337    let pair = agent_keypair(&key.pkcs8)?;
1338    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1339    Ok(format!(
1340        "{},\"sig\":\"{}\"}}",
1341        &unsigned[..unsigned.len() - 1],
1342        sig
1343    ))
1344}
1345
1346/// An env var, treated as absent when unset or empty (an empty
1347/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1348fn env_nonempty(name: &str) -> Option<String> {
1349    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1350}
1351
1352/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1353/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1354/// ignored (forward-compatible). A missing or unreadable file is simply "not
1355/// configured here".
1356fn config_file_hub(path: &Path) -> Option<String> {
1357    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1358    #[cfg(unix)]
1359    let file = {
1360        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1361        use std::os::unix::ffi::OsStrExt as _;
1362        let parent =
1363            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1364        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1365        let fd = unsafe {
1366            libc::openat(
1367                parent.as_raw_fd(),
1368                leaf.as_ptr(),
1369                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1370            )
1371        };
1372        if fd < 0 {
1373            return None;
1374        }
1375        unsafe { std::fs::File::from_raw_fd(fd) }
1376    };
1377    #[cfg(not(unix))]
1378    let file = std::fs::File::open(path).ok()?;
1379    let metadata = file.metadata().ok()?;
1380    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1381        return None;
1382    }
1383    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1384    file.take(MAX_CONFIG_BYTES + 1)
1385        .read_to_end(&mut bytes)
1386        .ok()?;
1387    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1388        return None;
1389    }
1390    let text = String::from_utf8(bytes).ok()?;
1391    for line in text.lines() {
1392        let line = line.trim();
1393        if line.is_empty() || line.starts_with('#') {
1394            continue;
1395        }
1396        if let Some((k, v)) = line.split_once('=') {
1397            if k.trim() == "hub" {
1398                let v = v.trim();
1399                if !v.is_empty() {
1400                    return Some(v.to_string());
1401                }
1402            }
1403        }
1404    }
1405    None
1406}
1407
1408/// The bearer key must never travel in cleartext; only loopback hosts may
1409/// skip TLS (local development against a hub on localhost).
1410fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1411    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1412        hub: hub.to_string(),
1413    })?;
1414    if !(parsed.scheme().eq_ignore_ascii_case("https")
1415        || parsed.scheme().eq_ignore_ascii_case("http"))
1416        || !parsed.username().is_empty()
1417        || parsed.password().is_some()
1418        || (parsed.path() != "/" && !parsed.path().is_empty())
1419        || parsed.query().is_some()
1420        || parsed.fragment().is_some()
1421    {
1422        return Err(LinkError::UnsafeHub {
1423            hub: hub.to_string(),
1424        });
1425    }
1426    let loopback = match parsed.host() {
1427        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1428        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1429        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1430        None => false,
1431    };
1432    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1433        Ok(())
1434    } else {
1435        Err(LinkError::UnsafeHub {
1436            hub: hub.to_string(),
1437        })
1438    }
1439}
1440
1441/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1442/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1443/// value tends to echo the whole header line, credential included, so the
1444/// gate sits here instead.
1445fn clean_key(raw: &str) -> LinkResult<String> {
1446    let k = raw.trim();
1447    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1448        return Err(LinkError::BadKey);
1449    }
1450    Ok(k.to_string())
1451}
1452
1453// ─────────────────────────────────────────────────────────────────────────────
1454// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1455// ─────────────────────────────────────────────────────────────────────────────
1456
1457/// One hub response: the status plus the parsed JSON body when there was one.
1458#[derive(Debug)]
1459pub struct HubResponse {
1460    /// The HTTP status code.
1461    pub status: u16,
1462    /// The parsed JSON body, `None` when the body was empty or not JSON.
1463    pub body: Option<Value>,
1464}
1465
1466struct RawHubResponse {
1467    status: u16,
1468    body: Vec<u8>,
1469}
1470
1471/// Whether a request carries the bearer credential.
1472#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1473enum Auth {
1474    /// Send `authorization: Bearer <key>`; error without a key.
1475    Required,
1476    /// Send no credential — the propose door is unauthenticated by design.
1477    None,
1478    /// Send the configured credential when one exists, otherwise nothing —
1479    /// brain-addressed propose works anonymously on public brains, and an
1480    /// authenticated caller earns a bigger actor-class budget.
1481    Optional,
1482}
1483
1484fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1485    ureq::AgentBuilder::new()
1486        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1487        // Never follow a redirect while a bearer, a store pack, or a signed
1488        // response is in flight. Callers see the 3xx as a non-success instead
1489        // of letting an origin steer sensitive material elsewhere.
1490        .redirects(0)
1491        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1492        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1493        .timeout_write(overall)
1494        .timeout(overall)
1495}
1496
1497fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1498    hub_agent_with_timeout(
1499        cfg,
1500        std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1501    )
1502}
1503
1504fn hub_agent_with_timeout(
1505    cfg: &HubConfig,
1506    overall: std::time::Duration,
1507) -> LinkResult<ureq::Agent> {
1508    if !cfg.store_selected {
1509        return Ok(agent_builder_with_timeout(overall).build());
1510    }
1511    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1512        hub: cfg.hub.clone(),
1513    })?;
1514    pinned_public_agent_pooled(
1515        &parsed,
1516        false,
1517        "store-selected hub",
1518        AgentShape {
1519            overall,
1520            ..AgentShape::default()
1521        },
1522    )
1523}
1524
1525/// Perform one hub request. `path` is the binding path (starts with `/`);
1526/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1527/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1528/// the [`HubResponse`] for [`ensure_ok`] to shape.
1529fn request_raw(
1530    cfg: &HubConfig,
1531    method: &str,
1532    path: &str,
1533    body: Option<&Value>,
1534    auth: Auth,
1535    max_response_bytes: u64,
1536) -> LinkResult<RawHubResponse> {
1537    let http = hub_agent(cfg)?;
1538    request_raw_with_agent(
1539        cfg,
1540        &http,
1541        method,
1542        path,
1543        body,
1544        RawRequestOptions {
1545            auth,
1546            max_response_bytes,
1547            request_id: None,
1548            retry_transport: false,
1549        },
1550    )
1551}
1552
1553/// A body-bearing request that reads immutable state without changing hub
1554/// authority. Some proof lists are too large for a URL, so link.md transports
1555/// them with POST even though repeating the request is side-effect free.
1556fn request_raw_retryable_read(
1557    cfg: &HubConfig,
1558    method: &str,
1559    path: &str,
1560    body: Option<&Value>,
1561    auth: Auth,
1562    max_response_bytes: u64,
1563) -> LinkResult<RawHubResponse> {
1564    let http = hub_agent(cfg)?;
1565    request_raw_with_agent(
1566        cfg,
1567        &http,
1568        method,
1569        path,
1570        body,
1571        RawRequestOptions {
1572            auth,
1573            max_response_bytes,
1574            request_id: None,
1575            retry_transport: true,
1576        },
1577    )
1578}
1579
1580struct RawRequestOptions<'a> {
1581    auth: Auth,
1582    max_response_bytes: u64,
1583    request_id: Option<&'a str>,
1584    retry_transport: bool,
1585}
1586
1587fn request_raw_with_agent(
1588    cfg: &HubConfig,
1589    http: &ureq::Agent,
1590    method: &str,
1591    path: &str,
1592    body: Option<&Value>,
1593    options: RawRequestOptions<'_>,
1594) -> LinkResult<RawHubResponse> {
1595    let url = format!("{}{}", cfg.hub, path);
1596    let encoded_body = body.map(Value::to_string);
1597    let origin = normalized_origin(&cfg.hub)?;
1598    let safe_read = (method == "GET" && encoded_body.is_none()) || options.retry_transport;
1599    let mut read_attempt = 0;
1600    loop {
1601        // An agent signing key outranks the bearer: possession proofs put
1602        // nothing reusable on the wire, so when both are configured the
1603        // stronger one wins. Regenerate its timestamp for a whole-request GET
1604        // retry; a long stalled read must not age the replacement signature
1605        // outside the hub's acceptance window. This applies only to GETs and
1606        // the explicitly marked read-only POST bindings above.
1607        let credential = match options.auth {
1608            Auth::Required => Some(match &cfg.agent_key {
1609                Some(key) => {
1610                    linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?
1611                }
1612                None => format!("Bearer {}", cfg.require_key()?),
1613            }),
1614            Auth::Optional => match &cfg.agent_key {
1615                Some(key) => Some(linkmd_sig_header(
1616                    key,
1617                    &origin,
1618                    method,
1619                    path,
1620                    encoded_body.as_deref(),
1621                )?),
1622                None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1623            },
1624            Auth::None => None,
1625        };
1626        let result = with_connect_retries(|| {
1627            let mut req = http.request(method, &url);
1628            if let Some(value) = &credential {
1629                req = req.set("authorization", value);
1630            }
1631            if let Some(value) = options.request_id {
1632                req = req.set("x-request-id", value);
1633            }
1634            match &encoded_body {
1635                Some(value) => req
1636                    .set("content-type", "application/json")
1637                    .send_string(value)
1638                    .map_err(Box::new),
1639                None => req.call().map_err(Box::new),
1640            }
1641        });
1642        let resp = match result {
1643            Ok(resp) => resp,
1644            Err(error) => match *error {
1645                ureq::Error::Status(_, resp) => resp,
1646                ureq::Error::Transport(error) => {
1647                    if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS {
1648                        std::thread::sleep(std::time::Duration::from_millis(
1649                            SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1650                        ));
1651                        read_attempt += 1;
1652                        continue;
1653                    }
1654                    return Err(LinkError::Transport {
1655                        hub: cfg.hub.clone(),
1656                        message: error.to_string(),
1657                    });
1658                }
1659            },
1660        };
1661
1662        let status = resp.status();
1663        let buf = match read_response_body(resp, options.max_response_bytes + 1, &cfg.hub) {
1664            Ok(buf) => buf,
1665            Err(LinkError::Transport { .. })
1666                if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS =>
1667            {
1668                std::thread::sleep(std::time::Duration::from_millis(
1669                    SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1670                ));
1671                read_attempt += 1;
1672                continue;
1673            }
1674            Err(error) => return Err(error),
1675        };
1676        if buf.len() as u64 > options.max_response_bytes {
1677            return Err(LinkError::ResponseTooLarge {
1678                limit_bytes: options.max_response_bytes,
1679            });
1680        }
1681        return Ok(RawHubResponse { status, body: buf });
1682    }
1683}
1684
1685fn request_capped(
1686    cfg: &HubConfig,
1687    method: &str,
1688    path: &str,
1689    body: Option<&Value>,
1690    auth: Auth,
1691    max_response_bytes: u64,
1692) -> LinkResult<HubResponse> {
1693    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1694    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1695    Ok(HubResponse {
1696        status: raw.status,
1697        body: parsed,
1698    })
1699}
1700
1701/// One request the hub is allowed to take brain-sized time over. Used for the
1702/// commit lane, where abandoning the attempt early does not cancel the work —
1703/// it only loses the receipt for it.
1704///
1705/// Which is why a lost connection is asked again rather than reported. The
1706/// mutation coordinate is derived from the observed base and the exact
1707/// operation set precisely so a repeat is the SAME effect: the hub answers a
1708/// mutation it already applied with that commit's receipt instead of a second
1709/// commit. Measured on a 32,338-file store: the commit landed durably and the
1710/// push reported a transport failure, because nobody went back for the
1711/// receipt.
1712fn request_patient(
1713    cfg: &HubConfig,
1714    method: &str,
1715    path: &str,
1716    body: Option<&Value>,
1717    auth: Auth,
1718) -> LinkResult<HubResponse> {
1719    let http = hub_agent_with_timeout(
1720        cfg,
1721        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1722    )?;
1723    let mut attempt = 0;
1724    let validation_started = std::time::Instant::now();
1725    let mut validation_wait = std::time::Duration::from_millis(250);
1726    loop {
1727        let sent = request_raw_with_agent(
1728            cfg,
1729            &http,
1730            method,
1731            path,
1732            body,
1733            RawRequestOptions {
1734                auth,
1735                max_response_bytes: MAX_RESPONSE_BYTES,
1736                request_id: None,
1737                retry_transport: false,
1738            },
1739        );
1740        match sent {
1741            Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1742                std::thread::sleep(std::time::Duration::from_millis(
1743                    COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1744                ));
1745                attempt += 1;
1746            }
1747            Err(error) => return Err(error),
1748            Ok(raw) => {
1749                let response = HubResponse {
1750                    status: raw.status,
1751                    body: serde_json::from_slice(&raw.body).ok(),
1752                };
1753                // A patient mutation can land, lose its response, and then be
1754                // retried while validation recovery is still behind the new
1755                // source head. The hub returns the same typed lag signal as
1756                // the head endpoint. Repeating the exact content-derived
1757                // mutation id is idempotent and is the only way to recover its
1758                // receipt without guessing whether the first attempt landed.
1759                if v2_validation_catching_up(&response) {
1760                    if validation_started.elapsed() >= std::time::Duration::from_secs(15 * 60) {
1761                        return Err(LinkError::Http {
1762                            what: "v2 commit receipt",
1763                            status: response.status,
1764                            message: "validation/index recovery did not make the exact mutation receipt available within 15 minutes".to_string(),
1765                            code: Some("validation_index_catching_up".to_string()),
1766                            details: response.body,
1767                        });
1768                    }
1769                    std::thread::sleep(validation_wait);
1770                    validation_wait = validation_wait
1771                        .saturating_mul(2)
1772                        .min(std::time::Duration::from_secs(5));
1773                    continue;
1774                }
1775                return Ok(response);
1776            }
1777        }
1778    }
1779}
1780
1781fn request(
1782    cfg: &HubConfig,
1783    method: &str,
1784    path: &str,
1785    body: Option<&Value>,
1786    auth: Auth,
1787) -> LinkResult<HubResponse> {
1788    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1789}
1790
1791/// Repeat one challenge-response mutation with the exact request identity the
1792/// hub bound into its actor claim. A new request id would create a new actor
1793/// hash and therefore a different canonical commit candidate, making the
1794/// otherwise correct self-custody signature unusable.
1795fn request_with_request_id(
1796    cfg: &HubConfig,
1797    method: &str,
1798    path: &str,
1799    body: Option<&Value>,
1800    auth: Auth,
1801    request_id: &str,
1802) -> LinkResult<HubResponse> {
1803    if request_id.is_empty()
1804        || request_id.len() > 128
1805        || !request_id
1806            .bytes()
1807            .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1808    {
1809        return Err(invalid_feed("hub returned an unsafe request id"));
1810    }
1811    // Both callers are commit responses, so this shares the commit lane's
1812    // patience: the hub is finishing the same brain-sized work either way.
1813    let http = hub_agent_with_timeout(
1814        cfg,
1815        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1816    )?;
1817    let raw = request_raw_with_agent(
1818        cfg,
1819        &http,
1820        method,
1821        path,
1822        body,
1823        RawRequestOptions {
1824            auth,
1825            max_response_bytes: MAX_RESPONSE_BYTES,
1826            request_id: Some(request_id),
1827            retry_transport: false,
1828        },
1829    )?;
1830    Ok(HubResponse {
1831        status: raw.status,
1832        body: serde_json::from_slice(&raw.body).ok(),
1833    })
1834}
1835
1836fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1837    if (200..300).contains(&r.status) {
1838        return Ok(r.body);
1839    }
1840    ensure_ok(
1841        HubResponse {
1842            status: r.status,
1843            body: serde_json::from_slice(&r.body).ok(),
1844        },
1845        what,
1846    )
1847    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1848}
1849
1850/// These failures happen before any HTTP request reaches the hub, so retrying
1851/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1852/// bytes may have crossed the wire, the caller must rely on the verb's own
1853/// idempotency contract instead of guessing.
1854fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1855    matches!(
1856        kind,
1857        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1858    )
1859}
1860
1861fn with_connect_retries(
1862    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1863) -> Result<ureq::Response, Box<ureq::Error>> {
1864    let mut attempt = 0;
1865    loop {
1866        match send() {
1867            Err(error)
1868                if matches!(
1869                    error.as_ref(),
1870                    ureq::Error::Transport(transport)
1871                        if is_pre_request_transport(transport.kind())
1872                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1873            {
1874                std::thread::sleep(std::time::Duration::from_millis(
1875                    CONNECT_RETRY_BACKOFF_MS[attempt],
1876                ));
1877                attempt += 1;
1878            }
1879            result => return result,
1880        }
1881    }
1882}
1883
1884fn hub_is_loopback(hub: &str) -> bool {
1885    url::Url::parse(hub).ok().is_some_and(|parsed| {
1886        parsed.host().is_some_and(|host| match host {
1887            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1888            url::Host::Ipv4(ip) => ip.is_loopback(),
1889            url::Host::Ipv6(ip) => ip.is_loopback(),
1890        })
1891    })
1892}
1893
1894/// Every safety property a presigned object-store URL must carry, checked
1895/// before anything is sent to it. Split out from agent construction so a batch
1896/// can share one connection pool without ever skipping a per-URL check.
1897fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1898    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1899        message: "the hub returned an invalid object-store URL".to_string(),
1900    })?;
1901    let allow_private = hub_is_loopback(&cfg.hub)
1902        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1903    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1904        || !parsed.username().is_empty()
1905        || parsed.password().is_some()
1906        || parsed.fragment().is_some()
1907    {
1908        return Err(LinkError::InvalidPack {
1909            message: "the hub returned an unsafe object-store URL".to_string(),
1910        });
1911    }
1912    Ok((parsed, allow_private))
1913}
1914
1915fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1916    let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1917    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1918        LinkError::InvalidPack {
1919            message: "the hub returned an object-store URL with an unsafe network target"
1920                .to_string(),
1921        }
1922    })
1923}
1924
1925/// One pinned agent for a whole staging batch.
1926///
1927/// Every object in a batch is presigned against the same object-store
1928/// authority, so one agent can serve them all — and must: building one per
1929/// object costs a DNS resolution and a fresh TLS handshake per object, which is
1930/// the difference between a real store staging in minutes and in an hour. The
1931/// shared authority is verified rather than assumed, and a batch that ever
1932/// spans two of them falls back to a per-object agent.
1933fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1934    let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1935    let authority = (
1936        first.host_str()?.to_string(),
1937        first.port_or_known_default()?,
1938    );
1939    for raw in &urls[1..] {
1940        let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1941        if (parsed.host_str()?, parsed.port_or_known_default()?)
1942            != (authority.0.as_str(), authority.1)
1943        {
1944            return None;
1945        }
1946    }
1947    pinned_public_agent_pooled(
1948        &first,
1949        allow_private,
1950        "object-store URL",
1951        AgentShape {
1952            idle_per_host: V2_UPLOAD_CONCURRENCY,
1953            ..AgentShape::default()
1954        },
1955    )
1956    .ok()
1957}
1958
1959/// Turn an object-store transport failure into a useful but credential-safe
1960/// diagnostic. `ureq::Transport`'s Display implementation includes the full
1961/// request URL; for a presigned URL that means the short-lived signature and
1962/// every signed query coordinate. The error kind is enough to distinguish a
1963/// network failure without copying bearer material into terminals or logs.
1964fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1965    LinkError::Transport {
1966        hub: "the object store".to_string(),
1967        message: format!("network error ({:?})", error.kind()),
1968    }
1969}
1970
1971fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1972    let http = presigned_agent(cfg, raw)?;
1973    let deadline = std::time::Instant::now()
1974        .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1975        .ok_or_else(upload_deadline_error)?;
1976    let mut attempt = 0;
1977    let result = loop {
1978        // The reservation states content-length itself. This client sends the
1979        // body from memory, so it never needs to add one, and adding one under
1980        // a different capitalization would leave both on the wire.
1981        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1982        if let Some(map) = headers.as_object() {
1983            for (name, value) in map {
1984                if let Some(value) = value.as_str() {
1985                    req = req.set(name, value);
1986                }
1987            }
1988        }
1989        match req.send_bytes(bytes) {
1990            // An upload is idempotent here: fixed content address, exact
1991            // length and checksum, write-once precondition. A dropped
1992            // connection or a single overloaded refusal is worth replaying
1993            // rather than failing a whole migration on. 412 already means
1994            // "someone else stored these exact bytes".
1995            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1996                attempt += 1;
1997            }
1998            Err(ureq::Error::Status(status, _))
1999                if status != 412
2000                    && is_retryable_upload_status(status)
2001                    && wait_for_upload_retry(deadline, attempt) =>
2002            {
2003                attempt += 1;
2004            }
2005            result => break result,
2006        }
2007    };
2008    match result {
2009        Ok(resp) if (200..300).contains(&resp.status()) => {
2010            drain_presigned_response(resp);
2011            Ok(())
2012        }
2013        Ok(resp) => Err(presigned_upload_refusal(resp)),
2014        Err(error) => match error {
2015            // Immutable uploads use If-None-Match. A concurrent writer may
2016            // win the same content address; the commit/confirm endpoint reads
2017            // and hashes that object before accepting it, so 412 safely means
2018            // "continue to verification", never "trust the upload".
2019            ureq::Error::Status(412, _) => Ok(()),
2020            ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
2021            ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
2022        },
2023    }
2024}
2025
2026/// Read a response body, typed as the transport failure it is.
2027///
2028/// A connection that fails while the body is still arriving is the same class
2029/// of problem as one that fails before it arrives at all — but a bare `?` on
2030/// the read turned it into an anonymous io error that no retry path recognised
2031/// and no message attributed to a peer. On a migration making tens of
2032/// thousands of requests, one stalled body ended the whole push and reported
2033/// only "timed out reading response".
2034fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
2035    let mut buf = Vec::new();
2036    response
2037        .into_reader()
2038        .take(limit)
2039        .read_to_end(&mut buf)
2040        .map_err(|error| LinkError::Transport {
2041            hub: peer.to_string(),
2042            message: error.to_string(),
2043        })?;
2044    Ok(buf)
2045}
2046
2047/// Draining a response is what returns its connection to the pool: the client
2048/// cannot recycle a socket whose body may still have bytes pending, so a body
2049/// that is dropped instead of read costs the next object a fresh handshake.
2050/// An object-store PUT answers with almost nothing, so this is nearly free.
2051fn drain_presigned_response(response: ureq::Response) {
2052    let mut reader = response.into_reader().take(64 * 1024);
2053    let _ = std::io::copy(&mut reader, &mut std::io::sink());
2054}
2055
2056/// Carry the object store's own reason for a refusal. An unexplained status is
2057/// unactionable; the body states checksum mismatch, precondition, or expiry.
2058fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
2059    let status = response.status();
2060    let detail = response
2061        .into_string()
2062        .ok()
2063        .map(|body| body.chars().take(400).collect::<String>())
2064        .filter(|body| !body.trim().is_empty());
2065    LinkError::Http {
2066        what: "pack upload",
2067        status,
2068        message: match detail {
2069            Some(body) => format!(
2070                "object store rejected the upload: {}",
2071                body.replace('\n', " ")
2072            ),
2073            None => "object store rejected the upload".to_string(),
2074        },
2075        code: None,
2076        details: None,
2077    }
2078}
2079
2080fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
2081    max_bytes.checked_add(1)
2082}
2083
2084fn presigned_download_read_limit() -> u64 {
2085    one_past_bounded_limit(MAX_PACK_BYTES)
2086        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
2087}
2088
2089fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
2090    let http = presigned_agent(cfg, raw)?;
2091    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
2092        Ok(resp) => resp,
2093        Err(error) => match *error {
2094            ureq::Error::Status(_, resp) => {
2095                return Err(LinkError::Http {
2096                    what: "pack download",
2097                    status: resp.status(),
2098                    message: "object store rejected the download".to_string(),
2099                    code: None,
2100                    details: None,
2101                });
2102            }
2103            ureq::Error::Transport(err) => {
2104                return Err(LinkError::Transport {
2105                    hub: "the object store".to_string(),
2106                    message: err.to_string(),
2107                });
2108            }
2109        },
2110    };
2111    if !(200..300).contains(&resp.status()) {
2112        return Err(LinkError::Http {
2113            what: "pack download",
2114            status: resp.status(),
2115            message: "object store rejected the download".to_string(),
2116            code: None,
2117            details: None,
2118        });
2119    }
2120    let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2121    if bytes.len() as u64 > MAX_PACK_BYTES {
2122        return Err(LinkError::InvalidPack {
2123            message: "download exceeds the compressed-size limit".to_string(),
2124        });
2125    }
2126    Ok(bytes)
2127}
2128
2129/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
2130/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
2131/// answer.
2132fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2133    if !(200..300).contains(&r.status) {
2134        let message = r
2135            .body
2136            .as_ref()
2137            .and_then(|b| b.get("error"))
2138            .and_then(Value::as_str)
2139            .unwrap_or("unknown error")
2140            .to_string();
2141        let code = r
2142            .body
2143            .as_ref()
2144            .and_then(|b| b.get("code"))
2145            .and_then(Value::as_str)
2146            .map(str::to_string);
2147        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2148        return Err(LinkError::Http {
2149            what,
2150            status: r.status,
2151            message,
2152            code,
2153            details,
2154        });
2155    }
2156    r.body.ok_or(LinkError::NotJson {
2157        what,
2158        status: r.status,
2159    })
2160}
2161
2162// ─────────────────────────────────────────────────────────────────────────────
2163// resolve — handle → brain card; @brain/id → the record
2164// ─────────────────────────────────────────────────────────────────────────────
2165
2166/// Resolve an address. A bare `@brain` returns the brain card (metadata +
2167/// index stats — the v0 form of the card; keys arrive with the protocol's
2168/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
2169/// record, frontmatter + body.
2170fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2171    match ip {
2172        std::net::IpAddr::V4(ip) => {
2173            let [a, b, c, _] = ip.octets();
2174            !(a == 0
2175                || a == 10
2176                || a == 127
2177                || (a == 100 && (64..=127).contains(&b))
2178                || (a == 169 && b == 254)
2179                || (a == 172 && (16..=31).contains(&b))
2180                || (a == 192 && b == 0 && c == 0)
2181                || (a == 192 && b == 0 && c == 2)
2182                || (a == 192 && b == 88 && c == 99)
2183                || (a == 192 && b == 168)
2184                || (a == 198 && (b == 18 || b == 19))
2185                || (a == 198 && b == 51 && c == 100)
2186                || (a == 203 && b == 0 && c == 113)
2187                || a >= 224)
2188        }
2189        std::net::IpAddr::V6(ip) => {
2190            let segments = ip.segments();
2191            // Conservatively accept only global unicast 2000::/3, excluding
2192            // special-purpose/transition blocks. In particular, 6to4 embeds
2193            // an IPv4 destination and must not tunnel a validated public
2194            // connect to 127/8 or RFC1918.
2195            (segments[0] & 0xe000) == 0x2000
2196                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2197                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2198                && segments[0] != 0x2002
2199                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2200        }
2201    }
2202}
2203
2204#[derive(Clone)]
2205struct PinnedRegistryResolver {
2206    netloc: String,
2207    addresses: Vec<std::net::SocketAddr>,
2208}
2209
2210impl ureq::Resolver for PinnedRegistryResolver {
2211    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2212        if requested == self.netloc {
2213            Ok(self.addresses.clone())
2214        } else {
2215            Err(std::io::Error::new(
2216                std::io::ErrorKind::PermissionDenied,
2217                "registry request attempted to resolve an unvalidated authority",
2218            ))
2219        }
2220    }
2221}
2222
2223fn pinned_public_agent(
2224    url: &url::Url,
2225    allow_private: bool,
2226    label: &str,
2227) -> LinkResult<ureq::Agent> {
2228    pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2229}
2230
2231/// How one agent is built: `idle_per_host` sizes the connection pool (the
2232/// default of one is right for a lone request but throttles a concurrent batch
2233/// back to a single warm connection), and `overall` is the wall-clock budget
2234/// for one attempt.
2235struct AgentShape {
2236    idle_per_host: usize,
2237    overall: std::time::Duration,
2238}
2239
2240impl Default for AgentShape {
2241    fn default() -> Self {
2242        Self {
2243            idle_per_host: 1,
2244            overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2245        }
2246    }
2247}
2248
2249fn pinned_public_agent_pooled(
2250    url: &url::Url,
2251    allow_private: bool,
2252    label: &str,
2253    shape: AgentShape,
2254) -> LinkResult<ureq::Agent> {
2255    let host = url
2256        .host_str()
2257        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2258    let port = url
2259        .port_or_known_default()
2260        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2261    let addresses = resolve_addresses_with_deadline(
2262        host,
2263        port,
2264        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2265    )
2266    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2267    if addresses.is_empty() {
2268        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2269    }
2270    if !allow_private
2271        && addresses
2272            .iter()
2273            .any(|address| !is_public_registry_ip(address.ip()))
2274    {
2275        return Err(invalid_feed(format!(
2276            "{label} resolves to a non-public address"
2277        )));
2278    }
2279    let netloc = if host.contains(':') {
2280        format!("[{host}]:{port}")
2281    } else {
2282        format!("{host}:{port}")
2283    };
2284    Ok(agent_builder_with_timeout(shape.overall)
2285        .max_idle_connections_per_host(shape.idle_per_host.max(1))
2286        .resolver(PinnedRegistryResolver { netloc, addresses })
2287        .build())
2288}
2289
2290/// Resolve one authority without allowing libc DNS to hold the caller forever.
2291/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
2292/// worker and only its result channel is awaited. A late resolver result has no
2293/// side effects and is discarded after the deadline.
2294fn resolve_addresses_with_deadline(
2295    host: &str,
2296    port: u16,
2297    timeout: std::time::Duration,
2298) -> std::io::Result<Vec<std::net::SocketAddr>> {
2299    use std::net::ToSocketAddrs as _;
2300
2301    let host = host.to_string();
2302    let (send, receive) = std::sync::mpsc::sync_channel(1);
2303    std::thread::Builder::new()
2304        .name("dbmd-dns".to_string())
2305        .spawn(move || {
2306            let result = (host.as_str(), port)
2307                .to_socket_addrs()
2308                .map(|addresses| addresses.collect());
2309            let _ = send.send(result);
2310        })
2311        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2312    match receive.recv_timeout(timeout) {
2313        Ok(result) => result,
2314        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2315            std::io::ErrorKind::TimedOut,
2316            "resolution exceeded its deadline",
2317        )),
2318        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2319            "resolver stopped without returning a result",
2320        )),
2321    }
2322}
2323
2324fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2325    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2326    pinned_public_agent(url, allow_private, "registry home")
2327}
2328
2329/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
2330/// from a FOREIGN home during registry resolution. DNS is resolved once,
2331/// every answer is required to be public, and that exact answer set is pinned
2332/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
2333fn get_json_absolute(url: &str) -> LinkResult<Value> {
2334    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2335    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2336    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2337        || !parsed.username().is_empty()
2338        || parsed.password().is_some()
2339        || parsed.query().is_some()
2340        || parsed.fragment().is_some()
2341    {
2342        return Err(invalid_feed("unsafe registry home URL"));
2343    }
2344    let http = registry_agent(&parsed)?;
2345    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2346        Ok(resp) => resp,
2347        Err(error) => match *error {
2348            ureq::Error::Status(status, resp) => {
2349                let _ = resp;
2350                return Err(LinkError::Http {
2351                    what: "registry home fetch",
2352                    status,
2353                    message: "the home node rejected the card request".to_string(),
2354                    code: None,
2355                    details: None,
2356                });
2357            }
2358            ureq::Error::Transport(err) => {
2359                return Err(LinkError::Transport {
2360                    hub: url.to_string(),
2361                    message: err.to_string(),
2362                });
2363            }
2364        },
2365    };
2366    if !(200..300).contains(&resp.status()) {
2367        return Err(LinkError::Http {
2368            what: "registry home fetch",
2369            status: resp.status(),
2370            message: "the home node returned a redirect or error".to_string(),
2371            code: None,
2372            details: None,
2373        });
2374    }
2375    let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2376    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2377        return Err(LinkError::ResponseTooLarge {
2378            limit_bytes: MAX_REGISTRY_CARD_BYTES,
2379        });
2380    }
2381    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2382        message: "the home node returned invalid JSON".to_string(),
2383    })
2384}
2385
2386/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
2387/// E5): look the handle up in the hub's registry, fetch the brain card from
2388/// the returned HOME node, and PIN — the card's identity fingerprint must
2389/// equal the registry's, or resolution fails. Returns the card enriched with
2390/// the resolved `home`, or `Ok(None)` when the registry has no such handle
2391/// (so the caller can fall back to a direct lookup).
2392pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2393    require_safe_ref(handle)?;
2394    // Hold the validated trust-directory capability before any network I/O.
2395    // Every lock/load/save below remains relative to this exact inode even if
2396    // an attacker swaps an ancestor while the registry or home is answering.
2397    let trust_directory = open_trust_dir(cfg)?;
2398    let reg = request_capped(
2399        cfg,
2400        "GET",
2401        &format!("/api/hub/registry/{handle}"),
2402        None,
2403        Auth::None,
2404        MAX_REGISTRY_CARD_BYTES,
2405    )?;
2406    if reg.status == 404 {
2407        return Ok(None);
2408    }
2409    let body = ensure_ok(reg, "registry resolve")?;
2410    let home = body
2411        .get("home")
2412        .and_then(Value::as_str)
2413        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2414    let brain = body
2415        .get("brain")
2416        .and_then(Value::as_str)
2417        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2418    if !crate::ulid::is_ulid(brain) {
2419        return Err(invalid_feed(
2420            "registry entry brain is not a canonical lowercase ULID",
2421        ));
2422    }
2423    let want_fp = body
2424        .get("identity")
2425        .and_then(|i| i.get("fingerprint"))
2426        .and_then(Value::as_str)
2427        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2428
2429    let home = home.trim_end_matches('/');
2430    let origin = normalized_origin(home)?;
2431    if origin != home {
2432        return Err(invalid_feed(
2433            "registry home must be an origin without a path, query, or fragment",
2434        ));
2435    }
2436    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2437    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2438    if let Some(binding) = &alias_binding {
2439        if binding
2440            .home
2441            .as_deref()
2442            .is_some_and(|pinned_home| pinned_home != home)
2443        {
2444            return Err(invalid_feed(
2445                "registry relocated a pinned handle to a different home",
2446            ));
2447        }
2448    }
2449    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2450    if card.get("id").and_then(Value::as_str) != Some(brain) {
2451        return Err(invalid_feed(
2452            "the home node served a card for a different brain",
2453        ));
2454    }
2455    let identity: FeedIdentity = serde_json::from_value(
2456        card.get("identity")
2457            .cloned()
2458            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2459    )
2460    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2461    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2462    let got_fp = card
2463        .get("identity")
2464        .and_then(|i| i.get("fingerprint"))
2465        .and_then(Value::as_str)
2466        .unwrap_or_default();
2467    if got_fp != want_fp {
2468        return Err(invalid_feed(
2469            "the home node served an identity that does not match the registry — refusing",
2470        ));
2471    }
2472    let current = format!("ed25519:{}", identity.fingerprint);
2473    let advertised_seq = card
2474        .get("headSeq")
2475        .and_then(Value::as_u64)
2476        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2477    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2478    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2479        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2480    {
2481        return Err(invalid_feed(
2482            "the home node served an invalid feed head boundary",
2483        ));
2484    }
2485    // This is required even on first contact. Otherwise a syntactically valid
2486    // identity may claim that a rotation happened after a head the home has
2487    // never reached, then become the permanent TOFU anchor.
2488    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2489    let registry_alias = AliasBinding {
2490        v: 1,
2491        origin: normalized_origin(&cfg.hub)?,
2492        requested: handle.to_string(),
2493        brain: brain.to_string(),
2494        home: Some(home.to_string()),
2495    };
2496    save_canonical_pin_and_alias(
2497        cfg,
2498        &trust_directory,
2499        handle,
2500        brain,
2501        TrustState {
2502            v: 2,
2503            origin: normalized_origin(&cfg.hub)?,
2504            requested: brain.to_string(),
2505            brain: brain.to_string(),
2506            home: None,
2507            anchor,
2508            current,
2509            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2510            feed_hash: pinned
2511                .as_ref()
2512                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2513            rotations: identity.rotations.clone(),
2514            hub_signer: None,
2515            protocol_profile: None,
2516        },
2517        Some(&registry_alias),
2518    )?;
2519    let mut out = card;
2520    if let Value::Object(map) = &mut out {
2521        map.insert("home".to_string(), Value::String(home.to_string()));
2522        map.insert(
2523            "resolvedVia".to_string(),
2524            Value::String("registry".to_string()),
2525        );
2526    }
2527    Ok(Some(out))
2528}
2529
2530pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2531    // `Address::parse` refuses these shapes already, but `Address` has public
2532    // fields — re-assert at the wire so a hand-built address can never
2533    // reshape the request path.
2534    require_safe_ref(&addr.brain)?;
2535    if let Some(target) = &addr.target {
2536        let (given, ok) = match target {
2537            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2538            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2539        };
2540        if !ok {
2541            return Err(LinkError::BadAddress {
2542                given: given.clone(),
2543                reason: BAD_TARGET_REASON.to_string(),
2544            });
2545        }
2546    }
2547
2548    // A record is never accepted from the hub's mutable query/index response.
2549    // Under v2, an id lookup may suggest a path, but the client accepts only
2550    // bytes whose exact path proof reaches the verified content root and whose
2551    // parsed id still matches. Under v1, the signed snapshot pack remains the
2552    // binding. Both profiles therefore reject a forged query response.
2553    if let Some(target) = &addr.target {
2554        if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2555            let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2556                what: "resolve",
2557                status: 404,
2558                message: "record not found".to_string(),
2559                code: Some("NOT_FOUND".to_string()),
2560                details: None,
2561            })?;
2562            let (path, file) = match target {
2563                AddressTarget::Path(path) => {
2564                    let file =
2565                        v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2566                            LinkError::Http {
2567                                what: "resolve",
2568                                status: 404,
2569                                message: "record not found".to_string(),
2570                                code: Some("NOT_FOUND".to_string()),
2571                                details: None,
2572                            }
2573                        })?;
2574                    (path.clone(), file)
2575                }
2576                AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2577            };
2578            let mut downloaded =
2579                download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2580            let (_, bytes) = downloaded
2581                .pop()
2582                .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2583            let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2584            accept_v2_head(cfg, &head)?;
2585            return Ok(resolved);
2586        }
2587        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2588        if !remote.head.verified {
2589            return Err(invalid_feed(
2590                "a path-scoped feed cannot prove a record against the full signed snapshot",
2591            ));
2592        }
2593        if remote.head.seq == 0 {
2594            return Err(LinkError::Http {
2595                what: "resolve",
2596                status: 404,
2597                message: "record not found".to_string(),
2598                code: Some("NOT_FOUND".to_string()),
2599                details: None,
2600            });
2601        }
2602        let brain = remote.head.brain.clone();
2603        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2604        return resolve_from_verified_pack(&brain, target, pack);
2605    }
2606
2607    let path = format!("/api/hub/brains/{}", addr.brain);
2608    // Direct first: the caller's own slug and hub-hosted public handles resolve
2609    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2610    // (404) falls through to the federation registry — how a handle reaches a
2611    // brain on ANOTHER node (link.md §7.1, E5).
2612    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2613    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2614        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2615            return Ok(card);
2616        }
2617    }
2618    let mut resolved = ensure_ok(direct, "resolve")?;
2619    if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2620        let v2 = v2_verified_head(cfg, &addr.brain)?
2621            .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2622        if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2623            return Err(invalid_feed(
2624                "resolve card is not bound to the verified v2 brain",
2625            ));
2626        }
2627        let card_identity: FeedIdentity = serde_json::from_value(
2628            resolved
2629                .get("identity")
2630                .cloned()
2631                .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2632        )
2633        .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2634        if card_identity != v2_identity(&v2.identity) {
2635            return Err(invalid_feed(
2636                "resolve card identity differs from the verified v2 identity",
2637            ));
2638        }
2639        accept_v2_head(cfg, &v2)?;
2640        if let Value::Object(card) = &mut resolved {
2641            card.insert(
2642                "headSeq".to_string(),
2643                json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2644            );
2645            card.insert(
2646                "feedHash".to_string(),
2647                v2.pointer
2648                    .as_ref()
2649                    .map(|pointer| Value::String(pointer.feed_hash.clone()))
2650                    .unwrap_or(Value::Null),
2651            );
2652            card.insert(
2653                "storageProfile".to_string(),
2654                Value::String("v2".to_string()),
2655            );
2656            if let Some(pointer) = &v2.pointer {
2657                card.insert(
2658                    "updatedAt".to_string(),
2659                    Value::String(pointer.signed_at.clone()),
2660                );
2661            }
2662        }
2663        return Ok(resolved);
2664    }
2665    // A successful direct response is accepted only after the same centralized
2666    // identity/rotation/feed checkpoint verification used by sync and
2667    // subscribe. No verb gets a weaker ad-hoc pinning path.
2668    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2669    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2670        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2671        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2672    {
2673        return Err(invalid_feed(
2674            "resolve card is not bound to the exact verified feed checkpoint",
2675        ));
2676    }
2677    let card_identity: FeedIdentity = serde_json::from_value(
2678        resolved
2679            .get("identity")
2680            .cloned()
2681            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2682    )
2683    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2684    if remote.identity.as_ref() != Some(&card_identity) {
2685        return Err(invalid_feed(
2686            "resolve card identity differs from the verified feed identity",
2687        ));
2688    }
2689    Ok(resolved)
2690}
2691
2692/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2693/// mutable query/index result is intentionally not consulted: a signed pack
2694/// digest is the only cryptographic binding between a feed checkpoint and
2695/// record bytes in wire profile v1.
2696fn resolve_from_verified_pack(
2697    brain: &str,
2698    target: &AddressTarget,
2699    pack: Vec<u8>,
2700) -> LinkResult<Value> {
2701    let entries = parse_store_pack(pack)?;
2702    let mut matched: Option<(String, Vec<u8>)> = None;
2703
2704    for (path, bytes) in entries {
2705        let is_candidate = match target {
2706            AddressTarget::Path(want) => &path == want,
2707            AddressTarget::Id(_) => {
2708                path.ends_with(".md")
2709                    && (path.starts_with("records/") || path.starts_with("sources/"))
2710            }
2711        };
2712        if !is_candidate {
2713            continue;
2714        }
2715        let text = std::str::from_utf8(&bytes)
2716            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2717        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2718            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2719        if let AddressTarget::Id(want) = target {
2720            let frontmatter =
2721                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2722                    .map_err(|_| {
2723                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2724                    })?;
2725            if frontmatter.id.as_deref() != Some(want) {
2726                continue;
2727            }
2728        }
2729        if matched.is_some() {
2730            return Err(invalid_feed(
2731                "signed snapshot contains more than one record for the requested target",
2732            ));
2733        }
2734        matched = Some((path, bytes));
2735    }
2736
2737    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2738        what: "resolve",
2739        status: 404,
2740        message: "record not found".to_string(),
2741        code: Some("NOT_FOUND".to_string()),
2742        details: None,
2743    })?;
2744    resolve_from_verified_record_bytes(brain, target, path, bytes)
2745}
2746
2747fn resolve_from_verified_record_bytes(
2748    brain: &str,
2749    target: &AddressTarget,
2750    path: String,
2751    bytes: Vec<u8>,
2752) -> LinkResult<Value> {
2753    match target {
2754        AddressTarget::Path(expected) if expected != &path => {
2755            return Err(invalid_feed(
2756                "verified record path differs from the requested path",
2757            ));
2758        }
2759        AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2760            return Err(invalid_feed(
2761                "verified id resolved outside records or sources",
2762            ));
2763        }
2764        _ => {}
2765    }
2766    let text = std::str::from_utf8(&bytes)
2767        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2768    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2769        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2770    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2771        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2772    let Value::Object(fields) = frontmatter else {
2773        return Err(invalid_feed(format!(
2774            "signed snapshot record `{path}` frontmatter is not a mapping"
2775        )));
2776    };
2777    if let AddressTarget::Id(expected) = target {
2778        if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2779            return Err(invalid_feed(
2780                "verified record id differs from the requested id",
2781            ));
2782        }
2783    }
2784    let mut document = serde_json::Map::new();
2785    document.insert("path".to_string(), Value::String(path));
2786    for (key, value) in fields {
2787        document.insert(key, value);
2788    }
2789    document.insert("body".to_string(), Value::String(parsed.body));
2790    document.insert(
2791        "contentSha".to_string(),
2792        Value::String(content_sha256(&bytes)),
2793    );
2794    Ok(json!({
2795        "brain": brain,
2796        "document": Value::Object(document),
2797    }))
2798}
2799
2800// ─────────────────────────────────────────────────────────────────────────────
2801// sync — pull the granted slice as files; push the local store as a snapshot
2802// ─────────────────────────────────────────────────────────────────────────────
2803
2804/// What a pull materialized.
2805#[derive(Debug, Clone, serde::Serialize)]
2806pub struct PullReport {
2807    /// The brain id the hub reported.
2808    pub brain: String,
2809    /// The brain's slug.
2810    pub slug: String,
2811    /// The hub's feed head at export time.
2812    #[serde(rename = "headSeq")]
2813    pub head_seq: u64,
2814    /// How many files were written.
2815    pub files: usize,
2816    /// Where they were written (as given or derived from the slug).
2817    pub dest: String,
2818    /// Local content files that the export did not carry — present so a
2819    /// caller sees divergence; nothing is ever deleted locally.
2820    #[serde(rename = "extraLocal")]
2821    pub extra_local: Vec<String>,
2822    /// Exact convergence result from the post-install barrier.
2823    #[serde(rename = "syncStatus")]
2824    pub sync_status: String,
2825}
2826
2827struct V2PulledSnapshot {
2828    report: PullReport,
2829    head: V2VerifiedHead,
2830    files: std::collections::BTreeMap<String, V2BaselineFile>,
2831    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2832    local: V2LocalView,
2833    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2834}
2835
2836fn download_verified_snapshot_pack(
2837    cfg: &HubConfig,
2838    brain: &str,
2839    remote: &VerifiedRemote,
2840) -> LinkResult<Vec<u8>> {
2841    let feed_hash = remote
2842        .head
2843        .feed_hash
2844        .as_deref()
2845        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2846    let signed_head = remote
2847        .head_entry
2848        .as_ref()
2849        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2850    let expected = &signed_head.entry.pack_sha256;
2851    if !is_sha256(expected) {
2852        return Err(invalid_feed(
2853            "signed head carries an invalid snapshot pack digest",
2854        ));
2855    }
2856    let path = format!(
2857        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2858        remote.head.seq
2859    );
2860    let body = ensure_ok(
2861        request(cfg, "GET", &path, None, Auth::Required)?,
2862        "sync pull",
2863    )?;
2864    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2865        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2866        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2867        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2868    {
2869        return Err(invalid_feed(
2870            "export response is not bound to the exact verified snapshot",
2871        ));
2872    }
2873    let url = body
2874        .get("url")
2875        .and_then(Value::as_str)
2876        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2877    let bytes = get_presigned(cfg, url)?;
2878    if content_sha256(&bytes) != *expected {
2879        return Err(LinkError::InvalidPack {
2880            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2881        });
2882    }
2883    let entries = parse_store_pack(bytes.clone())?;
2884    if signed_head.entry.kind == "push" {
2885        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2886    }
2887    Ok(bytes)
2888}
2889
2890#[derive(Debug, Clone, Deserialize, Serialize)]
2891struct V2PointerBody {
2892    v: u8,
2893    brain: String,
2894    seq: u64,
2895    commit_hash: String,
2896    feed_hash: String,
2897    content_root: Option<String>,
2898    asset_root: Option<String>,
2899    materializer: String,
2900    signer_epoch: u64,
2901    control_revision: String,
2902    backup_preparation: String,
2903    prior_pointer_hash: Option<String>,
2904    signed_at: String,
2905}
2906
2907#[derive(Debug, Clone, Deserialize)]
2908struct V2SignedPointer {
2909    pointer: V2PointerBody,
2910    hub_public_key: String,
2911    hub_fingerprint: String,
2912    sig: String,
2913}
2914
2915#[derive(Debug, Clone, Deserialize)]
2916struct V2HeadIdentity {
2917    #[serde(default)]
2918    custody: String,
2919    fingerprint: String,
2920    public_key_spki: String,
2921    #[serde(default)]
2922    previous: Vec<V2PreviousIdentity>,
2923    #[serde(default)]
2924    rotations: Vec<String>,
2925}
2926
2927#[derive(Debug, Clone, Deserialize)]
2928struct V2PreviousIdentity {
2929    fingerprint: String,
2930    public_key_spki: String,
2931}
2932
2933#[derive(Debug, Deserialize)]
2934struct V2HeadResponse {
2935    v: u8,
2936    brain_id: String,
2937    profile: String,
2938    view: Option<V2HeadView>,
2939    pointer: Option<V2SignedPointer>,
2940    identity: Option<V2HeadIdentity>,
2941}
2942
2943#[derive(Debug, Clone, Deserialize)]
2944struct V2HeadView {
2945    kind: String,
2946    #[serde(default)]
2947    id: Option<String>,
2948    control_revision: String,
2949}
2950
2951#[derive(Debug, Clone)]
2952struct V2VerifiedHead {
2953    requested: String,
2954    brain_id: String,
2955    view_kind: String,
2956    /// Stable identity of the exact current-content path projection.
2957    view_revision: String,
2958    /// Use-time authority revision independently bound into writes/signatures.
2959    control_revision: String,
2960    identity: V2HeadIdentity,
2961    pointer: Option<V2PointerBody>,
2962    trust: TrustState,
2963    alias: Option<AliasBinding>,
2964}
2965
2966fn verify_v2_spki_signature(
2967    public_key: &str,
2968    message: &[u8],
2969    signature: &str,
2970) -> LinkResult<Vec<u8>> {
2971    let der = URL_SAFE_NO_PAD
2972        .decode(public_key)
2973        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2974    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2975        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2976    }
2977    let sig = URL_SAFE_NO_PAD
2978        .decode(signature)
2979        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2980    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2981        .verify(message, &sig)
2982        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2983    Ok(der)
2984}
2985
2986fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2987    if pointer.pointer.v != 2
2988        || pointer.pointer.brain != expected_brain
2989        || pointer.pointer.seq == 0
2990        || !is_sha256(&pointer.pointer.commit_hash)
2991        || !is_sha256(&pointer.pointer.feed_hash)
2992        || pointer
2993            .pointer
2994            .content_root
2995            .as_deref()
2996            .is_some_and(|hash| !is_sha256(hash))
2997        || !is_sha256(&pointer.pointer.backup_preparation)
2998    {
2999        return Err(invalid_feed("v2 pointer fields are invalid"));
3000    }
3001    let value = serde_json::to_value(&pointer.pointer)
3002        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
3003    let message = crate::linkmd_v2::canonical_bytes(&value)
3004        .map_err(|error| invalid_feed(error.to_string()))?;
3005    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
3006    let fingerprint = format!("{:x}", Sha256::digest(&der));
3007    if fingerprint != pointer.hub_fingerprint {
3008        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
3009    }
3010    Ok(format!(
3011        "{}:{}",
3012        pointer.hub_fingerprint, pointer.hub_public_key
3013    ))
3014}
3015
3016fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
3017    FeedIdentity {
3018        fingerprint: identity.fingerprint.clone(),
3019        public_key_spki: identity.public_key_spki.clone(),
3020        previous: identity
3021            .previous
3022            .iter()
3023            .map(|previous| PreviousIdentity {
3024                fingerprint: previous.fingerprint.clone(),
3025                public_key_spki: previous.public_key_spki.clone(),
3026            })
3027            .collect(),
3028        rotations: identity.rotations.clone(),
3029    }
3030}
3031
3032fn verified_v2_commit_object(
3033    raw: &[u8],
3034    identity: &V2HeadIdentity,
3035) -> LinkResult<serde_json::Map<String, Value>> {
3036    let mut value: Value =
3037        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
3038    let canonical = crate::linkmd_v2::canonical_bytes(&value)
3039        .map_err(|error| invalid_feed(error.to_string()))?;
3040    if canonical != raw {
3041        return Err(invalid_feed("v2 commit is not canonical JSON"));
3042    }
3043    let object = value
3044        .as_object_mut()
3045        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
3046    let sig = object
3047        .remove("sig")
3048        .and_then(|value| value.as_str().map(str::to_string))
3049        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
3050    const FIELDS: [&str; 18] = [
3051        "actor_ref",
3052        "asset_root",
3053        "brain",
3054        "changes_sha256",
3055        "control_revision",
3056        "materializer",
3057        "op",
3058        "parent_asset_root",
3059        "parent_commit",
3060        "parent_root",
3061        "prev_entry_hash",
3062        "public_key",
3063        "seq",
3064        "signer_epoch",
3065        "state_root",
3066        "ts",
3067        "v",
3068        "v1_bridge",
3069    ];
3070    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
3071        return Err(invalid_feed("v2 commit has a non-normative field set"));
3072    }
3073    let seq = object
3074        .get("seq")
3075        .and_then(Value::as_u64)
3076        .filter(|seq| *seq > 0)
3077        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
3078    let signer_epoch = object
3079        .get("signer_epoch")
3080        .and_then(Value::as_u64)
3081        .filter(|epoch| *epoch > 0)
3082        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
3083    let hash_or_null = |field: &str| {
3084        object
3085            .get(field)
3086            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
3087    };
3088    if object.get("v").and_then(Value::as_u64) != Some(2)
3089        || object.get("op").and_then(Value::as_str) != Some("changeset")
3090        || !object
3091            .get("changes_sha256")
3092            .and_then(Value::as_str)
3093            .is_some_and(is_sha256)
3094        || !object
3095            .get("actor_ref")
3096            .and_then(Value::as_str)
3097            .is_some_and(is_sha256)
3098        || !object
3099            .get("control_revision")
3100            .and_then(Value::as_str)
3101            .is_some_and(is_sha256)
3102        || !object
3103            .get("state_root")
3104            .and_then(Value::as_str)
3105            .is_some_and(is_sha256)
3106        || !hash_or_null("parent_commit")
3107        || !hash_or_null("parent_root")
3108        || !hash_or_null("parent_asset_root")
3109        || !hash_or_null("asset_root")
3110        || !hash_or_null("prev_entry_hash")
3111        || !object
3112            .get("materializer")
3113            .and_then(Value::as_str)
3114            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
3115        || !object
3116            .get("ts")
3117            .and_then(Value::as_str)
3118            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3119    {
3120        return Err(invalid_feed("v2 commit fields are invalid"));
3121    }
3122    if (seq == 1
3123        && [
3124            "parent_commit",
3125            "parent_root",
3126            "parent_asset_root",
3127            "prev_entry_hash",
3128        ]
3129        .iter()
3130        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3131        || (seq > 1
3132            && ["parent_commit", "parent_root", "prev_entry_hash"]
3133                .iter()
3134                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3135    {
3136        return Err(invalid_feed("v2 commit parent shape is invalid"));
3137    }
3138    match object.get("v1_bridge") {
3139        Some(Value::Null) => {}
3140        Some(Value::Object(bridge))
3141            if seq == 1
3142                && bridge.len() == 3
3143                && bridge
3144                    .get("head_seq")
3145                    .and_then(Value::as_u64)
3146                    .is_some_and(|v| v > 0)
3147                && bridge
3148                    .get("feed_hash")
3149                    .and_then(Value::as_str)
3150                    .is_some_and(is_sha256)
3151                && bridge
3152                    .get("pack_sha256")
3153                    .and_then(Value::as_str)
3154                    .is_some_and(is_sha256) => {}
3155        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3156    }
3157    let public_key = object
3158        .get("public_key")
3159        .and_then(Value::as_str)
3160        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3161    let der = URL_SAFE_NO_PAD
3162        .decode(public_key)
3163        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3164    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3165    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3166        return Err(invalid_feed("v2 commit brain identity mismatch"));
3167    }
3168    // Verify the old-key-signed chain before trusting any prior public key.
3169    verify_identity_chain(&v2_identity(identity), None)?;
3170    // Old commits remain verifiable after brain-key rotation. `previous` is
3171    // newest-first, while signer epochs and rotation statements are oldest-first.
3172    let mut chain: Vec<(&str, &str)> = identity
3173        .previous
3174        .iter()
3175        .rev()
3176        .map(|previous| {
3177            (
3178                previous.fingerprint.as_str(),
3179                previous.public_key_spki.as_str(),
3180            )
3181        })
3182        .collect();
3183    chain.push((&identity.fingerprint, &identity.public_key_spki));
3184    let signer_index = chain.iter().position(|(fingerprint, spki)| {
3185        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3186    });
3187    let Some(signer_index) = signer_index else {
3188        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3189    };
3190    if signer_epoch != signer_index as u64 + 1 {
3191        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3192    }
3193    let lower_boundary = if signer_index == 0 {
3194        None
3195    } else {
3196        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3197            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3198        Some(prior.prior_head_seq)
3199    };
3200    let upper_boundary = if signer_index == identity.rotations.len() {
3201        None
3202    } else {
3203        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3204            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3205        Some(next.prior_head_seq)
3206    };
3207    if lower_boundary.is_some_and(|boundary| seq <= boundary)
3208        || upper_boundary.is_some_and(|boundary| seq > boundary)
3209    {
3210        return Err(invalid_feed(
3211            "v2 commit signer is outside its authenticated rotation epoch",
3212        ));
3213    }
3214    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3215        .map_err(|error| invalid_feed(error.to_string()))?;
3216    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3217    Ok(object.clone())
3218}
3219
3220#[derive(Debug, Deserialize)]
3221struct V2FeedWireEntry {
3222    seq: u64,
3223    commit_hash: String,
3224    feed_hash: String,
3225    bytes_base64: String,
3226}
3227
3228#[derive(Debug, Deserialize)]
3229struct V2FeedPage {
3230    v: u8,
3231    head_seq: u64,
3232    head_commit_hash: String,
3233    head_feed_hash: String,
3234    entries: Vec<V2FeedWireEntry>,
3235    next_after: u64,
3236    complete: bool,
3237}
3238
3239fn replay_v2_feed(
3240    cfg: &HubConfig,
3241    brain: &str,
3242    pointer: &V2PointerBody,
3243    identity: &V2HeadIdentity,
3244    start_after: u64,
3245    start_feed: Option<String>,
3246) -> LinkResult<()> {
3247    let mut after = start_after;
3248    let mut prior_feed = start_feed;
3249    let mut final_object = None;
3250    let mut replayed_entries = 0_u64;
3251    let mut replayed_bytes = 0_u64;
3252    while after < pointer.seq {
3253        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3254        let value = ensure_ok(
3255            request_capped(
3256                cfg,
3257                "GET",
3258                &path,
3259                None,
3260                Auth::Required,
3261                MAX_FEED_REPLAY_BYTES,
3262            )?,
3263            "v2 feed replay",
3264        )?;
3265        let page: V2FeedPage = serde_json::from_value(value)
3266            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3267        if page.v != 2
3268            || page.head_seq != pointer.seq
3269            || page.head_commit_hash != pointer.commit_hash
3270            || page.head_feed_hash != pointer.feed_hash
3271            || page.entries.is_empty()
3272            || page.entries.len() > FEED_PAGE_LIMIT
3273        {
3274            return Err(invalid_feed("v2 feed page differs from the signed head"));
3275        }
3276        for entry in page.entries {
3277            if entry.seq != after + 1
3278                || !is_sha256(&entry.commit_hash)
3279                || !is_sha256(&entry.feed_hash)
3280            {
3281                return Err(invalid_feed("v2 feed sequence is not contiguous"));
3282            }
3283            let raw = base64::engine::general_purpose::STANDARD
3284                .decode(&entry.bytes_base64)
3285                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3286            replayed_entries = replayed_entries
3287                .checked_add(1)
3288                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3289            replayed_bytes = replayed_bytes
3290                .checked_add(raw.len() as u64)
3291                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3292            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3293            {
3294                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3295            }
3296            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3297                .map_err(|error| invalid_feed(error.to_string()))?
3298                != entry.commit_hash
3299                || content_sha256(&raw) != entry.feed_hash
3300            {
3301                return Err(invalid_feed("v2 feed entry address mismatch"));
3302            }
3303            let object = verified_v2_commit_object(&raw, identity)?;
3304            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3305                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3306            {
3307                return Err(invalid_feed(
3308                    "v2 feed entry does not extend its predecessor",
3309                ));
3310            }
3311            after = entry.seq;
3312            prior_feed = Some(entry.feed_hash);
3313            final_object = Some((entry.commit_hash, object));
3314        }
3315        if page.next_after != after || (page.complete != (after == pointer.seq)) {
3316            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3317        }
3318    }
3319    let (final_hash, object) =
3320        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3321    if final_hash != pointer.commit_hash
3322        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3323        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3324        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3325        || object.get("control_revision").and_then(Value::as_str)
3326            != Some(pointer.control_revision.as_str())
3327        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3328    {
3329        return Err(invalid_feed(
3330            "v2 replay did not converge on the signed pointer",
3331        ));
3332    }
3333    Ok(())
3334}
3335
3336fn verify_v1_to_v2_bridge(
3337    cfg: &HubConfig,
3338    brain: &str,
3339    pointer: &V2PointerBody,
3340    identity: &V2HeadIdentity,
3341    checkpoint: &TrustState,
3342) -> LinkResult<()> {
3343    let value = ensure_ok(
3344        request_capped(
3345            cfg,
3346            "GET",
3347            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3348            None,
3349            Auth::Required,
3350            MAX_FEED_RESPONSE_BYTES,
3351        )?,
3352        "v2 genesis bridge",
3353    )?;
3354    let page: V2FeedPage = serde_json::from_value(value)
3355        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3356    if page.v != 2
3357        || page.head_seq != pointer.seq
3358        || page.head_commit_hash != pointer.commit_hash
3359        || page.head_feed_hash != pointer.feed_hash
3360        || page.entries.len() != 1
3361        || page.entries[0].seq != 1
3362        || !is_sha256(&page.entries[0].commit_hash)
3363        || !is_sha256(&page.entries[0].feed_hash)
3364    {
3365        return Err(invalid_feed(
3366            "v2 genesis bridge page differs from the signed head",
3367        ));
3368    }
3369    let first = &page.entries[0];
3370    let raw = STANDARD
3371        .decode(&first.bytes_base64)
3372        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3373    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3374        .map_err(|error| invalid_feed(error.to_string()))?
3375        != first.commit_hash
3376        || content_sha256(&raw) != first.feed_hash
3377    {
3378        return Err(invalid_feed("v2 genesis bridge address mismatch"));
3379    }
3380    let object = verified_v2_commit_object(&raw, identity)?;
3381    if checkpoint.head_seq == 0 {
3382        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3383            return Err(invalid_feed(
3384                "empty v1 checkpoint did not transition through an empty v2 genesis",
3385            ));
3386        }
3387        return Ok(());
3388    }
3389    let bridge = object
3390        .get("v1_bridge")
3391        .and_then(Value::as_object)
3392        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3393    let checkpoint_feed = checkpoint
3394        .feed_hash
3395        .as_deref()
3396        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3397    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3398        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3399    {
3400        return Err(invalid_feed(
3401            "v2 genesis bridge differs from the pinned v1 checkpoint",
3402        ));
3403    }
3404    let legacy_raw = ensure_raw_ok(
3405        request_raw(
3406            cfg,
3407            "GET",
3408            &format!(
3409                "/api/hub/brains/{brain}/feed?after={}&limit=1",
3410                checkpoint.head_seq - 1
3411            ),
3412            None,
3413            Auth::Required,
3414            MAX_FEED_RESPONSE_BYTES,
3415        )?,
3416        "v1 bridge boundary",
3417    )?;
3418    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3419        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3420    let legacy_identity = legacy
3421        .identity
3422        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3423    let item = legacy
3424        .entries
3425        .first()
3426        .filter(|_| legacy.entries.len() == 1)
3427        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3428    if legacy.scope_limited
3429        || legacy.head_seq != checkpoint.head_seq
3430        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3431        || item.entry.seq != checkpoint.head_seq
3432        || item.hash != checkpoint_feed
3433        || legacy_identity != v2_identity(identity)
3434        || bridge.get("pack_sha256").and_then(Value::as_str)
3435            != Some(item.entry.pack_sha256.as_str())
3436    {
3437        return Err(invalid_feed(
3438            "v1 bridge boundary differs from its signed legacy head",
3439        ));
3440    }
3441    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3442    if anchor != checkpoint.anchor {
3443        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3444    }
3445    verify_feed_item(item, &legacy_identity)?;
3446    verify_rotation_feed_boundaries(
3447        &legacy_identity,
3448        Some(checkpoint),
3449        std::slice::from_ref(item),
3450        checkpoint.head_seq,
3451    )?;
3452    Ok(())
3453}
3454
3455fn verify_v2_commit(
3456    cfg: &HubConfig,
3457    brain: &str,
3458    pointer: &V2PointerBody,
3459    identity: &V2HeadIdentity,
3460    pinned: Option<&TrustState>,
3461) -> LinkResult<()> {
3462    let path = format!(
3463        "/api/hub/brains/{brain}/v2/commit?commit={}",
3464        pointer.commit_hash
3465    );
3466    let raw = ensure_raw_ok(
3467        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3468        "v2 commit",
3469    )?;
3470    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3471        .map_err(|error| invalid_feed(error.to_string()))?
3472        != pointer.commit_hash
3473        || content_sha256(&raw) != pointer.feed_hash
3474    {
3475        return Err(invalid_feed("v2 commit address differs from the pointer"));
3476    }
3477    let object = verified_v2_commit_object(&raw, identity)?;
3478    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3479        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3480        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3481        || object.get("control_revision").and_then(Value::as_str)
3482            != Some(pointer.control_revision.as_str())
3483        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3484    {
3485        return Err(invalid_feed("v2 commit fields differ from the pointer"));
3486    }
3487    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3488        if pointer.seq == checkpoint.head_seq + 1
3489            && object.get("prev_entry_hash").and_then(Value::as_str)
3490                != checkpoint.feed_hash.as_deref()
3491        {
3492            return Err(invalid_feed(
3493                "v2 commit does not extend the pinned feed hash",
3494            ));
3495        }
3496        if pointer.seq > checkpoint.head_seq + 1 {
3497            return replay_v2_feed(
3498                cfg,
3499                brain,
3500                pointer,
3501                identity,
3502                checkpoint.head_seq,
3503                checkpoint.feed_hash.clone(),
3504            );
3505        }
3506    } else {
3507        if let Some(checkpoint) = pinned {
3508            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3509        }
3510        if pointer.seq > 1 {
3511            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3512        }
3513    }
3514    Ok(())
3515}
3516
3517fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3518    require_hardened_filesystem("verified link.md v2 state")?;
3519    require_safe_ref(brain)?;
3520    // Open and pin the private trust directory before a credential can leave
3521    // the process. A hostile checkout must not turn an unsafe state root into
3522    // a network oracle or receive a bearer before checkpoint safety is known.
3523    let trust_directory = open_trust_dir(cfg)?;
3524    let path = format!("/api/hub/brains/{brain}/v2/head");
3525    // A commit is durable before the hub's validation/index recovery view is
3526    // necessarily at that new source head. That is one typed transient state,
3527    // not a rejected commit. Waiting here keeps the accepted commit and the
3528    // local verification baseline in one operation; otherwise a large brain
3529    // can be forced through an expensive historical reconciliation merely
3530    // because its projection lagged for a few seconds.
3531    let started = std::time::Instant::now();
3532    let mut wait = std::time::Duration::from_millis(250);
3533    let response = loop {
3534        let response = request(cfg, "GET", &path, None, Auth::Required)?;
3535        if !v2_validation_catching_up(&response) {
3536            break response;
3537        }
3538        if started.elapsed() >= std::time::Duration::from_secs(15 * 60) {
3539            return Err(LinkError::Http {
3540                what: "v2 head",
3541                status: response.status,
3542                message: "validation/index recovery did not reach the durable source head within 15 minutes"
3543                    .to_string(),
3544                code: Some("validation_index_catching_up".to_string()),
3545                details: response.body,
3546            });
3547        }
3548        std::thread::sleep(wait);
3549        wait = wait
3550            .saturating_mul(2)
3551            .min(std::time::Duration::from_secs(5));
3552    };
3553    if response.status == 404 {
3554        if has_accepted_v2_ref(cfg, brain)? {
3555            return Err(LinkError::BrainUnavailable);
3556        }
3557        return Ok(None);
3558    }
3559    let body = ensure_ok(response, "v2 head")?;
3560    let head: V2HeadResponse = serde_json::from_value(body)
3561        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3562    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3563        return Err(invalid_feed("v2 head has no canonical brain id"));
3564    }
3565    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3566        return Err(invalid_feed("v2 head resolved a different brain id"));
3567    }
3568    if head.profile == "v1" {
3569        return Ok(None);
3570    }
3571    if head.profile != "v2" && head.profile != "v2-empty" {
3572        return Err(invalid_feed("v2 head advertised an unknown profile"));
3573    }
3574    let view = head
3575        .view
3576        .as_ref()
3577        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3578    if !matches!(view.kind.as_str(), "full" | "scoped")
3579        || !is_sha256(&view.control_revision)
3580        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3581    {
3582        return Err(invalid_feed("v2 head has an invalid permission view"));
3583    }
3584    let view_kind = view.kind.clone();
3585    // `id` is the stable materialized namespace. Older v2 hubs did not expose
3586    // it, so retain the fail-closed control-revision fallback for compatibility.
3587    let view_revision = view
3588        .id
3589        .clone()
3590        .unwrap_or_else(|| view.control_revision.clone());
3591    let control_revision = view.control_revision.clone();
3592    let identity = head
3593        .identity
3594        .as_ref()
3595        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3596    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3597    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3598    let feed_identity = v2_identity(identity);
3599    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3600    let (seq, feed_hash, hub_signer) = match &head.pointer {
3601        None => {
3602            if head.profile != "v2-empty" {
3603                return Err(invalid_feed("initialized v2 head has no pointer"));
3604            }
3605            (
3606                0,
3607                None,
3608                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3609            )
3610        }
3611        Some(signed) => {
3612            let signer = verify_v2_pointer(signed, &head.brain_id)?;
3613            if pinned
3614                .as_ref()
3615                .and_then(|state| state.hub_signer.as_ref())
3616                .is_some_and(|known| known != &signer)
3617            {
3618                return Err(invalid_feed(
3619                    "v2 hub pointer signer changed without a trust transition",
3620                ));
3621            }
3622            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3623                if signed.pointer.seq < checkpoint.head_seq
3624                    || (signed.pointer.seq == checkpoint.head_seq
3625                        && checkpoint.feed_hash.as_deref()
3626                            != Some(signed.pointer.feed_hash.as_str()))
3627                {
3628                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3629                }
3630            }
3631            verify_v2_commit(
3632                cfg,
3633                &head.brain_id,
3634                &signed.pointer,
3635                identity,
3636                pinned.as_ref(),
3637            )?;
3638            (
3639                signed.pointer.seq,
3640                Some(signed.pointer.feed_hash.clone()),
3641                Some(signer),
3642            )
3643        }
3644    };
3645    let trust = TrustState {
3646        v: 2,
3647        origin: normalized_origin(&cfg.hub)?,
3648        requested: head.brain_id.clone(),
3649        brain: head.brain_id.clone(),
3650        home: None,
3651        anchor,
3652        current: format!("ed25519:{}", identity.fingerprint),
3653        head_seq: seq,
3654        feed_hash,
3655        rotations: identity.rotations.clone(),
3656        hub_signer,
3657        protocol_profile: Some("link-v2".to_string()),
3658    };
3659    Ok(Some(V2VerifiedHead {
3660        requested: brain.to_string(),
3661        brain_id: head.brain_id,
3662        view_kind,
3663        view_revision,
3664        control_revision,
3665        identity: identity.clone(),
3666        pointer: head.pointer.map(|signed| signed.pointer),
3667        trust,
3668        alias: alias_binding,
3669    }))
3670}
3671
3672fn v2_validation_catching_up(response: &HubResponse) -> bool {
3673    response.status == 422
3674        && response.body.as_ref().is_some_and(|body| {
3675            body.get("code").and_then(Value::as_str) == Some("validation_index_catching_up")
3676                || body
3677                    .get("details")
3678                    .and_then(|details| details.get("code"))
3679                    .and_then(Value::as_str)
3680                    == Some("validation_index_catching_up")
3681        })
3682}
3683
3684fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3685    let directory = open_trust_dir(cfg)?;
3686    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3687    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3688    if let Some(current) = current {
3689        let common_invalid = head.trust.anchor != current.anchor
3690            || !head.trust.rotations.starts_with(&current.rotations);
3691        let profile_invalid = if accepted_as_v2(&current) {
3692            head.trust.head_seq < current.head_seq
3693                || (head.trust.head_seq == current.head_seq
3694                    && head.trust.feed_hash != current.feed_hash)
3695                || current
3696                    .hub_signer
3697                    .as_ref()
3698                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3699        } else {
3700            head.trust.protocol_profile.as_deref() != Some("link-v2")
3701                || head.trust.hub_signer.is_none()
3702        };
3703        if common_invalid || profile_invalid {
3704            return Err(invalid_feed(
3705                "v2 head cannot advance the currently accepted trust checkpoint",
3706            ));
3707        }
3708    }
3709    save_canonical_pin_and_alias(
3710        cfg,
3711        &directory,
3712        &head.requested,
3713        &head.brain_id,
3714        head.trust.clone(),
3715        alias.as_ref().or(head.alias.as_ref()),
3716    )
3717}
3718
3719#[derive(Debug, Clone, Deserialize, Serialize)]
3720struct V2BaselineFile {
3721    sha256: String,
3722    bytes: u64,
3723    #[serde(skip)]
3724    proof: Option<Vec<V2ProofStep>>,
3725}
3726
3727/// Private checkout acceleration, analogous to Git's stat cache. A cached
3728/// hash is reusable only when the same held regular file still has the exact
3729/// Unix identity/timestamps/length and those timestamps were already outside
3730/// the racy-clean window when observed. `withheld_targets` is policy-specific;
3731/// the complete cache is ignored when `.sevralocal`'s digest changes.
3732#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
3733struct V2ScanCacheFile {
3734    fingerprint: String,
3735    sha256: String,
3736    bytes: u64,
3737    #[serde(default)]
3738    withheld_targets: Vec<String>,
3739}
3740
3741#[derive(Debug, Clone, Deserialize, Serialize)]
3742struct V2SyncBaseline {
3743    v: u8,
3744    origin: String,
3745    brain: String,
3746    #[serde(default)]
3747    checkout_id: Option<String>,
3748    #[serde(default)]
3749    head_seq: Option<u64>,
3750    commit_hash: Option<String>,
3751    content_root: Option<String>,
3752    #[serde(default)]
3753    asset_root: Option<String>,
3754    #[serde(default)]
3755    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3756    #[serde(default)]
3757    view_kind: Option<String>,
3758    #[serde(default)]
3759    view_revision: Option<String>,
3760    /// The authority revision observed with this materialized view. Older
3761    /// baselines omit it and deliberately take one full verified refresh
3762    /// before they become eligible for unchanged-head reuse.
3763    #[serde(default)]
3764    control_revision: Option<String>,
3765    #[serde(default)]
3766    projection_sha256: Option<String>,
3767    files: std::collections::BTreeMap<String, V2BaselineFile>,
3768    #[serde(default)]
3769    scan_cache: std::collections::BTreeMap<String, V2ScanCacheFile>,
3770    #[serde(default)]
3771    local_policy_digest: Option<String>,
3772    #[serde(default)]
3773    local_eligibility: std::collections::BTreeMap<String, bool>,
3774    #[serde(default)]
3775    remote_copy_remains: std::collections::BTreeMap<String, String>,
3776}
3777
3778#[derive(Clone)]
3779struct V2LocalView {
3780    riding: std::collections::BTreeMap<String, (String, u64)>,
3781    scan_cache: std::collections::BTreeMap<String, V2ScanCacheFile>,
3782    eligibility: std::collections::BTreeMap<String, bool>,
3783    policy: crate::linkmd_sync_policy::SyncPolicy,
3784    withheld_links: Vec<V2WithheldLink>,
3785}
3786
3787#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3788struct V2WithheldLink {
3789    source: String,
3790    target: String,
3791}
3792
3793#[derive(Debug, Clone, Deserialize, Serialize)]
3794struct V2ProofStep {
3795    directory_root: String,
3796    component: String,
3797    proof: crate::linkmd_v2::HamtProof,
3798}
3799
3800#[derive(Debug, Deserialize)]
3801struct V2ManifestFile {
3802    path: String,
3803    sha256: String,
3804    bytes: u64,
3805    proof: Vec<V2ProofStep>,
3806}
3807
3808#[derive(Debug, Deserialize)]
3809struct V2ManifestPage {
3810    v: u8,
3811    commit: String,
3812    content_root: Option<String>,
3813    files: Vec<V2ManifestFile>,
3814    next_cursor: Option<String>,
3815}
3816
3817#[derive(Debug, Clone, Deserialize, Serialize)]
3818struct V2BaselineAsset {
3819    blob_sha256: String,
3820    bytes: u64,
3821    media_type: String,
3822    wrappers: Vec<String>,
3823    required: bool,
3824    disposition: String,
3825    leaf_hash: String,
3826}
3827
3828#[derive(Debug, Deserialize)]
3829struct V2AssetManifestItem {
3830    path: String,
3831    blob_sha256: String,
3832    bytes: u64,
3833    media_type: String,
3834    wrappers: Vec<String>,
3835    required: bool,
3836    disposition: String,
3837    leaf_hash: String,
3838    proof: crate::linkmd_v2::HamtProof,
3839}
3840
3841#[derive(Debug, Deserialize)]
3842struct V2AssetManifestPage {
3843    v: u8,
3844    commit: String,
3845    asset_root: Option<String>,
3846    assets: Vec<V2AssetManifestItem>,
3847    next_cursor: Option<String>,
3848}
3849
3850#[derive(Debug, Deserialize)]
3851struct V2SigningCandidate {
3852    seq: u64,
3853    content_root: Option<String>,
3854    asset_root: Option<String>,
3855    signing_bytes_base64: String,
3856    changes_base64: String,
3857    actor_claim_base64: String,
3858}
3859
3860#[derive(Debug, Deserialize)]
3861struct V2SigningCandidatePage {
3862    v: u8,
3863    challenge_id: String,
3864    mutation_id: String,
3865    request_hash: String,
3866    parent: V2SigningParent,
3867    candidate: V2SigningCandidate,
3868    files: Vec<V2ManifestFile>,
3869    #[serde(default)]
3870    assets: Vec<V2AssetManifestItem>,
3871    next_cursor: Option<String>,
3872    expires_at: String,
3873}
3874
3875#[derive(Debug, Deserialize)]
3876struct V2SigningParent {
3877    seq: u64,
3878    commit_hash: Option<String>,
3879}
3880
3881fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3882    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3883        .map_err(|error| invalid_feed(error.to_string()))?;
3884    let components = normalized.split('/').collect::<Vec<_>>();
3885    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3886        return Err(invalid_feed("v2 file proof has the wrong shape"));
3887    }
3888    let mut directory_root = root.to_string();
3889    for (index, step) in file.proof.iter().enumerate() {
3890        if step.directory_root != directory_root || step.component != components[index] {
3891            return Err(invalid_feed(
3892                "v2 file proof path chain differs from its manifest",
3893            ));
3894        }
3895        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3896            .map_err(|error| invalid_feed(error.to_string()))?
3897        {
3898            return Err(invalid_feed("v2 file proof failed verification"));
3899        }
3900        let entry = match &step.proof {
3901            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3902            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3903                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3904            }
3905        };
3906        if index + 1 == components.len() {
3907            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3908                || entry.child_hash != file.sha256
3909                || entry.bytes != Some(file.bytes)
3910            {
3911                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3912            }
3913        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3914            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3915        } else {
3916            directory_root = entry.child_hash.clone();
3917        }
3918    }
3919    Ok(())
3920}
3921
3922fn v2_manifest(
3923    cfg: &HubConfig,
3924    brain: &str,
3925    pointer: Option<&V2PointerBody>,
3926) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3927    let Some(pointer) = pointer else {
3928        return Ok(std::collections::BTreeMap::new());
3929    };
3930    let Some(root) = pointer.content_root.as_deref() else {
3931        return Ok(std::collections::BTreeMap::new());
3932    };
3933    let mut files = std::collections::BTreeMap::new();
3934    let mut after = String::new();
3935    loop {
3936        let encoded_after: String =
3937            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3938        let path = format!(
3939            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3940            pointer.commit_hash
3941        );
3942        let value = ensure_ok(
3943            request_capped(
3944                cfg,
3945                "GET",
3946                &path,
3947                None,
3948                Auth::Required,
3949                MAX_FEED_RESPONSE_BYTES,
3950            )?,
3951            "v2 file manifest",
3952        )?;
3953        let page: V2ManifestPage = serde_json::from_value(value)
3954            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3955        if page.v != 2
3956            || page.commit != pointer.commit_hash
3957            || page.content_root.as_deref() != Some(root)
3958            || page.files.len() > 500
3959        {
3960            return Err(invalid_feed(
3961                "v2 file manifest is not bound to the verified head",
3962            ));
3963        }
3964        for file in page.files {
3965            verify_v2_file_proof(root, &file)?;
3966            if files
3967                .insert(
3968                    file.path.clone(),
3969                    V2BaselineFile {
3970                        sha256: file.sha256,
3971                        bytes: file.bytes,
3972                        proof: Some(file.proof),
3973                    },
3974                )
3975                .is_some()
3976            {
3977                return Err(invalid_feed("v2 file manifest repeats a path"));
3978            }
3979            if files.len() > MAX_PUSH_FILES {
3980                return Err(invalid_feed(
3981                    "v2 file manifest exceeds the file-count bound",
3982                ));
3983            }
3984        }
3985        match page.next_cursor {
3986            None => break,
3987            Some(next) if next > after => after = next,
3988            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3989        }
3990    }
3991    Ok(files)
3992}
3993
3994/// Fetch one exact path proof instead of enumerating the whole permission
3995/// view. This keeps `resolve @brain/path` proportional to path depth even for
3996/// a very large brain. The returned leaf is accepted only after its complete
3997/// proof chain reaches the content root signed by `pointer`.
3998fn v2_manifest_file(
3999    cfg: &HubConfig,
4000    brain: &str,
4001    pointer: &V2PointerBody,
4002    path: &str,
4003) -> LinkResult<Option<V2BaselineFile>> {
4004    let Some(root) = pointer.content_root.as_deref() else {
4005        return Ok(None);
4006    };
4007    crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
4008        path: error.to_string(),
4009    })?;
4010    let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
4011    let response = request_capped(
4012        cfg,
4013        "GET",
4014        &format!(
4015            "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
4016            pointer.commit_hash
4017        ),
4018        None,
4019        Auth::Required,
4020        MAX_FEED_RESPONSE_BYTES,
4021    )?;
4022    // Exact v2 file reads deliberately collapse absent and unreadable paths
4023    // into the same 404. Preserve that non-oracle as a normal `None`; callers
4024    // such as `resolve` then produce the stable, local `NOT_FOUND` contract.
4025    if response.status == 404 {
4026        return Ok(None);
4027    }
4028    let value = ensure_ok(response, "v2 exact file proof")?;
4029    let mut page: V2ManifestPage = serde_json::from_value(value)
4030        .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
4031    if page.v != 2
4032        || page.commit != pointer.commit_hash
4033        || page.content_root.as_deref() != Some(root)
4034        || page.next_cursor.is_some()
4035        || page.files.len() != 1
4036        || page.files[0].path != path
4037    {
4038        return Err(invalid_feed(
4039            "v2 exact file proof is not bound to the requested signed path",
4040        ));
4041    }
4042    let file = page.files.pop().expect("exactly one file was checked");
4043    verify_v2_file_proof(root, &file)?;
4044    Ok(Some(V2BaselineFile {
4045        sha256: file.sha256,
4046        bytes: file.bytes,
4047        proof: Some(file.proof),
4048    }))
4049}
4050
4051/// Resolve an id through the hub's synchronously maintained, content-light
4052/// validation certificate, then require the same exact signed path proof as a
4053/// path lookup. The certificate is only an efficient locator: the downloaded
4054/// bytes are parsed again and their id is checked by the caller.
4055fn v2_manifest_file_by_id(
4056    cfg: &HubConfig,
4057    brain: &str,
4058    pointer: &V2PointerBody,
4059    id: &str,
4060) -> LinkResult<(String, V2BaselineFile)> {
4061    let root = pointer
4062        .content_root
4063        .as_deref()
4064        .ok_or_else(|| LinkError::Http {
4065            what: "resolve",
4066            status: 404,
4067            message: "record not found".to_string(),
4068            code: Some("NOT_FOUND".to_string()),
4069            details: None,
4070        })?;
4071    if !crate::ulid::is_ulid(id) {
4072        return Err(LinkError::BadAddress {
4073            given: id.to_string(),
4074            reason: BAD_TARGET_REASON.to_string(),
4075        });
4076    }
4077    let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
4078    let value = ensure_ok(
4079        request_capped(
4080            cfg,
4081            "GET",
4082            &format!(
4083                "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
4084                pointer.commit_hash
4085            ),
4086            None,
4087            Auth::Required,
4088            MAX_FEED_RESPONSE_BYTES,
4089        )?,
4090        "v2 exact id proof",
4091    )?;
4092    let mut page: V2ManifestPage = serde_json::from_value(value)
4093        .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
4094    if page.v != 2
4095        || page.commit != pointer.commit_hash
4096        || page.content_root.as_deref() != Some(root)
4097        || page.next_cursor.is_some()
4098        || page.files.len() != 1
4099    {
4100        return Err(invalid_feed(
4101            "v2 exact id proof is not bound to one signed path",
4102        ));
4103    }
4104    let file = page.files.pop().expect("exactly one file was checked");
4105    if !safe_store_rel_path(&file.path)
4106        || !file.path.ends_with(".md")
4107        || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4108    {
4109        return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4110    }
4111    verify_v2_file_proof(root, &file)?;
4112    Ok((
4113        file.path,
4114        V2BaselineFile {
4115            sha256: file.sha256,
4116            bytes: file.bytes,
4117            proof: Some(file.proof),
4118        },
4119    ))
4120}
4121
4122fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4123    crate::linkmd_v2::normalize_path(&item.path)
4124        .map_err(|error| invalid_feed(error.to_string()))?;
4125    if !is_sha256(&item.blob_sha256)
4126        || !is_sha256(&item.leaf_hash)
4127        || item.bytes > MAX_ASSET_BYTES
4128        || item.wrappers.is_empty()
4129        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4130        || item
4131            .wrappers
4132            .iter()
4133            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4134    {
4135        return Err(invalid_feed("v2 asset manifest item is invalid"));
4136    }
4137    let leaf = json!({
4138        "blob_sha256": item.blob_sha256,
4139        "bytes": item.bytes,
4140        "disposition": item.disposition,
4141        "media_type": item.media_type,
4142        "path": item.path,
4143        "required": item.required,
4144        "v": 2,
4145        "wrappers": item.wrappers,
4146    });
4147    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4148        .map_err(|error| invalid_feed(error.to_string()))?
4149        != item.leaf_hash
4150        || !crate::linkmd_v2::verify_proof_with_domain(
4151            root,
4152            &item.path,
4153            &item.proof,
4154            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4155        )
4156        .map_err(|error| invalid_feed(error.to_string()))?
4157    {
4158        return Err(invalid_feed("v2 asset inclusion proof failed"));
4159    }
4160    match &item.proof {
4161        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4162            if entry.name == item.path
4163                && entry.kind == crate::linkmd_v2::EntryKind::Blob
4164                && entry.child_hash == item.leaf_hash
4165                && entry.bytes == Some(item.bytes) =>
4166        {
4167            Ok(())
4168        }
4169        _ => Err(invalid_feed(
4170            "v2 asset proof leaf differs from its manifest",
4171        )),
4172    }
4173}
4174
4175fn v2_asset_manifest(
4176    cfg: &HubConfig,
4177    brain: &str,
4178    pointer: Option<&V2PointerBody>,
4179) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4180    let Some(pointer) = pointer else {
4181        return Ok(std::collections::BTreeMap::new());
4182    };
4183    let Some(root) = pointer.asset_root.as_deref() else {
4184        return Ok(std::collections::BTreeMap::new());
4185    };
4186    let mut assets = std::collections::BTreeMap::new();
4187    let mut after = String::new();
4188    loop {
4189        let encoded_after: String =
4190            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4191        let path = format!(
4192            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4193            pointer.commit_hash
4194        );
4195        let value = ensure_ok(
4196            request_capped(
4197                cfg,
4198                "GET",
4199                &path,
4200                None,
4201                Auth::Required,
4202                MAX_FEED_RESPONSE_BYTES,
4203            )?,
4204            "v2 asset manifest",
4205        )?;
4206        let page: V2AssetManifestPage = serde_json::from_value(value)
4207            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4208        if page.v != 2
4209            || page.commit != pointer.commit_hash
4210            || page.asset_root.as_deref() != Some(root)
4211            || page.assets.len() > 500
4212        {
4213            return Err(invalid_feed(
4214                "v2 asset manifest is not bound to the verified head",
4215            ));
4216        }
4217        for item in page.assets {
4218            verify_v2_asset_proof(root, &item)?;
4219            let path = item.path.clone();
4220            if assets
4221                .insert(
4222                    path,
4223                    V2BaselineAsset {
4224                        blob_sha256: item.blob_sha256,
4225                        bytes: item.bytes,
4226                        media_type: item.media_type,
4227                        wrappers: item.wrappers,
4228                        required: item.required,
4229                        disposition: item.disposition,
4230                        leaf_hash: item.leaf_hash,
4231                    },
4232                )
4233                .is_some()
4234            {
4235                return Err(invalid_feed("v2 asset manifest repeats a path"));
4236            }
4237            if assets.len() > MAX_PUSH_FILES {
4238                return Err(invalid_feed(
4239                    "v2 asset manifest exceeds the item-count bound",
4240                ));
4241            }
4242        }
4243        match page.next_cursor {
4244            None => break,
4245            Some(next) if next > after => after = next,
4246            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4247        }
4248    }
4249    Ok(assets)
4250}
4251
4252fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4253    crate::AssetRecord {
4254        path: path.to_string(),
4255        sha256: asset.blob_sha256.clone(),
4256        bytes: asset.bytes,
4257        media_type: asset.media_type.clone(),
4258        wrappers: asset.wrappers.clone(),
4259        required: asset.required,
4260    }
4261}
4262
4263fn v2_asset_resumes_hosting(
4264    remote: Option<&V2BaselineAsset>,
4265    path: &str,
4266    record: &crate::AssetRecord,
4267    disposition: &str,
4268) -> bool {
4269    remote.is_some_and(|asset| {
4270        asset.disposition == "withheld"
4271            && disposition == "hosted"
4272            && v2_asset_record(asset, path) == *record
4273    })
4274}
4275
4276fn v2_asset_inherits_withheld_absence(
4277    base: Option<&V2BaselineAsset>,
4278    base_record: Option<&crate::AssetRecord>,
4279    local_record: Option<&crate::AssetRecord>,
4280    raw_present: bool,
4281) -> bool {
4282    !raw_present
4283        && base.is_some_and(|asset| asset.disposition == "withheld")
4284        && local_record == base_record
4285}
4286
4287fn v2_asset_record_manifest_bytes(
4288    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4289) -> LinkResult<Vec<u8>> {
4290    let mut bytes = Vec::new();
4291    for (path, asset) in assets {
4292        if asset.path != *path {
4293            return Err(invalid_feed(
4294                "local asset manifest key differs from its record path",
4295            ));
4296        }
4297        serde_json::to_writer(&mut bytes, asset)
4298            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4299        bytes.push(b'\n');
4300    }
4301    Ok(bytes)
4302}
4303
4304fn v2_local_asset_records(
4305    store: &Store,
4306) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4307    let assets = crate::assets::read_manifest(store)
4308        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4309    if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4310        return Err(LinkError::InvalidPack {
4311            message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4312        });
4313    }
4314    Ok(assets
4315        .into_iter()
4316        .map(|asset| (asset.path.clone(), asset))
4317        .collect())
4318}
4319
4320fn v2_asset_records_match_remote(
4321    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4322    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4323) -> bool {
4324    local.len() == remote.len()
4325        && remote
4326            .iter()
4327            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4328}
4329
4330#[derive(Debug, Clone, PartialEq, Eq)]
4331struct V2PulledMerge<T> {
4332    records: std::collections::BTreeMap<String, T>,
4333    accept_remote: std::collections::BTreeSet<String>,
4334    conflicts: Vec<String>,
4335}
4336
4337/// Compute the local checkout that a pull may install without discarding a
4338/// local-only edit. Remote-only changes are accepted, identical concurrent
4339/// changes converge, and divergent concurrent changes are reported. The
4340/// caller can exempt kept-home coordinates from both remote installation and
4341/// conflict disclosure.
4342fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4343    base: &std::collections::BTreeMap<String, Base>,
4344    remote: &std::collections::BTreeMap<String, Remote>,
4345    local: &std::collections::BTreeMap<String, Record>,
4346    base_record: BaseRecord,
4347    remote_record: RemoteRecord,
4348    keep_local: KeepLocal,
4349) -> V2PulledMerge<Record>
4350where
4351    Record: Clone + Eq,
4352    BaseRecord: Fn(&Base, &str) -> Record,
4353    RemoteRecord: Fn(&Remote, &str) -> Record,
4354    KeepLocal: Fn(&str) -> bool,
4355{
4356    let paths = base
4357        .keys()
4358        .chain(remote.keys())
4359        .chain(local.keys())
4360        .cloned()
4361        .collect::<std::collections::BTreeSet<_>>();
4362    let mut records = local.clone();
4363    let mut accept_remote = std::collections::BTreeSet::new();
4364    let mut conflicts = Vec::new();
4365    for path in paths {
4366        if keep_local(&path) {
4367            continue;
4368        }
4369        let base_value = base.get(&path).map(|value| base_record(value, &path));
4370        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4371        let local_value = local.get(&path).cloned();
4372        if local_value != base_value && remote_value != base_value && local_value != remote_value {
4373            conflicts.push(path);
4374            continue;
4375        }
4376        if local_value == base_value || local_value == remote_value {
4377            accept_remote.insert(path.clone());
4378            match remote_value {
4379                Some(value) => {
4380                    records.insert(path, value);
4381                }
4382                None => {
4383                    records.remove(&path);
4384                }
4385            }
4386        }
4387    }
4388    V2PulledMerge {
4389        records,
4390        accept_remote,
4391        conflicts,
4392    }
4393}
4394
4395fn sign_verified_v2_candidate(
4396    cfg: &HubConfig,
4397    head: &V2VerifiedHead,
4398    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4399    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4400    mutation_id: &str,
4401    request_body: &Value,
4402    challenge_value: &Value,
4403) -> LinkResult<(String, String, String)> {
4404    if head.view_kind != "full" {
4405        return Err(invalid_feed(
4406            "a scoped self-custody writer must use the proposal workflow",
4407        ));
4408    }
4409    if head.identity.custody != "self" {
4410        return Err(invalid_feed(
4411            "a hub-custodied brain unexpectedly requested an external signature",
4412        ));
4413    }
4414    let key = cfg
4415        .brain_key
4416        .as_ref()
4417        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4418    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4419        || key.public_key_spki != head.identity.public_key_spki
4420    {
4421        return Err(bad_agent_key(
4422            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4423        ));
4424    }
4425    let challenge_id = challenge_value
4426        .get("id")
4427        .and_then(Value::as_str)
4428        .filter(|id| crate::ulid::is_ulid(id))
4429        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4430    let expected_endpoint = format!(
4431        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4432        head.brain_id
4433    );
4434    if challenge_value
4435        .get("candidate_endpoint")
4436        .and_then(Value::as_str)
4437        != Some(expected_endpoint.as_str())
4438    {
4439        return Err(invalid_feed(
4440            "self-custody challenge candidate endpoint is not origin-bound",
4441        ));
4442    }
4443
4444    let mut files = std::collections::BTreeMap::new();
4445    let mut after = String::new();
4446    type CandidateCoordinate = (
4447        String,
4448        String,
4449        String,
4450        String,
4451        Option<String>,
4452        Option<String>,
4453        u64,
4454        Option<String>,
4455    );
4456    let mut pinned: Option<CandidateCoordinate> = None;
4457    loop {
4458        let encoded_after: String =
4459            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4460        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4461        let value = ensure_ok(
4462            request_capped(
4463                cfg,
4464                "GET",
4465                &path,
4466                None,
4467                Auth::Required,
4468                MAX_FEED_RESPONSE_BYTES,
4469            )?,
4470            "v2 self-custody candidate",
4471        )?;
4472        let page: V2SigningCandidatePage = serde_json::from_value(value)
4473            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4474        if page.v != 2
4475            || page.challenge_id != challenge_id
4476            || page.mutation_id != mutation_id
4477            || page.candidate.seq != page.parent.seq + 1
4478            || page.files.len() > 500
4479            || page.expires_at.is_empty()
4480        {
4481            return Err(invalid_feed(
4482                "self-custody candidate is not bound to this mutation",
4483            ));
4484        }
4485        let coordinate = (
4486            page.request_hash.clone(),
4487            page.candidate.signing_bytes_base64.clone(),
4488            page.candidate.changes_base64.clone(),
4489            page.candidate.actor_claim_base64.clone(),
4490            page.candidate.content_root.clone(),
4491            page.candidate.asset_root.clone(),
4492            page.parent.seq,
4493            page.parent.commit_hash.clone(),
4494        );
4495        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4496            return Err(invalid_feed(
4497                "self-custody candidate changed between manifest pages",
4498            ));
4499        }
4500        pinned = Some(coordinate);
4501        let root = page
4502            .candidate
4503            .content_root
4504            .as_deref()
4505            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4506        for file in page.files {
4507            verify_v2_file_proof(root, &file)?;
4508            if files
4509                .insert(
4510                    file.path.clone(),
4511                    V2BaselineFile {
4512                        sha256: file.sha256,
4513                        bytes: file.bytes,
4514                        proof: Some(file.proof),
4515                    },
4516                )
4517                .is_some()
4518            {
4519                return Err(invalid_feed(
4520                    "self-custody candidate repeats a manifest path",
4521                ));
4522            }
4523            if files.len() > MAX_PUSH_FILES {
4524                return Err(invalid_feed(
4525                    "self-custody candidate exceeds the file-count bound",
4526                ));
4527            }
4528        }
4529        match page.next_cursor {
4530            None => break,
4531            Some(next) if next > after => after = next,
4532            Some(_) => {
4533                return Err(invalid_feed(
4534                    "self-custody candidate cursor did not advance",
4535                ))
4536            }
4537        }
4538    }
4539    if files.len() != expected.len()
4540        || files.iter().any(|(path, file)| {
4541            expected.get(path).is_none_or(|expected| {
4542                expected.sha256 != file.sha256 || expected.bytes != file.bytes
4543            })
4544        })
4545    {
4546        return Err(invalid_feed(
4547            "self-custody candidate contains an unexpected file mutation",
4548        ));
4549    }
4550    let mut assets = std::collections::BTreeMap::new();
4551    after.clear();
4552    loop {
4553        let encoded_after: String =
4554            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4555        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4556        let value = ensure_ok(
4557            request_capped(
4558                cfg,
4559                "GET",
4560                &path,
4561                None,
4562                Auth::Required,
4563                MAX_FEED_RESPONSE_BYTES,
4564            )?,
4565            "v2 self-custody asset candidate",
4566        )?;
4567        let page: V2SigningCandidatePage = serde_json::from_value(value)
4568            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4569        let coordinate = (
4570            page.request_hash.clone(),
4571            page.candidate.signing_bytes_base64.clone(),
4572            page.candidate.changes_base64.clone(),
4573            page.candidate.actor_claim_base64.clone(),
4574            page.candidate.content_root.clone(),
4575            page.candidate.asset_root.clone(),
4576            page.parent.seq,
4577            page.parent.commit_hash.clone(),
4578        );
4579        if page.v != 2
4580            || page.challenge_id != challenge_id
4581            || page.mutation_id != mutation_id
4582            || page.assets.len() > 500
4583            || pinned.as_ref() != Some(&coordinate)
4584        {
4585            return Err(invalid_feed(
4586                "self-custody asset candidate changed or is not bound",
4587            ));
4588        }
4589        let root = page.candidate.asset_root.as_deref();
4590        if !page.assets.is_empty() && root.is_none() {
4591            return Err(invalid_feed("asset candidate has no asset root"));
4592        }
4593        for item in page.assets {
4594            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4595            if assets
4596                .insert(
4597                    item.path.clone(),
4598                    V2BaselineAsset {
4599                        blob_sha256: item.blob_sha256,
4600                        bytes: item.bytes,
4601                        media_type: item.media_type,
4602                        wrappers: item.wrappers,
4603                        required: item.required,
4604                        disposition: item.disposition,
4605                        leaf_hash: item.leaf_hash,
4606                    },
4607                )
4608                .is_some()
4609            {
4610                return Err(invalid_feed("self-custody candidate repeats an asset"));
4611            }
4612        }
4613        match page.next_cursor {
4614            None => break,
4615            Some(next) if next > after => after = next,
4616            Some(_) => {
4617                return Err(invalid_feed(
4618                    "self-custody asset candidate cursor did not advance",
4619                ))
4620            }
4621        }
4622    }
4623    if assets.len() != expected_assets.len()
4624        || assets.iter().any(|(path, asset)| {
4625            expected_assets.get(path).is_none_or(|expected| {
4626                asset.blob_sha256 != expected.blob_sha256
4627                    || asset.bytes != expected.bytes
4628                    || asset.media_type != expected.media_type
4629                    || asset.wrappers != expected.wrappers
4630                    || asset.required != expected.required
4631                    || asset.disposition != expected.disposition
4632            })
4633        })
4634    {
4635        return Err(invalid_feed(
4636            "self-custody candidate contains an unexpected asset mutation",
4637        ));
4638    }
4639    let Some((
4640        request_hash,
4641        signing_b64,
4642        changes_b64,
4643        actor_b64,
4644        root,
4645        asset_root,
4646        parent_seq,
4647        parent,
4648    )) = pinned
4649    else {
4650        return Err(invalid_feed("self-custody candidate has no manifest"));
4651    };
4652    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4653    let current_commit = head
4654        .pointer
4655        .as_ref()
4656        .map(|pointer| pointer.commit_hash.clone());
4657    if parent_seq != current_seq || parent != current_commit {
4658        return Err(LinkError::RemoteAdvancedDuringSync);
4659    }
4660    let changes = STANDARD
4661        .decode(changes_b64)
4662        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4663    let mut expected_changes = json!({
4664        "mutation_id": mutation_id,
4665        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4666        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4667        "v": 2,
4668    });
4669    if let Some(withheld_links) = request_body.get("withheld_links") {
4670        expected_changes["withheld_links"] = withheld_links.clone();
4671    }
4672    if let Some(checkout_id) = request_body.get("checkout_id") {
4673        expected_changes["checkout_id"] = checkout_id.clone();
4674    }
4675    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4676        .map_err(|error| invalid_feed(error.to_string()))?;
4677    if changes != expected_changes_bytes {
4678        return Err(invalid_feed(
4679            "self-custody changeset differs from the requested mutation",
4680        ));
4681    }
4682    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4683        .map_err(|error| invalid_feed(error.to_string()))?;
4684    let request_value = json!({
4685        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4686        "brain": head.brain_id,
4687        "changes_sha256": changes_hash,
4688        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4689        "v": 2,
4690        "v1_bridge": Value::Null,
4691    });
4692    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4693        .map_err(|error| invalid_feed(error.to_string()))?;
4694    if request_hash != expected_request_hash {
4695        return Err(invalid_feed(
4696            "self-custody request hash differs from the requested mutation",
4697        ));
4698    }
4699    let actor = STANDARD
4700        .decode(actor_b64)
4701        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4702    let actor_value: Value = serde_json::from_slice(&actor)
4703        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4704    if crate::linkmd_v2::canonical_bytes(&actor_value)
4705        .map_err(|error| invalid_feed(error.to_string()))?
4706        != actor
4707    {
4708        return Err(invalid_feed("self-custody actor claim is not canonical"));
4709    }
4710    let actor_object = actor_value
4711        .as_object()
4712        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4713    let actor_claim = actor_object
4714        .get("claim")
4715        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4716    let actor_public_key = actor_object
4717        .get("public_key")
4718        .and_then(Value::as_str)
4719        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4720    let actor_fingerprint = actor_object
4721        .get("fingerprint")
4722        .and_then(Value::as_str)
4723        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4724    let actor_signature = actor_object
4725        .get("sig")
4726        .and_then(Value::as_str)
4727        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4728    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4729        .map_err(|error| invalid_feed(error.to_string()))?;
4730    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4731    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4732    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4733    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4734    let impact = actor_claim
4735        .get("result")
4736        .and_then(|result| result.get("impact"))
4737        .and_then(Value::as_object);
4738    let impact_fields = [
4739        "creates",
4740        "updates",
4741        "deletes",
4742        "withdrawals",
4743        "renames",
4744        "restores",
4745        "asset_changes",
4746        "public_expansions",
4747        "executable_activations",
4748    ];
4749    let impact_is_valid = impact.is_some_and(|impact| {
4750        impact.len() == impact_fields.len() + 1
4751            && impact.get("v").and_then(Value::as_u64) == Some(1)
4752            && impact_fields
4753                .iter()
4754                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4755    });
4756    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4757        || head
4758            .trust
4759            .hub_signer
4760            .as_ref()
4761            .is_some_and(|known| known != &expected_actor_signer)
4762        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4763        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4764        || actor_claim
4765            .get("candidate")
4766            .and_then(|candidate| candidate.get("changes_sha256"))
4767            .and_then(Value::as_str)
4768            != Some(changes_hash.as_str())
4769        || actor_claim
4770            .get("candidate")
4771            .and_then(|candidate| candidate.get("state_root"))
4772            != Some(&expected_actor_root)
4773        || actor_claim
4774            .get("candidate")
4775            .and_then(|candidate| candidate.get("asset_root"))
4776            != Some(&expected_actor_asset_root)
4777        || actor_claim
4778            .get("candidate")
4779            .and_then(|candidate| candidate.get("control_revision"))
4780            .and_then(Value::as_str)
4781            != Some(head.control_revision.as_str())
4782        || !impact_is_valid
4783    {
4784        return Err(invalid_feed(
4785            "self-custody actor claim does not bind the verified authority",
4786        ));
4787    }
4788    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4789        .map_err(|error| invalid_feed(error.to_string()))?;
4790    let signing = STANDARD
4791        .decode(signing_b64)
4792        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4793    let signing_value: Value = serde_json::from_slice(&signing)
4794        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4795    if crate::linkmd_v2::canonical_bytes(&signing_value)
4796        .map_err(|error| invalid_feed(error.to_string()))?
4797        != signing
4798    {
4799        return Err(invalid_feed("self-custody signing bytes are not canonical"));
4800    }
4801    let pointer = head.pointer.as_ref();
4802    let expected_materializer = pointer
4803        .map(|value| value.materializer.as_str())
4804        .unwrap_or("dbmd-projection-v1");
4805    let expected_parent_commit = request_body
4806        .get("base")
4807        .and_then(|base| base.get("commit_hash"))
4808        .cloned()
4809        .unwrap_or(Value::Null);
4810    let expected_parent_root = request_body
4811        .get("base")
4812        .and_then(|base| base.get("content_root"))
4813        .cloned()
4814        .unwrap_or(Value::Null);
4815    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4816    let expected_parent_asset_root = request_body
4817        .get("base")
4818        .and_then(|base| base.get("asset_root"))
4819        .cloned()
4820        .unwrap_or(Value::Null);
4821    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4822    let expected_prev_entry = pointer
4823        .map(|value| Value::String(value.feed_hash.clone()))
4824        .unwrap_or(Value::Null);
4825    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4826        .map_err(|_| invalid_feed("brain identity history is too large"))?
4827        + 1;
4828    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4829        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4830        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4831        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4832        || signing_value.get("public_key").and_then(Value::as_str)
4833            != Some(key.public_key_spki.as_str())
4834        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4835        || signing_value.get("parent_root") != Some(&expected_parent_root)
4836        || signing_value.get("state_root") != Some(&expected_state_root)
4837        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4838        || signing_value.get("asset_root") != Some(&expected_asset_root)
4839        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4840        || signing_value.get("changes_sha256").and_then(Value::as_str)
4841            != Some(changes_hash.as_str())
4842        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4843        || signing_value
4844            .get("control_revision")
4845            .and_then(Value::as_str)
4846            != Some(head.control_revision.as_str())
4847        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4848        || signing_value.get("v1_bridge") != Some(&Value::Null)
4849        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4850    {
4851        return Err(invalid_feed(
4852            "self-custody signing bytes do not bind the verified candidate",
4853        ));
4854    }
4855    let pair = agent_keypair(&key.pkcs8)?;
4856    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4857    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4858}
4859
4860fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4861    let origin = normalized_origin(&cfg.hub)?;
4862    let absolute = if checkout.is_absolute() {
4863        checkout.to_path_buf()
4864    } else {
4865        std::env::current_dir()?.join(checkout)
4866    };
4867    Ok(format!(
4868        "sync-{}.json",
4869        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4870    ))
4871}
4872
4873fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4874    if let Some(value) = existing {
4875        if !is_sha256(value) {
4876            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4877        }
4878        return Ok(value.to_string());
4879    }
4880    use ring::rand::SecureRandom as _;
4881    let mut random = [0_u8; 32];
4882    ring::rand::SystemRandom::new()
4883        .fill(&mut random)
4884        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4885    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4886}
4887
4888#[cfg(any(unix, windows))]
4889fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4890    let directory = open_trust_dir(cfg)?;
4891    let origin = normalized_origin(&cfg.hub)?;
4892    let name = format!(
4893        "operation-{}.lock",
4894        content_sha256(format!("{origin}\0{brain}").as_bytes())
4895    );
4896    lock_trust_name(&directory, &name)
4897}
4898
4899#[cfg(not(any(unix, windows)))]
4900fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4901    Err(LinkError::UnsupportedPlatform {
4902        operation: "serialized link.md v2 sync",
4903    })
4904}
4905
4906fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4907    left.brain_id == right.brain_id
4908        && left.view_kind == right.view_kind
4909        && left.view_revision == right.view_revision
4910        && left.control_revision == right.control_revision
4911        && match (&left.pointer, &right.pointer) {
4912            (None, None) => true,
4913            (Some(left), Some(right)) => {
4914                left.seq == right.seq
4915                    && left.commit_hash == right.commit_hash
4916                    && left.content_root == right.content_root
4917                    && left.asset_root == right.asset_root
4918                    && left.feed_hash == right.feed_hash
4919            }
4920            _ => false,
4921        }
4922}
4923
4924/// A private sync baseline is an already verified materialization of the
4925/// remote manifest. It may replace another paginated manifest download only
4926/// when a freshly verified signed head names the exact same content, assets,
4927/// view, and authority revision. Any remote or permission movement falls back
4928/// to proof-bearing hub reads.
4929fn v2_baseline_matches_head(head: &V2VerifiedHead, baseline: &V2SyncBaseline) -> bool {
4930    let pointer = head.pointer.as_ref();
4931    baseline.head_seq == Some(pointer.map_or(0, |value| value.seq))
4932        && baseline.commit_hash.as_deref() == pointer.map(|value| value.commit_hash.as_str())
4933        && baseline.content_root.as_deref()
4934            == pointer.and_then(|value| value.content_root.as_deref())
4935        && baseline.asset_root.as_deref() == pointer.and_then(|value| value.asset_root.as_deref())
4936        && baseline.view_kind.as_deref() == Some(head.view_kind.as_str())
4937        && baseline.view_revision.as_deref() == Some(head.view_revision.as_str())
4938        && baseline.control_revision.as_deref() == Some(head.control_revision.as_str())
4939}
4940
4941fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4942    format!(
4943        "---\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"
4944    )
4945    .into_bytes()
4946}
4947
4948fn scoped_projection_sha256(brain: &str) -> String {
4949    content_sha256(&scoped_projection_bytes(brain))
4950}
4951
4952#[derive(Deserialize)]
4953struct LocalScopedViewMarker {
4954    v: u8,
4955    kind: String,
4956    authoritative: bool,
4957    brain: String,
4958    projection_sha256: String,
4959}
4960
4961/// True only when this store carries the exact generated marker for a local
4962/// link.md scoped view. This is presentation context, never authorization;
4963/// the hub remains the only authority for reads and writes.
4964pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4965    let marker = store
4966        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4967        .ok()
4968        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4969    let Some(marker) = marker else {
4970        return false;
4971    };
4972    if marker.v != 1
4973        || marker.kind != "link.md-scoped-view"
4974        || marker.authoritative
4975        || !crate::ulid::is_ulid(&marker.brain)
4976        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4977    {
4978        return false;
4979    }
4980    store
4981        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4982        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4983}
4984
4985fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4986    let mut bytes = serde_json::to_vec_pretty(&json!({
4987        "v": 1,
4988        "kind": "link.md-scoped-view",
4989        "authoritative": false,
4990        "brain": head.brain_id,
4991        "view_revision": head.view_revision,
4992        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4993        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4994        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4995        "visible_files": files,
4996        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4997    }))
4998    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4999    bytes.push(b'\n');
5000    Ok(bytes)
5001}
5002
5003fn refresh_scoped_view_marker(
5004    store: &Store,
5005    head: &V2VerifiedHead,
5006    files: usize,
5007) -> LinkResult<()> {
5008    if head.view_kind == "scoped" {
5009        store.write_atomic(
5010            Path::new(".dbmd/view.json"),
5011            &scoped_view_metadata(head, files)?,
5012        )?;
5013    }
5014    Ok(())
5015}
5016
5017fn ensure_v2_view_compatible(
5018    head: &V2VerifiedHead,
5019    baseline: Option<&V2SyncBaseline>,
5020) -> LinkResult<()> {
5021    let Some(baseline) = baseline else {
5022        return Ok(());
5023    };
5024    match (
5025        baseline.view_kind.as_deref(),
5026        baseline.view_revision.as_deref(),
5027    ) {
5028        (None, None) if head.view_kind == "full" => Ok(()),
5029        (Some(kind), Some(revision))
5030            if kind == head.view_kind && revision == head.view_revision =>
5031        {
5032            Ok(())
5033        }
5034        _ => Err(LinkError::ScopedViewChanged),
5035    }
5036}
5037
5038fn ensure_established_v2_checkout_opened(
5039    head: &V2VerifiedHead,
5040    baseline: Option<&V2SyncBaseline>,
5041    opened: bool,
5042) -> LinkResult<()> {
5043    if baseline.is_none() || opened {
5044        return Ok(());
5045    }
5046    if head.view_kind == "scoped" {
5047        return Err(LinkError::ScopedProjectionModified);
5048    }
5049    Err(LinkError::InvalidPack {
5050        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
5051    })
5052}
5053
5054fn remove_scoped_projection(
5055    head: &V2VerifiedHead,
5056    baseline: Option<&V2SyncBaseline>,
5057    view: &mut V2LocalView,
5058) -> LinkResult<()> {
5059    if head.view_kind != "scoped" {
5060        return Ok(());
5061    }
5062    let expected = scoped_projection_sha256(&head.brain_id);
5063    if baseline
5064        .and_then(|state| state.projection_sha256.as_deref())
5065        .is_some_and(|pinned| pinned != expected)
5066    {
5067        return Err(LinkError::ScopedViewChanged);
5068    }
5069    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
5070        return Err(LinkError::ScopedProjectionModified);
5071    }
5072    view.riding.remove("DB.md");
5073    view.scan_cache.remove("DB.md");
5074    view.eligibility.remove("DB.md");
5075    Ok(())
5076}
5077
5078fn local_view_for_v2_push(
5079    store: &Store,
5080    head: &V2VerifiedHead,
5081    baseline: Option<&V2SyncBaseline>,
5082    carried: Option<V2LocalView>,
5083) -> LinkResult<V2LocalView> {
5084    match carried {
5085        // The pull half already verified and removed the generated scoped
5086        // projection before constructing its in-memory handoff. Requiring it
5087        // a second time would turn every scoped bidirectional write into a
5088        // false tamper refusal.
5089        Some(view) => Ok(view),
5090        None => {
5091            let hint = baseline.and_then(|state| {
5092                state
5093                    .local_policy_digest
5094                    .as_deref()
5095                    .map(|digest| (digest, &state.scan_cache))
5096            });
5097            let mut view = v2_local_files_cached(store, hint)?;
5098            remove_scoped_projection(head, baseline, &mut view)?;
5099            Ok(view)
5100        }
5101    }
5102}
5103
5104fn files_for_v2_view(
5105    head: &V2VerifiedHead,
5106    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
5107) -> std::collections::BTreeMap<String, V2BaselineFile> {
5108    if head.view_kind == "scoped" {
5109        // Even when a grant explicitly includes canonical DB.md, a partial
5110        // checkout uses a generated marker. Canonical validation remains a
5111        // server-side operation over the complete brain.
5112        files.remove("DB.md");
5113    }
5114    files
5115}
5116
5117fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
5118    let baseline: V2SyncBaseline =
5119        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
5120    if baseline.v != 2
5121        || baseline.origin != normalized_origin(&cfg.hub)?
5122        || baseline.brain != brain
5123        || baseline
5124            .commit_hash
5125            .as_deref()
5126            .is_some_and(|hash| !is_sha256(hash))
5127        || baseline
5128            .content_root
5129            .as_deref()
5130            .is_some_and(|hash| !is_sha256(hash))
5131        || baseline
5132            .asset_root
5133            .as_deref()
5134            .is_some_and(|hash| !is_sha256(hash))
5135        || baseline
5136            .local_policy_digest
5137            .as_deref()
5138            .is_some_and(|hash| !is_sha256(hash))
5139        || baseline
5140            .view_kind
5141            .as_deref()
5142            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5143        || baseline
5144            .view_revision
5145            .as_deref()
5146            .is_some_and(|hash| !is_sha256(hash))
5147        || baseline
5148            .control_revision
5149            .as_deref()
5150            .is_some_and(|hash| !is_sha256(hash))
5151        || baseline
5152            .projection_sha256
5153            .as_deref()
5154            .is_some_and(|hash| !is_sha256(hash))
5155        || (baseline.view_kind.as_deref() == Some("scoped")
5156            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5157        || baseline.files.len() > MAX_PUSH_FILES
5158        || baseline.scan_cache.len() > MAX_PUSH_FILES
5159        || baseline.assets.len() > MAX_PUSH_FILES
5160        || baseline.local_eligibility.len() > MAX_PUSH_FILES
5161        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5162        || baseline.files.iter().any(|(path, file)| {
5163            crate::linkmd_v2::normalize_path(path).is_err()
5164                || !is_sha256(&file.sha256)
5165                || file.bytes > MAX_STORE_BYTES
5166        })
5167        || baseline.scan_cache.iter().any(|(path, file)| {
5168            crate::linkmd_v2::normalize_path(path).is_err()
5169                || !file.fingerprint.starts_with("unix-v1:")
5170                || file.fingerprint.len() > 256
5171                || !file.fingerprint.is_ascii()
5172                || !is_sha256(&file.sha256)
5173                || file.bytes > MAX_STORE_BYTES
5174                || file.withheld_targets.len() > MAX_PUSH_FILES
5175                || file.withheld_targets.iter().any(|target| {
5176                    target.len() > MAX_STORE_PATH_BYTES
5177                        || crate::linkmd_v2::normalize_path(target).is_err()
5178                })
5179                || baseline.files.get(path).is_none_or(|baseline_file| {
5180                    baseline_file.sha256 != file.sha256 || baseline_file.bytes != file.bytes
5181                })
5182        })
5183        || baseline.assets.iter().any(|(path, asset)| {
5184            crate::linkmd_v2::normalize_path(path).is_err()
5185                || !is_sha256(&asset.blob_sha256)
5186                || !is_sha256(&asset.leaf_hash)
5187                || asset.bytes > MAX_ASSET_BYTES
5188                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5189                || asset.wrappers.is_empty()
5190                || asset
5191                    .wrappers
5192                    .iter()
5193                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5194        })
5195        || baseline
5196            .local_eligibility
5197            .keys()
5198            .chain(baseline.remote_copy_remains.keys())
5199            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5200        || baseline
5201            .remote_copy_remains
5202            .values()
5203            .any(|hash| !is_sha256(hash))
5204        || baseline
5205            .checkout_id
5206            .as_deref()
5207            .is_some_and(|checkout_id| !is_sha256(checkout_id))
5208    {
5209        return Err(invalid_feed("v2 sync baseline failed validation"));
5210    }
5211    Ok(baseline)
5212}
5213
5214#[cfg(unix)]
5215fn load_v2_baseline_in(
5216    cfg: &HubConfig,
5217    brain: &str,
5218    directory: &TrustDirectory,
5219    name_string: &str,
5220) -> LinkResult<Option<V2SyncBaseline>> {
5221    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5222    let name = c_name(name_string.as_bytes(), name_string)?;
5223    let fd = unsafe {
5224        libc::openat(
5225            directory.as_raw_fd(),
5226            name.as_ptr(),
5227            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5228        )
5229    };
5230    if fd < 0 {
5231        let error = std::io::Error::last_os_error();
5232        return if error.kind() == std::io::ErrorKind::NotFound {
5233            Ok(None)
5234        } else {
5235            Err(LinkError::UnsafePath {
5236                path: name_string.to_string(),
5237            })
5238        };
5239    }
5240    let file = unsafe { std::fs::File::from_raw_fd(fd) };
5241    let mut bytes = Vec::new();
5242    file.take(MAX_V2_BASELINE_BYTES + 1)
5243        .read_to_end(&mut bytes)?;
5244    if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5245        return Err(invalid_feed("v2 sync baseline is oversized"));
5246    }
5247    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5248}
5249
5250#[cfg(unix)]
5251fn load_v2_baseline(
5252    cfg: &HubConfig,
5253    brain: &str,
5254    checkout: &Path,
5255) -> LinkResult<Option<V2SyncBaseline>> {
5256    let directory = open_trust_dir(cfg)?;
5257    let name = v2_baseline_name(cfg, brain, checkout)?;
5258    let _lock = lock_trust_name(&directory, &name)?;
5259    load_v2_baseline_in(cfg, brain, &directory, &name)
5260}
5261
5262#[cfg(windows)]
5263fn load_v2_baseline_in(
5264    cfg: &HubConfig,
5265    brain: &str,
5266    directory: &TrustDirectory,
5267    name: &str,
5268) -> LinkResult<Option<V2SyncBaseline>> {
5269    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
5270    match reader.read(Path::new(&name), MAX_V2_BASELINE_BYTES) {
5271        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5272        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5273        Err(_) => Err(LinkError::UnsafePath {
5274            path: name.to_string(),
5275        }),
5276    }
5277}
5278
5279#[cfg(windows)]
5280fn load_v2_baseline(
5281    cfg: &HubConfig,
5282    brain: &str,
5283    checkout: &Path,
5284) -> LinkResult<Option<V2SyncBaseline>> {
5285    let directory = open_trust_dir(cfg)?;
5286    let name = v2_baseline_name(cfg, brain, checkout)?;
5287    let _lock = lock_trust_name(&directory, &name)?;
5288    load_v2_baseline_in(cfg, brain, &directory, &name)
5289}
5290
5291#[cfg(not(any(unix, windows)))]
5292fn load_v2_baseline(
5293    _cfg: &HubConfig,
5294    _brain: &str,
5295    _checkout: &Path,
5296) -> LinkResult<Option<V2SyncBaseline>> {
5297    Err(LinkError::UnsupportedPlatform {
5298        operation: "verified link.md v2 baseline",
5299    })
5300}
5301
5302#[cfg(unix)]
5303fn save_v2_baseline(
5304    cfg: &HubConfig,
5305    brain: &str,
5306    checkout: &Path,
5307    baseline: &V2SyncBaseline,
5308) -> LinkResult<()> {
5309    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5310    let directory = open_trust_dir(cfg)?;
5311    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5312    let _lock = lock_trust_name(&directory, &name_string)?;
5313    let name = c_name(name_string.as_bytes(), &name_string)?;
5314    let mut bytes = serde_json::to_vec(baseline)
5315        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5316    bytes.push(b'\n');
5317    if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5318        return Err(invalid_feed("v2 sync baseline is oversized"));
5319    }
5320    let temp_string = format!(
5321        ".{name_string}.tmp.{}-{}",
5322        std::process::id(),
5323        std::time::SystemTime::now()
5324            .duration_since(std::time::UNIX_EPOCH)
5325            .unwrap_or_default()
5326            .as_nanos()
5327    );
5328    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5329    let fd = unsafe {
5330        libc::openat(
5331            directory.as_raw_fd(),
5332            temp.as_ptr(),
5333            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5334            0o600,
5335        )
5336    };
5337    if fd < 0 {
5338        return Err(std::io::Error::last_os_error().into());
5339    }
5340    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5341    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5342        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5343        return Err(error.into());
5344    }
5345    drop(file);
5346    if unsafe {
5347        libc::renameat(
5348            directory.as_raw_fd(),
5349            temp.as_ptr(),
5350            directory.as_raw_fd(),
5351            name.as_ptr(),
5352        )
5353    } != 0
5354    {
5355        let error = std::io::Error::last_os_error();
5356        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5357        return Err(error.into());
5358    }
5359    directory.sync_all()?;
5360    Ok(())
5361}
5362
5363#[cfg(windows)]
5364fn save_v2_baseline(
5365    cfg: &HubConfig,
5366    brain: &str,
5367    checkout: &Path,
5368    baseline: &V2SyncBaseline,
5369) -> LinkResult<()> {
5370    let directory = open_trust_dir(cfg)?;
5371    let name = v2_baseline_name(cfg, brain, checkout)?;
5372    let _lock = lock_trust_name(&directory, &name)?;
5373    let mut bytes = serde_json::to_vec(baseline)
5374        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5375    bytes.push(b'\n');
5376    if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5377        return Err(invalid_feed("v2 sync baseline is oversized"));
5378    }
5379    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5380    Ok(())
5381}
5382
5383#[cfg(not(any(unix, windows)))]
5384fn save_v2_baseline(
5385    _cfg: &HubConfig,
5386    _brain: &str,
5387    _checkout: &Path,
5388    _baseline: &V2SyncBaseline,
5389) -> LinkResult<()> {
5390    Err(LinkError::UnsupportedPlatform {
5391        operation: "verified link.md v2 baseline",
5392    })
5393}
5394
5395fn v2_baseline_from_head(
5396    cfg: &HubConfig,
5397    head: &V2VerifiedHead,
5398    files: std::collections::BTreeMap<String, V2BaselineFile>,
5399    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5400    local: Option<&V2LocalView>,
5401    checkout_id: Option<&str>,
5402) -> LinkResult<V2SyncBaseline> {
5403    let mut local_eligibility = local
5404        .map(|view| view.eligibility.clone())
5405        .unwrap_or_default();
5406    if let Some(view) = local {
5407        for path in files.keys() {
5408            local_eligibility
5409                .entry(path.clone())
5410                .or_insert_with(|| !view.policy.keeps_home(path));
5411        }
5412    }
5413    let remote_copy_remains = local_eligibility
5414        .iter()
5415        .filter(|(_, riding)| !**riding)
5416        .filter_map(|(path, _)| {
5417            files
5418                .get(path)
5419                .map(|file| (path.clone(), file.sha256.clone()))
5420        })
5421        .collect();
5422    // The accepted remote tree is the new comparison base even if a local
5423    // writer changed a few files while the commit was in flight. Preserve
5424    // acceleration only for exact local/remote matches; divergent paths are
5425    // intentionally absent and take the full hash path next run.
5426    let scan_cache = local
5427        .map(|view| {
5428            view.scan_cache
5429                .iter()
5430                .filter(|(path, cached)| {
5431                    files.get(*path).is_some_and(|remote| {
5432                        remote.sha256 == cached.sha256 && remote.bytes == cached.bytes
5433                    })
5434                })
5435                .map(|(path, cached)| (path.clone(), cached.clone()))
5436                .collect()
5437        })
5438        .unwrap_or_default();
5439    Ok(V2SyncBaseline {
5440        v: 2,
5441        origin: normalized_origin(&cfg.hub)?,
5442        brain: head.brain_id.clone(),
5443        checkout_id: Some(v2_checkout_id(checkout_id)?),
5444        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5445        commit_hash: head
5446            .pointer
5447            .as_ref()
5448            .map(|pointer| pointer.commit_hash.clone()),
5449        content_root: head
5450            .pointer
5451            .as_ref()
5452            .and_then(|pointer| pointer.content_root.clone()),
5453        asset_root: head
5454            .pointer
5455            .as_ref()
5456            .and_then(|pointer| pointer.asset_root.clone()),
5457        assets,
5458        view_kind: Some(head.view_kind.clone()),
5459        view_revision: Some(head.view_revision.clone()),
5460        control_revision: Some(head.control_revision.clone()),
5461        projection_sha256: (head.view_kind == "scoped")
5462            .then(|| scoped_projection_sha256(&head.brain_id)),
5463        files,
5464        scan_cache,
5465        local_policy_digest: local.map(|view| view.policy.digest.clone()),
5466        local_eligibility,
5467        remote_copy_remains,
5468    })
5469}
5470
5471#[cfg(unix)]
5472fn v2_scan_fingerprint_at(metadata: &std::fs::Metadata, now_ns: i128) -> Option<String> {
5473    use std::os::unix::fs::MetadataExt as _;
5474
5475    // Git's "racy clean" lesson applies here: a same-size edit inside a
5476    // filesystem timestamp tick can otherwise preserve the whole stat tuple.
5477    // Do not cache a file until its mtime and ctime are at least two seconds
5478    // behind the observer. Future/negative timestamps also stay on the full
5479    // hash path. ctime cannot be restored by an unprivileged writer, so after
5480    // that window an in-place edit, chmod, or atomic replacement invalidates
5481    // the tuple even when a tool deliberately restores mtime.
5482    let mtime_ns = i128::from(metadata.mtime()) * 1_000_000_000 + i128::from(metadata.mtime_nsec());
5483    let ctime_ns = i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
5484    let newest = mtime_ns.max(ctime_ns);
5485    if newest < 0 || now_ns.checked_sub(newest)? < 2_000_000_000 {
5486        return None;
5487    }
5488    Some(format!(
5489        "unix-v1:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}",
5490        metadata.dev(),
5491        metadata.ino(),
5492        metadata.len(),
5493        metadata.mtime(),
5494        metadata.mtime_nsec(),
5495        metadata.ctime(),
5496        metadata.ctime_nsec(),
5497    ))
5498}
5499
5500#[cfg(unix)]
5501fn v2_scan_fingerprint(metadata: &std::fs::Metadata) -> Option<String> {
5502    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?;
5503    let now_ns = i128::from(now.as_secs()) * 1_000_000_000 + i128::from(now.subsec_nanos());
5504    v2_scan_fingerprint_at(metadata, now_ns)
5505}
5506
5507#[cfg(not(unix))]
5508fn v2_scan_fingerprint(_metadata: &std::fs::Metadata) -> Option<String> {
5509    // Full hashing remains the honest portable fallback until another
5510    // platform has an equally strong file-identity + change-time tuple.
5511    None
5512}
5513
5514fn v2_local_files_cached(
5515    store: &Store,
5516    prior_cache: Option<(&str, &std::collections::BTreeMap<String, V2ScanCacheFile>)>,
5517) -> LinkResult<V2LocalView> {
5518    let policy = crate::linkmd_sync_policy::load(store)
5519        .map_err(|message| LinkError::InvalidPack { message })?;
5520    let prior_cache = prior_cache
5521        .filter(|(digest, _)| *digest == policy.digest.as_str())
5522        .map(|(_, cache)| cache);
5523    let non_markdown_asset_paths = crate::assets::read_manifest(store)
5524        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5525        .into_iter()
5526        .filter(|asset| !is_markdown_asset_path(&asset.path))
5527        .map(|asset| asset.path)
5528        .collect::<std::collections::BTreeSet<_>>();
5529    let mut result = std::collections::BTreeMap::new();
5530    let mut scan_cache = std::collections::BTreeMap::new();
5531    let mut eligibility = std::collections::BTreeMap::new();
5532    let mut withheld_links = Vec::<V2WithheldLink>::new();
5533    let mut total = 0_u64;
5534    let mut paths = vec![PathBuf::from("DB.md")];
5535    paths.extend(store.walk()?);
5536    for relative in paths {
5537        let path = relative.to_string_lossy().replace('\\', "/");
5538        // v2 catalogs and asset inventory are materialized/signed separately.
5539        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5540            continue;
5541        }
5542        if non_markdown_asset_paths.contains(&path) {
5543            continue;
5544        }
5545        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5546            path: error.to_string(),
5547        })?;
5548        let riding = !policy.keeps_home(&path);
5549        eligibility.insert(path.clone(), riding);
5550        if !riding {
5551            continue;
5552        }
5553        let remaining = MAX_STORE_BYTES.saturating_sub(total);
5554        let mut file = store.open_regular(&relative)?;
5555        let before = file.metadata()?;
5556        if before.len() > remaining {
5557            return Err(LinkError::PushTooLarge {
5558                detail: format!("more than {MAX_STORE_BYTES} uncompressed bytes"),
5559            });
5560        }
5561        let fingerprint = v2_scan_fingerprint(&before);
5562        if let (Some(fingerprint), Some(cached)) = (
5563            fingerprint.as_deref(),
5564            prior_cache.and_then(|cache| cache.get(&path)),
5565        ) {
5566            if cached.fingerprint == fingerprint && cached.bytes == before.len() {
5567                total = total
5568                    .checked_add(cached.bytes)
5569                    .ok_or_else(|| LinkError::PushTooLarge {
5570                        detail: "v2 local byte count overflow".to_string(),
5571                    })?;
5572                result.insert(path.clone(), (cached.sha256.clone(), cached.bytes));
5573                for target in &cached.withheld_targets {
5574                    withheld_links.push(V2WithheldLink {
5575                        source: path.clone(),
5576                        target: target.clone(),
5577                    });
5578                }
5579                scan_cache.insert(path, cached.clone());
5580                continue;
5581            }
5582        }
5583        let mut bytes = Vec::with_capacity(before.len().min(8 * 1024 * 1024) as usize);
5584        Read::by_ref(&mut file)
5585            .take(remaining.saturating_add(1))
5586            .read_to_end(&mut bytes)?;
5587        if bytes.len() as u64 > remaining {
5588            return Err(LinkError::PushTooLarge {
5589                detail: format!("more than {MAX_STORE_BYTES} uncompressed bytes"),
5590            });
5591        }
5592        total = total
5593            .checked_add(bytes.len() as u64)
5594            .ok_or_else(|| LinkError::PushTooLarge {
5595                detail: "v2 local byte count overflow".to_string(),
5596            })?;
5597        if total > MAX_STORE_BYTES {
5598            return Err(LinkError::PushTooLarge {
5599                detail: format!("{total} uncompressed bytes"),
5600            });
5601        }
5602        if std::str::from_utf8(&bytes).is_err() {
5603            return Err(LinkError::NotUtf8 { path });
5604        }
5605        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5606        // A kept-home target need not be present on THIS machine. The policy
5607        // is the owner's durable statement about where a path lives. Cache
5608        // only the resulting withheld targets (rather than every wiki-link),
5609        // and bind reuse to the exact policy digest above.
5610        let targets = crate::store::extract_edge_targets(text)
5611            .into_iter()
5612            .map(|target| format!("{target}.md"))
5613            .filter(|target| policy.keeps_home(target))
5614            .collect::<Vec<_>>();
5615        for target in &targets {
5616            withheld_links.push(V2WithheldLink {
5617                source: path.clone(),
5618                target: target.clone(),
5619            });
5620        }
5621        let sha256 = content_sha256(&bytes);
5622        result.insert(path.clone(), (sha256.clone(), bytes.len() as u64));
5623        let after = file.metadata()?;
5624        if before.len() == bytes.len() as u64
5625            && v2_scan_fingerprint(&before) == v2_scan_fingerprint(&after)
5626        {
5627            if let Some(fingerprint) = v2_scan_fingerprint(&after) {
5628                scan_cache.insert(
5629                    path,
5630                    V2ScanCacheFile {
5631                        fingerprint,
5632                        sha256,
5633                        bytes: bytes.len() as u64,
5634                        withheld_targets: targets,
5635                    },
5636                );
5637            }
5638        }
5639    }
5640    withheld_links.sort();
5641    withheld_links.dedup();
5642    Ok(V2LocalView {
5643        riding: result,
5644        scan_cache,
5645        eligibility,
5646        policy,
5647        withheld_links,
5648    })
5649}
5650
5651fn is_markdown_asset_path(path: &str) -> bool {
5652    path.to_ascii_lowercase().ends_with(".md")
5653}
5654
5655fn v2_content_put_operation_kind(
5656    path: &str,
5657    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
5658) -> &'static str {
5659    if local_assets.contains_key(path) && is_markdown_asset_path(path) {
5660        "put_asset_content"
5661    } else {
5662        "put"
5663    }
5664}
5665
5666fn v2_withdrawal_includes_content(
5667    path: &str,
5668    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
5669) -> bool {
5670    local_assets
5671        .get(path)
5672        .is_none_or(|asset| is_markdown_asset_path(&asset.path))
5673}
5674
5675fn verify_v2_markdown_asset_content_bindings(
5676    content: &std::collections::BTreeMap<String, V2BaselineFile>,
5677    assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
5678) -> LinkResult<()> {
5679    for (path, asset) in assets {
5680        if !is_markdown_asset_path(path) {
5681            continue;
5682        }
5683        let content_file = content.get(path);
5684        let exact = content_file
5685            .is_some_and(|file| file.sha256 == asset.blob_sha256 && file.bytes == asset.bytes);
5686        if (asset.disposition == "hosted" && !exact)
5687            || (asset.disposition == "withheld" && content_file.is_some())
5688        {
5689            return Err(invalid_feed(format!(
5690                "markdown asset `{path}` is not exactly bound across the signed content and asset views"
5691            )));
5692        }
5693    }
5694    Ok(())
5695}
5696
5697fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5698    v2_local_files_cached(store, None)
5699}
5700
5701#[derive(Debug, Clone, Deserialize)]
5702struct V2DownloadItem {
5703    path: String,
5704    sha256: String,
5705    bytes: u64,
5706    url: String,
5707    method: String,
5708}
5709
5710#[derive(Debug, Deserialize)]
5711struct V2DownloadWindow {
5712    v: u8,
5713    commit: String,
5714    downloads: Vec<V2DownloadItem>,
5715}
5716
5717#[derive(Debug, Deserialize)]
5718struct V2BulkStreamHeader {
5719    v: u8,
5720    path: String,
5721    sha256: String,
5722    bytes: u64,
5723}
5724
5725fn parse_v2_bulk_stream(
5726    bytes: &[u8],
5727    expected: &[(&String, &V2BaselineFile)],
5728) -> LinkResult<Vec<(String, Vec<u8>)>> {
5729    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5730        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5731    }
5732    let mut cursor = V2_BULK_STREAM_MAGIC.len();
5733    let mut result = Vec::with_capacity(expected.len());
5734    for (expected_path, expected_file) in expected {
5735        let length_bytes = bytes
5736            .get(cursor..cursor + 4)
5737            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5738        cursor += 4;
5739        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5740        if header_len == 0 || header_len > 4 * 1024 {
5741            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5742        }
5743        let header_bytes = bytes
5744            .get(cursor..cursor + header_len)
5745            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5746        cursor += header_len;
5747        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5748            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5749        if header.v != 2
5750            || &header.path != *expected_path
5751            || header.sha256 != expected_file.sha256
5752            || header.bytes != expected_file.bytes
5753            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5754        {
5755            return Err(invalid_feed(
5756                "v2 bulk stream frame differs from its proven manifest entry",
5757            ));
5758        }
5759        let body_len = usize::try_from(header.bytes)
5760            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5761        let body = bytes
5762            .get(cursor..cursor + body_len)
5763            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5764        cursor += body_len;
5765        if content_sha256(body) != header.sha256 {
5766            return Err(invalid_feed(
5767                "v2 bulk stream file differs from its proven manifest entry",
5768            ));
5769        }
5770        result.push((header.path, body.to_vec()));
5771    }
5772    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5773        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5774    }
5775    cursor += 4;
5776    if cursor != bytes.len() {
5777        return Err(invalid_feed("v2 bulk stream carries trailing data"));
5778    }
5779    Ok(result)
5780}
5781
5782fn download_v2_bulk_stream(
5783    cfg: &HubConfig,
5784    brain: &str,
5785    pointer: &V2PointerBody,
5786    pending: &[(&String, &V2BaselineFile)],
5787) -> LinkResult<Vec<(String, Vec<u8>)>> {
5788    let claims = pending
5789        .iter()
5790        .map(|(path, file)| {
5791            Ok(json!({
5792                "path": path,
5793                "sha256": file.sha256,
5794                "bytes": file.bytes,
5795                "proof": file.proof.as_ref().ok_or_else(|| {
5796                    invalid_feed("v2 manifest omitted a bulk-stream proof")
5797                })?,
5798            }))
5799        })
5800        .collect::<LinkResult<Vec<_>>>()?;
5801    let raw = request_raw_retryable_read(
5802        cfg,
5803        "POST",
5804        &format!("/api/hub/brains/{brain}/v2/stream"),
5805        Some(&json!({
5806            "commit": pointer.commit_hash,
5807            "files": claims,
5808        })),
5809        Auth::Required,
5810        V2_BULK_STREAM_RESPONSE_BYTES,
5811    )?;
5812    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5813    parse_v2_bulk_stream(&body, pending)
5814}
5815
5816fn request_capped_retryable_read(
5817    cfg: &HubConfig,
5818    method: &str,
5819    path: &str,
5820    body: Option<&Value>,
5821    auth: Auth,
5822    max_response_bytes: u64,
5823) -> LinkResult<HubResponse> {
5824    let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5825    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5826    Ok(HubResponse {
5827        status: raw.status,
5828        body: parsed,
5829    })
5830}
5831
5832fn prepare_v2_downloads(
5833    cfg: &HubConfig,
5834    brain: &str,
5835    pointer: &V2PointerBody,
5836    pending: &[(&String, &V2BaselineFile)],
5837) -> LinkResult<Vec<V2DownloadItem>> {
5838    let mut result = Vec::with_capacity(pending.len());
5839    for chunk in pending.chunks(128) {
5840        let claims = chunk
5841            .iter()
5842            .map(|(path, file)| {
5843                Ok(json!({
5844                    "path": path,
5845                    "sha256": file.sha256,
5846                    "bytes": file.bytes,
5847                    "proof": file.proof.as_ref().ok_or_else(|| {
5848                        invalid_feed("v2 manifest omitted a download proof")
5849                    })?,
5850                }))
5851            })
5852            .collect::<LinkResult<Vec<_>>>()?;
5853        let value = ensure_ok(
5854            request_capped_retryable_read(
5855                cfg,
5856                "POST",
5857                &format!("/api/hub/brains/{brain}/v2/downloads"),
5858                Some(&json!({
5859                    "commit": pointer.commit_hash,
5860                    "files": claims,
5861                })),
5862                Auth::Required,
5863                MAX_FEED_RESPONSE_BYTES,
5864            )?,
5865            "prepare v2 blob downloads",
5866        )?;
5867        let window: V2DownloadWindow = serde_json::from_value(value)
5868            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5869        if window.v != 2
5870            || window.commit != pointer.commit_hash
5871            || window.downloads.len() != chunk.len()
5872        {
5873            return Err(invalid_feed(
5874                "v2 download window is not bound to the requested files",
5875            ));
5876        }
5877        let mut by_path = window
5878            .downloads
5879            .into_iter()
5880            .map(|item| (item.path.clone(), item))
5881            .collect::<std::collections::BTreeMap<_, _>>();
5882        if by_path.len() != chunk.len() {
5883            return Err(invalid_feed("v2 download window repeats a path"));
5884        }
5885        for (path, file) in chunk {
5886            let item = by_path
5887                .remove(*path)
5888                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5889            if item.method != "GET"
5890                || item.sha256 != file.sha256
5891                || item.bytes != file.bytes
5892                || item.url.is_empty()
5893            {
5894                return Err(invalid_feed(
5895                    "v2 download capability differs from its proven file",
5896                ));
5897            }
5898            result.push(item);
5899        }
5900    }
5901    Ok(result)
5902}
5903
5904fn prepare_v2_asset_downloads(
5905    cfg: &HubConfig,
5906    brain: &str,
5907    pointer: &V2PointerBody,
5908    pending: &[(&String, &V2BaselineAsset)],
5909) -> LinkResult<Vec<V2DownloadItem>> {
5910    let mut result = Vec::with_capacity(pending.len());
5911    for chunk in pending.chunks(128) {
5912        let claims = chunk
5913            .iter()
5914            .map(|(path, asset)| {
5915                json!({
5916                    "path": path,
5917                    "sha256": asset.blob_sha256,
5918                    "bytes": asset.bytes,
5919                    "leaf_hash": asset.leaf_hash,
5920                })
5921            })
5922            .collect::<Vec<_>>();
5923        let value = ensure_ok(
5924            request_capped_retryable_read(
5925                cfg,
5926                "POST",
5927                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5928                Some(&json!({
5929                    "commit": pointer.commit_hash,
5930                    "assets": claims,
5931                })),
5932                Auth::Required,
5933                MAX_FEED_RESPONSE_BYTES,
5934            )?,
5935            "prepare v2 asset downloads",
5936        )?;
5937        let window: V2DownloadWindow = serde_json::from_value(value)
5938            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5939        if window.v != 2
5940            || window.commit != pointer.commit_hash
5941            || window.downloads.len() != chunk.len()
5942        {
5943            return Err(invalid_feed(
5944                "v2 asset download window is not bound to the requested assets",
5945            ));
5946        }
5947        let mut by_path = window
5948            .downloads
5949            .into_iter()
5950            .map(|item| (item.path.clone(), item))
5951            .collect::<std::collections::BTreeMap<_, _>>();
5952        if by_path.len() != chunk.len() {
5953            return Err(invalid_feed("v2 asset download window repeats a path"));
5954        }
5955        for (path, asset) in chunk {
5956            let item = by_path
5957                .remove(*path)
5958                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5959            if item.method != "GET"
5960                || item.sha256 != asset.blob_sha256
5961                || item.bytes != asset.bytes
5962                || item.url.is_empty()
5963            {
5964                return Err(invalid_feed(
5965                    "v2 asset download capability differs from its signed leaf",
5966                ));
5967            }
5968            result.push(item);
5969        }
5970    }
5971    Ok(result)
5972}
5973
5974#[cfg(any(unix, windows))]
5975fn stage_v2_asset_download_window(
5976    cfg: &HubConfig,
5977    brain: &str,
5978    pointer: &V2PointerBody,
5979    cache_dir: &Path,
5980    pending: &[(&String, &V2BaselineAsset)],
5981) -> LinkResult<Vec<V2StagedFile>> {
5982    if pending.is_empty() {
5983        return Ok(Vec::new());
5984    }
5985    if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5986        return Err(invalid_feed("v2 asset capability window is oversized"));
5987    }
5988
5989    let mut last_error = None;
5990    for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5991        .iter()
5992        .copied()
5993        .map(Some)
5994        .chain(std::iter::once(None))
5995    {
5996        // This POST rechecks the exact head and current path authority before
5997        // issuing fresh, immutable GET capabilities. Successful prior workers
5998        // are already durable in the content-addressed cache, so retrying the
5999        // complete small window neither redownloads them nor widens access.
6000        let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
6001        let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
6002        for item in downloads {
6003            match unique.get(&item.sha256) {
6004                Some(prior) if prior.bytes != item.bytes => {
6005                    return Err(invalid_feed(
6006                        "one v2 asset hash has conflicting byte lengths",
6007                    ));
6008                }
6009                Some(_) => {}
6010                None => {
6011                    unique.insert(item.sha256.clone(), item);
6012                }
6013            }
6014        }
6015        let downloads = unique.into_values().collect::<Vec<_>>();
6016        let next = std::sync::atomic::AtomicUsize::new(0);
6017        let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6018        let mut results = std::iter::repeat_with(|| None)
6019            .take(downloads.len())
6020            .collect::<Vec<Option<LinkResult<PathBuf>>>>();
6021        std::thread::scope(|scope| {
6022            let (sender, receiver) = std::sync::mpsc::channel();
6023            for _ in 0..worker_count {
6024                let sender = sender.clone();
6025                let downloads = &downloads;
6026                let next = &next;
6027                scope.spawn(move || loop {
6028                    let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6029                    let Some(item) = downloads.get(index) else {
6030                        break;
6031                    };
6032                    let result = download_presigned_to_cache(
6033                        cfg,
6034                        &item.url,
6035                        cache_dir,
6036                        &item.sha256,
6037                        item.bytes,
6038                    );
6039                    if sender.send((index, result)).is_err() {
6040                        break;
6041                    }
6042                });
6043            }
6044            drop(sender);
6045            for (index, result) in receiver {
6046                results[index] = Some(result);
6047            }
6048        });
6049
6050        let mut failed = None;
6051        for result in results {
6052            match result {
6053                Some(Ok(_)) => {}
6054                Some(Err(error)) if failed.is_none() => failed = Some(error),
6055                Some(Err(_)) => {}
6056                None if failed.is_none() => {
6057                    failed = Some(LinkError::Transport {
6058                        hub: cfg.hub.clone(),
6059                        message: "a bounded v2 asset worker stopped before reporting its result"
6060                            .to_string(),
6061                    });
6062                }
6063                None => {}
6064            }
6065        }
6066        if let Some(error) = failed {
6067            last_error = Some(error);
6068            if let Some(milliseconds) = retry_delay {
6069                std::thread::sleep(std::time::Duration::from_millis(milliseconds));
6070                continue;
6071            }
6072            break;
6073        }
6074
6075        return pending
6076            .iter()
6077            .map(|(path, asset)| {
6078                let source = cache_dir.join(&asset.blob_sha256);
6079                if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
6080                    return Err(invalid_feed(
6081                        "v2 asset download cache omitted a proven blob",
6082                    ));
6083                }
6084                Ok(V2StagedFile {
6085                    path: (*path).clone(),
6086                    source,
6087                    sha256: asset.blob_sha256.clone(),
6088                    bytes: asset.bytes,
6089                })
6090            })
6091            .collect();
6092    }
6093    Err(last_error
6094        .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
6095}
6096
6097fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
6098    let bytes = get_presigned(cfg, &item.url)?;
6099    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
6100        return Err(invalid_feed("v2 blob differs from its proven path entry"));
6101    }
6102    Ok(bytes)
6103}
6104
6105#[derive(Debug, Clone)]
6106struct V2StagedFile {
6107    path: String,
6108    source: PathBuf,
6109    sha256: String,
6110    bytes: u64,
6111}
6112
6113#[cfg(unix)]
6114fn v2_download_cache_dir(
6115    cfg: &HubConfig,
6116    brain: &str,
6117    pointer: &V2PointerBody,
6118) -> LinkResult<PathBuf> {
6119    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
6120}
6121
6122#[cfg(unix)]
6123fn v2_download_cache_dir_for(
6124    cfg: &HubConfig,
6125    brain: &str,
6126    transaction: &str,
6127) -> LinkResult<PathBuf> {
6128    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6129        return Err(invalid_feed("v2 download cache address is invalid"));
6130    }
6131    let path = cfg
6132        .state_dir
6133        .join("downloads")
6134        .join(brain)
6135        .join(transaction);
6136    let directory = open_or_create_dir_nofollow(&path)?;
6137    use std::os::fd::AsRawFd as _;
6138    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
6139        return Err(std::io::Error::last_os_error().into());
6140    }
6141    directory.sync_all()?;
6142    Ok(path)
6143}
6144
6145#[cfg(windows)]
6146fn v2_download_cache_dir(
6147    cfg: &HubConfig,
6148    brain: &str,
6149    pointer: &V2PointerBody,
6150) -> LinkResult<PathBuf> {
6151    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
6152}
6153
6154#[cfg(windows)]
6155fn v2_download_cache_dir_for(
6156    cfg: &HubConfig,
6157    brain: &str,
6158    transaction: &str,
6159) -> LinkResult<PathBuf> {
6160    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6161        return Err(invalid_feed("v2 download cache address is invalid"));
6162    }
6163    let path = cfg
6164        .state_dir
6165        .join("downloads")
6166        .join(brain)
6167        .join(transaction);
6168    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
6169    crate::fsx::open_directory_nofollow(&path)?;
6170    Ok(path)
6171}
6172
6173#[cfg(unix)]
6174fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
6175    use std::os::fd::AsRawFd as _;
6176    let parent = cfg.state_dir.join("downloads").join(brain);
6177    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
6178        return;
6179    };
6180    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
6181        return;
6182    };
6183    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
6184    let _ = directory.sync_all();
6185}
6186
6187#[cfg(windows)]
6188fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
6189    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6190        return;
6191    }
6192    let parent = cfg.state_dir.join("downloads").join(brain);
6193    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
6194        return;
6195    };
6196    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
6197}
6198
6199#[cfg(not(any(unix, windows)))]
6200fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
6201
6202#[cfg(not(any(unix, windows)))]
6203fn v2_download_cache_dir_for(
6204    _cfg: &HubConfig,
6205    _brain: &str,
6206    _transaction: &str,
6207) -> LinkResult<PathBuf> {
6208    Err(LinkError::UnsupportedPlatform {
6209        operation: "resumable v2 download staging",
6210    })
6211}
6212
6213#[cfg(any(unix, windows))]
6214fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
6215    let file = match crate::fsx::open_regular_nofollow(path) {
6216        Ok(file) => file,
6217        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6218        Err(error) => return Err(error.into()),
6219    };
6220    if file.metadata()?.len() != bytes {
6221        return Ok(false);
6222    }
6223    Ok(content_sha256_reader(file)? == sha256)
6224}
6225
6226#[cfg(any(unix, windows))]
6227fn cache_v2_blob_bytes(
6228    cache_dir: &Path,
6229    sha256: &str,
6230    expected_bytes: u64,
6231    bytes: &[u8],
6232) -> LinkResult<PathBuf> {
6233    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
6234        return Err(invalid_feed("v2 cached blob differs from its declaration"));
6235    }
6236    let path = cache_dir.join(sha256);
6237    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
6238        crate::fsx::write_atomic(&path, bytes)?;
6239    }
6240    Ok(path)
6241}
6242
6243#[cfg(not(any(unix, windows)))]
6244fn cache_v2_blob_bytes(
6245    _cache_dir: &Path,
6246    _sha256: &str,
6247    _expected_bytes: u64,
6248    _bytes: &[u8],
6249) -> LinkResult<PathBuf> {
6250    Err(LinkError::UnsupportedPlatform {
6251        operation: "resumable v2 download staging",
6252    })
6253}
6254
6255#[cfg(unix)]
6256fn download_presigned_to_cache(
6257    cfg: &HubConfig,
6258    url: &str,
6259    cache_dir: &Path,
6260    sha256: &str,
6261    expected_bytes: u64,
6262) -> LinkResult<PathBuf> {
6263    use std::os::fd::{AsRawFd as _, FromRawFd as _};
6264
6265    let target = cache_dir.join(sha256);
6266    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6267        return Ok(target);
6268    }
6269    let directory = open_existing_dir_nofollow(cache_dir)?;
6270    let mut nonce = [0_u8; 16];
6271    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
6272        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
6273    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
6274    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
6275    let fd = unsafe {
6276        libc::openat(
6277            directory.as_raw_fd(),
6278            temp.as_ptr(),
6279            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6280            0o600,
6281        )
6282    };
6283    if fd < 0 {
6284        return Err(std::io::Error::last_os_error().into());
6285    }
6286    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
6287    let response = match presigned_agent(cfg, url)?.get(url).call() {
6288        Ok(response) => response,
6289        Err(ureq::Error::Status(_, response)) => {
6290            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6291            return Err(LinkError::Http {
6292                what: "v2 direct download",
6293                status: response.status(),
6294                message: "object store rejected the download".to_string(),
6295                code: None,
6296                details: None,
6297            });
6298        }
6299        Err(ureq::Error::Transport(error)) => {
6300            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6301            return Err(LinkError::Transport {
6302                hub: cfg.hub.clone(),
6303                message: error.to_string(),
6304            });
6305        }
6306    };
6307    let mut reader = response
6308        .into_reader()
6309        .take(expected_bytes.saturating_add(1));
6310    let mut digest = Sha256::new();
6311    let mut total = 0_u64;
6312    let mut buffer = [0_u8; 64 * 1024];
6313    // Reading the socket and writing the file are different failures wearing
6314    // the same io::Error: a stalled download is the peer's problem and a full
6315    // disk is this machine's, and an operator who is told only "timed out"
6316    // cannot tell which one to act on.
6317    let write_result = (|| -> LinkResult<()> {
6318        loop {
6319            let read = reader
6320                .read(&mut buffer)
6321                .map_err(|error| LinkError::Transport {
6322                    hub: cfg.hub.clone(),
6323                    message: error.to_string(),
6324                })?;
6325            if read == 0 {
6326                break;
6327            }
6328            total = total.saturating_add(read as u64);
6329            digest.update(&buffer[..read]);
6330            output.write_all(&buffer[..read])?;
6331        }
6332        output.sync_all().map_err(LinkError::from)
6333    })();
6334    if let Err(error) = write_result {
6335        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6336        return Err(error);
6337    }
6338    drop(output);
6339    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6340        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6341        return Err(invalid_feed(
6342            "v2 direct download failed integrity verification",
6343        ));
6344    }
6345    let target_name = c_name(sha256.as_bytes(), sha256)?;
6346    // Replace only a previously inspected cache leaf. Cache bytes are private
6347    // and content-addressed; final install re-verifies them again.
6348    if unsafe {
6349        libc::renameat(
6350            directory.as_raw_fd(),
6351            temp.as_ptr(),
6352            directory.as_raw_fd(),
6353            target_name.as_ptr(),
6354        )
6355    } != 0
6356    {
6357        let error = std::io::Error::last_os_error();
6358        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6359        return Err(error.into());
6360    }
6361    directory.sync_all()?;
6362    Ok(target)
6363}
6364
6365#[cfg(windows)]
6366fn download_presigned_to_cache(
6367    cfg: &HubConfig,
6368    url: &str,
6369    cache_dir: &Path,
6370    sha256: &str,
6371    expected_bytes: u64,
6372) -> LinkResult<PathBuf> {
6373    use std::fs::OpenOptions;
6374
6375    let target = cache_dir.join(sha256);
6376    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6377        return Ok(target);
6378    }
6379    // Keep the checked directory handle alive while the private cache path is
6380    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
6381    // an ancestor can be swapped after validation.
6382    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6383    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6384    let mut output = OpenOptions::new()
6385        .write(true)
6386        .create_new(true)
6387        .open(&temp)?;
6388    let response = match presigned_agent(cfg, url)?.get(url).call() {
6389        Ok(response) => response,
6390        Err(ureq::Error::Status(_, response)) => {
6391            let _ = std::fs::remove_file(&temp);
6392            return Err(LinkError::Http {
6393                what: "v2 direct download",
6394                status: response.status(),
6395                message: "object store rejected the download".to_string(),
6396                code: None,
6397                details: None,
6398            });
6399        }
6400        Err(ureq::Error::Transport(error)) => {
6401            let _ = std::fs::remove_file(&temp);
6402            return Err(LinkError::Transport {
6403                hub: cfg.hub.clone(),
6404                message: error.to_string(),
6405            });
6406        }
6407    };
6408    let mut reader = response
6409        .into_reader()
6410        .take(expected_bytes.saturating_add(1));
6411    let mut digest = Sha256::new();
6412    let mut total = 0_u64;
6413    let mut buffer = [0_u8; 64 * 1024];
6414    // Same split as the cache-fill path: a stalled socket is not a disk error.
6415    let copied = (|| -> LinkResult<()> {
6416        loop {
6417            let read = reader
6418                .read(&mut buffer)
6419                .map_err(|error| LinkError::Transport {
6420                    hub: cfg.hub.clone(),
6421                    message: error.to_string(),
6422                })?;
6423            if read == 0 {
6424                break;
6425            }
6426            total = total.saturating_add(read as u64);
6427            digest.update(&buffer[..read]);
6428            output.write_all(&buffer[..read])?;
6429        }
6430        output.sync_all()?;
6431        Ok(())
6432    })();
6433    if let Err(error) = copied {
6434        let _ = std::fs::remove_file(&temp);
6435        return Err(error);
6436    }
6437    drop(output);
6438    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6439        let _ = std::fs::remove_file(&temp);
6440        return Err(invalid_feed(
6441            "v2 direct download failed integrity verification",
6442        ));
6443    }
6444    if target.exists() {
6445        std::fs::remove_file(&target)?;
6446    }
6447    if let Err(error) = std::fs::rename(&temp, &target) {
6448        let _ = std::fs::remove_file(&temp);
6449        return Err(error.into());
6450    }
6451    Ok(target)
6452}
6453
6454#[cfg(not(any(unix, windows)))]
6455fn download_presigned_to_cache(
6456    _cfg: &HubConfig,
6457    _url: &str,
6458    _cache_dir: &Path,
6459    _sha256: &str,
6460    _expected_bytes: u64,
6461) -> LinkResult<PathBuf> {
6462    Err(LinkError::UnsupportedPlatform {
6463        operation: "resumable v2 download staging",
6464    })
6465}
6466
6467fn download_v2_blobs(
6468    cfg: &HubConfig,
6469    brain: &str,
6470    pointer: &V2PointerBody,
6471    pending: Vec<(&String, &V2BaselineFile)>,
6472) -> LinkResult<Vec<(String, Vec<u8>)>> {
6473    if pending.is_empty() {
6474        return Ok(Vec::new());
6475    }
6476    let expected_order = pending
6477        .iter()
6478        .map(|(path, _)| (*path).clone())
6479        .collect::<Vec<_>>();
6480    let mut streamed = std::collections::BTreeMap::new();
6481    let mut direct = Vec::new();
6482    let mut window = Vec::new();
6483    let mut window_bytes = 0_u64;
6484    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6485                 window_bytes: &mut u64,
6486                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6487     -> LinkResult<()> {
6488        if window.is_empty() {
6489            return Ok(());
6490        }
6491        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6492            if streamed.insert(path, bytes).is_some() {
6493                return Err(invalid_feed("v2 bulk streams repeated a path"));
6494            }
6495        }
6496        window.clear();
6497        *window_bytes = 0;
6498        Ok(())
6499    };
6500    for &(path, file) in &pending {
6501        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6502            flush(&mut window, &mut window_bytes, &mut streamed)?;
6503            direct.push((path, file));
6504            continue;
6505        }
6506        if window.len() == V2_BULK_STREAM_FILES
6507            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6508        {
6509            flush(&mut window, &mut window_bytes, &mut streamed)?;
6510        }
6511        window.push((path, file));
6512        window_bytes += file.bytes;
6513    }
6514    flush(&mut window, &mut window_bytes, &mut streamed)?;
6515
6516    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6517    let next = std::sync::atomic::AtomicUsize::new(0);
6518    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6519    let mut results = std::iter::repeat_with(|| None)
6520        .take(downloads.len())
6521        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6522    std::thread::scope(|scope| {
6523        let (sender, receiver) = std::sync::mpsc::channel();
6524        for _ in 0..worker_count {
6525            let sender = sender.clone();
6526            let downloads = &downloads;
6527            let next = &next;
6528            scope.spawn(move || loop {
6529                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6530                let Some(item) = downloads.get(index) else {
6531                    break;
6532                };
6533                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6534                if sender.send((index, result)).is_err() {
6535                    break;
6536                }
6537            });
6538        }
6539        drop(sender);
6540        for (index, result) in receiver {
6541            results[index] = Some(result);
6542        }
6543    });
6544    for result in results.into_iter().map(|result| {
6545        result.ok_or_else(|| LinkError::Transport {
6546            hub: cfg.hub.clone(),
6547            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6548        })?
6549    }) {
6550        let (path, bytes) = result?;
6551        if streamed.insert(path, bytes).is_some() {
6552            return Err(invalid_feed("v2 download lanes repeated a path"));
6553        }
6554    }
6555    expected_order
6556        .into_iter()
6557        .map(|path| {
6558            streamed
6559                .remove(&path)
6560                .map(|bytes| (path, bytes))
6561                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6562        })
6563        .collect()
6564}
6565
6566/// Resume an exact-head materialization into the private global cache. Each
6567/// verified frame is durable before the next network window begins, so a
6568/// retry skips it and memory never grows with total changed bytes.
6569#[cfg(any(unix, windows))]
6570fn queue_v2_bulk_window<'a>(
6571    cache_dir: &Path,
6572    window: &mut Vec<(&'a String, &'a V2BaselineFile)>,
6573    window_bytes: &mut u64,
6574    staged: &mut std::collections::BTreeMap<String, V2StagedFile>,
6575    missing_windows: &mut Vec<Vec<(&'a String, &'a V2BaselineFile)>>,
6576) -> LinkResult<()> {
6577    if window.is_empty() {
6578        return Ok(());
6579    }
6580    let missing = window
6581        .iter()
6582        .filter_map(|(path, file)| {
6583            let target = cache_dir.join(&file.sha256);
6584            match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6585                Ok(true) => {
6586                    staged.insert(
6587                        (*path).clone(),
6588                        V2StagedFile {
6589                            path: (*path).clone(),
6590                            source: target,
6591                            sha256: file.sha256.clone(),
6592                            bytes: file.bytes,
6593                        },
6594                    );
6595                    None
6596                }
6597                Ok(false) => Some(Ok((*path, *file))),
6598                Err(error) => Some(Err(error)),
6599            }
6600        })
6601        .collect::<LinkResult<Vec<_>>>()?;
6602    if !missing.is_empty() {
6603        missing_windows.push(missing);
6604    }
6605    window.clear();
6606    *window_bytes = 0;
6607    Ok(())
6608}
6609
6610#[cfg(any(unix, windows))]
6611fn stage_v2_bulk_windows<'a>(
6612    cfg: &HubConfig,
6613    brain: &str,
6614    pointer: &V2PointerBody,
6615    cache_dir: &Path,
6616    windows: Vec<Vec<(&'a String, &'a V2BaselineFile)>>,
6617    staged: &mut std::collections::BTreeMap<String, V2StagedFile>,
6618) -> LinkResult<()> {
6619    let next = std::sync::atomic::AtomicUsize::new(0);
6620    let worker_count = windows.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6621    let mut first_error = None;
6622    std::thread::scope(|scope| {
6623        let (sender, receiver) = std::sync::mpsc::channel();
6624        for _ in 0..worker_count {
6625            let sender = sender.clone();
6626            let windows = &windows;
6627            let next = &next;
6628            scope.spawn(move || loop {
6629                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6630                let Some(window) = windows.get(index) else {
6631                    break;
6632                };
6633                let result = download_v2_bulk_stream(cfg, brain, pointer, window);
6634                if sender.send((index, result)).is_err() {
6635                    break;
6636                }
6637            });
6638        }
6639        drop(sender);
6640        for (index, result) in receiver {
6641            match result {
6642                Ok(files) => {
6643                    for (path, bytes) in files {
6644                        let Some(file) = windows[index].iter().find_map(|(expected_path, file)| {
6645                            (*expected_path == &path).then_some(*file)
6646                        }) else {
6647                            first_error.get_or_insert_with(|| {
6648                                invalid_feed("v2 stream returned an unrequested cache path")
6649                            });
6650                            continue;
6651                        };
6652                        match cache_v2_blob_bytes(cache_dir, &file.sha256, file.bytes, &bytes) {
6653                            Ok(source) => {
6654                                if staged
6655                                    .insert(
6656                                        path.clone(),
6657                                        V2StagedFile {
6658                                            path,
6659                                            source,
6660                                            sha256: file.sha256.clone(),
6661                                            bytes: file.bytes,
6662                                        },
6663                                    )
6664                                    .is_some()
6665                                {
6666                                    first_error.get_or_insert_with(|| {
6667                                        invalid_feed("v2 bulk streams repeated a path")
6668                                    });
6669                                }
6670                            }
6671                            Err(error) => {
6672                                first_error.get_or_insert(error);
6673                            }
6674                        }
6675                    }
6676                }
6677                Err(error) => {
6678                    first_error.get_or_insert(error);
6679                }
6680            }
6681        }
6682    });
6683    match first_error {
6684        Some(error) => Err(error),
6685        None => Ok(()),
6686    }
6687}
6688
6689#[cfg(any(unix, windows))]
6690fn stage_v2_blobs(
6691    cfg: &HubConfig,
6692    brain: &str,
6693    pointer: &V2PointerBody,
6694    pending: Vec<(&String, &V2BaselineFile)>,
6695) -> LinkResult<Vec<V2StagedFile>> {
6696    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6697    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6698    let mut direct = Vec::new();
6699    let mut window = Vec::new();
6700    let mut missing_windows = Vec::new();
6701    let mut window_bytes = 0_u64;
6702    for &(path, file) in &pending {
6703        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6704            queue_v2_bulk_window(
6705                &cache_dir,
6706                &mut window,
6707                &mut window_bytes,
6708                &mut staged,
6709                &mut missing_windows,
6710            )?;
6711            direct.push((path, file));
6712            continue;
6713        }
6714        if window.len() == V2_BULK_STREAM_FILES
6715            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6716        {
6717            queue_v2_bulk_window(
6718                &cache_dir,
6719                &mut window,
6720                &mut window_bytes,
6721                &mut staged,
6722                &mut missing_windows,
6723            )?;
6724        }
6725        window.push((path, file));
6726        window_bytes += file.bytes;
6727    }
6728    queue_v2_bulk_window(
6729        &cache_dir,
6730        &mut window,
6731        &mut window_bytes,
6732        &mut staged,
6733        &mut missing_windows,
6734    )?;
6735    stage_v2_bulk_windows(
6736        cfg,
6737        brain,
6738        pointer,
6739        &cache_dir,
6740        missing_windows,
6741        &mut staged,
6742    )?;
6743    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6744        let source =
6745            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6746        staged.insert(
6747            item.path.clone(),
6748            V2StagedFile {
6749                path: item.path,
6750                source,
6751                sha256: item.sha256,
6752                bytes: item.bytes,
6753            },
6754        );
6755    }
6756    pending
6757        .into_iter()
6758        .map(|(path, _)| {
6759            staged
6760                .remove(path)
6761                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6762        })
6763        .collect()
6764}
6765
6766#[cfg(not(any(unix, windows)))]
6767fn stage_v2_blobs(
6768    _cfg: &HubConfig,
6769    _brain: &str,
6770    _pointer: &V2PointerBody,
6771    _pending: Vec<(&String, &V2BaselineFile)>,
6772) -> LinkResult<Vec<V2StagedFile>> {
6773    Err(LinkError::UnsupportedPlatform {
6774        operation: "resumable v2 download staging",
6775    })
6776}
6777
6778const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6779const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6780const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6781
6782#[derive(Debug, Clone, Deserialize, Serialize)]
6783struct V2ConflictCoordinate {
6784    sha256: Option<String>,
6785    bytes: Option<u64>,
6786    file: Option<String>,
6787}
6788
6789#[derive(Debug, Clone, Deserialize, Serialize)]
6790struct V2ConflictFile {
6791    path: String,
6792    base: V2ConflictCoordinate,
6793    local: V2ConflictCoordinate,
6794    remote: V2ConflictCoordinate,
6795}
6796
6797#[derive(Debug, Clone, Deserialize, Serialize)]
6798struct V2ConflictPlan {
6799    v: u8,
6800    class: String,
6801    bundle: String,
6802    brain: String,
6803    origin: String,
6804    created_unix: u64,
6805    expires_unix: u64,
6806    base_seq: Option<u64>,
6807    base_commit: Option<String>,
6808    remote_seq: u64,
6809    remote_commit: Option<String>,
6810    remote_content_root: Option<String>,
6811    view_kind: String,
6812    view_revision: String,
6813    files: Vec<V2ConflictFile>,
6814}
6815
6816fn v2_take_remote_selection(
6817    files: &[V2ConflictFile],
6818    current: &std::collections::BTreeMap<String, V2BaselineFile>,
6819) -> LinkResult<(
6820    std::collections::BTreeMap<String, V2BaselineFile>,
6821    Vec<String>,
6822)> {
6823    let mut selected = std::collections::BTreeMap::new();
6824    let mut deleted = Vec::new();
6825    for file in files {
6826        match (&file.remote.sha256, file.remote.bytes) {
6827            (Some(sha256), Some(bytes)) => {
6828                let proven = current.get(&file.path).ok_or_else(|| {
6829                    invalid_feed("conflict remote coordinate disappeared from the exact head")
6830                })?;
6831                if proven.sha256 != *sha256 || proven.bytes != bytes {
6832                    return Err(invalid_feed(
6833                        "conflict remote coordinate differs from the exact head",
6834                    ));
6835                }
6836                if selected.insert(file.path.clone(), proven.clone()).is_some() {
6837                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6838                }
6839            }
6840            (None, None) => {
6841                if current.contains_key(&file.path) {
6842                    return Err(invalid_feed(
6843                        "conflict remote deletion differs from the exact head",
6844                    ));
6845                }
6846                deleted.push(file.path.clone());
6847            }
6848            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6849        }
6850    }
6851    Ok((selected, deleted))
6852}
6853
6854fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6855    PathBuf::from(".dbmd")
6856        .join("conflicts")
6857        .join(bundle)
6858        .join(suffix)
6859}
6860
6861fn read_historical_conflict_blob(
6862    cfg: &HubConfig,
6863    brain: &str,
6864    baseline: &V2SyncBaseline,
6865    path: &str,
6866    file: &V2BaselineFile,
6867) -> LinkResult<Option<Vec<u8>>> {
6868    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6869        return Ok(None);
6870    };
6871    if seq == 0 {
6872        return Ok(None);
6873    }
6874    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6875    let endpoint = format!(
6876        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6877        file.sha256
6878    );
6879    let history_http = hub_agent_with_timeout(cfg, std::time::Duration::from_secs(15))?;
6880    let raw = match request_raw_with_agent(
6881        cfg,
6882        &history_http,
6883        "GET",
6884        &endpoint,
6885        None,
6886        RawRequestOptions {
6887            auth: Auth::Required,
6888            max_response_bytes: file.bytes,
6889            request_id: None,
6890            retry_transport: false,
6891        },
6892    ) {
6893        Ok(raw) => raw,
6894        // The base body is diagnostic convenience only. The conflict plan
6895        // already carries its verified hash/length, plus exact local and
6896        // current-remote bodies. A slow retained-object read must not prevent
6897        // the operator from choosing keep-local or take-remote safely.
6898        Err(LinkError::Transport { .. }) => return Ok(None),
6899        Err(error) => return Err(error),
6900    };
6901    if raw.status == 404 || raw.status == 403 {
6902        return Ok(None);
6903    }
6904    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6905    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6906        return Err(invalid_feed(
6907            "v2 conflict base failed integrity verification",
6908        ));
6909    }
6910    Ok(Some(bytes))
6911}
6912
6913/// Persist a readable content conflict without modifying either side. The
6914/// plan is written last, so an interrupted bundle build is never actionable.
6915fn create_v2_conflict_bundle(
6916    cfg: &HubConfig,
6917    store: &Store,
6918    head: &V2VerifiedHead,
6919    baseline: Option<&V2SyncBaseline>,
6920    local: &std::collections::BTreeMap<String, (String, u64)>,
6921    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6922    paths: &[String],
6923) -> LinkResult<(String, Vec<String>)> {
6924    let conflicts_root = Path::new(".dbmd/conflicts");
6925    store.create_dir_all(conflicts_root)?;
6926    let completed = store
6927        .directory_names(conflicts_root)?
6928        .into_iter()
6929        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6930        .count();
6931    if completed >= V2_CONFLICT_BUNDLE_MAX {
6932        return Err(LinkError::InvalidPack {
6933            message: format!(
6934                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6935            ),
6936        });
6937    }
6938
6939    // A conflict bundle is diagnostic state, not a second whole-brain cache.
6940    // Materialize a deterministic prefix whose remote artifacts fit one
6941    // bounded window. Resolving it exposes the next prefix on the next sync.
6942    let mut selected_paths = Vec::new();
6943    let mut selected_remote_bytes = 0_u64;
6944    for path in paths {
6945        let bytes = remote.get(path).map_or(0, |file| file.bytes);
6946        if !selected_paths.is_empty()
6947            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6948        {
6949            break;
6950        }
6951        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6952        selected_paths.push(path.clone());
6953        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6954            break;
6955        }
6956    }
6957    if selected_paths.is_empty() {
6958        return Err(invalid_feed("content conflict set is empty"));
6959    }
6960    let bundle = crate::ulid::mint();
6961    let bundle_root = v2_conflict_relative(&bundle, "");
6962    store.create_dir_all(&bundle_root.join("files"))?;
6963    let pointer = head.pointer.as_ref();
6964    let remote_bytes = match pointer {
6965        Some(pointer) => download_v2_blobs(
6966            cfg,
6967            &head.brain_id,
6968            pointer,
6969            selected_paths
6970                .iter()
6971                .filter_map(|path| {
6972                    remote
6973                        .get(path)
6974                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6975                        .map(|file| (path, file))
6976                })
6977                .collect(),
6978        )?
6979        .into_iter()
6980        .collect::<std::collections::BTreeMap<_, _>>(),
6981        None => std::collections::BTreeMap::new(),
6982    };
6983
6984    let mut files = Vec::with_capacity(selected_paths.len());
6985    let mut historical_body_available = true;
6986    for (index, path) in selected_paths.iter().enumerate() {
6987        let base_file = baseline.and_then(|state| state.files.get(path));
6988        let base_bytes = match (historical_body_available, baseline, base_file) {
6989            (true, Some(state), Some(file)) => {
6990                let bytes = read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?;
6991                if bytes.is_none() {
6992                    // Historical content is optional diagnostic context. Once
6993                    // the retained-object lane proves unavailable, preserve
6994                    // the exact base coordinates but spend no more network
6995                    // time on this bundle.
6996                    historical_body_available = false;
6997                }
6998                bytes
6999            }
7000            _ => None,
7001        };
7002        let local_file = local.get(path);
7003        let remote_file = remote.get(path);
7004        let remote_content = remote_bytes.get(path);
7005        let prefix = format!("files/{index:04}");
7006        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
7007        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
7008        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
7009        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
7010            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
7011        }
7012        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
7013            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
7014            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
7015                return Err(LinkError::InvalidPack {
7016                    message: format!("local conflict path `{path}` changed while bundling"),
7017                });
7018            }
7019            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
7020        }
7021        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
7022            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
7023        }
7024        files.push(V2ConflictFile {
7025            path: path.clone(),
7026            base: V2ConflictCoordinate {
7027                sha256: base_file.map(|file| file.sha256.clone()),
7028                bytes: base_file.map(|file| file.bytes),
7029                file: base_name,
7030            },
7031            local: V2ConflictCoordinate {
7032                sha256: local_file.map(|(sha256, _)| sha256.clone()),
7033                bytes: local_file.map(|(_, bytes)| *bytes),
7034                file: local_name,
7035            },
7036            remote: V2ConflictCoordinate {
7037                sha256: remote_file.map(|file| file.sha256.clone()),
7038                bytes: remote_file.map(|file| file.bytes),
7039                file: remote_name,
7040            },
7041        });
7042    }
7043    let now = SystemTime::now()
7044        .duration_since(UNIX_EPOCH)
7045        .unwrap_or_default()
7046        .as_secs();
7047    let plan = V2ConflictPlan {
7048        v: 2,
7049        class: "content_resolution_required".to_string(),
7050        bundle: bundle.clone(),
7051        brain: head.brain_id.clone(),
7052        origin: normalized_origin(&cfg.hub)?,
7053        created_unix: now,
7054        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
7055        base_seq: baseline.and_then(|state| state.head_seq),
7056        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
7057        remote_seq: pointer.map_or(0, |value| value.seq),
7058        remote_commit: pointer.map(|value| value.commit_hash.clone()),
7059        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
7060        view_kind: head.view_kind.clone(),
7061        view_revision: head.view_revision.clone(),
7062        files,
7063    };
7064    let mut bytes = serde_json::to_vec_pretty(&plan)
7065        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
7066    bytes.push(b'\n');
7067    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
7068    Ok((bundle, selected_paths))
7069}
7070
7071fn v2_sync_pull_with_resolution(
7072    cfg: &HubConfig,
7073    requested_brain: &str,
7074    expected_head: V2VerifiedHead,
7075    out: Option<&Path>,
7076    take_remote: Option<&std::collections::BTreeSet<String>>,
7077) -> LinkResult<V2PulledSnapshot> {
7078    let dest = out
7079        .map(Path::to_path_buf)
7080        .unwrap_or_else(|| PathBuf::from(requested_brain));
7081    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
7082    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
7083    let head = v2_verified_head(cfg, requested_brain)?
7084        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7085    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
7086        return Err(LinkError::RemoteAdvancedDuringSync);
7087    }
7088    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
7089    ensure_v2_view_compatible(&head, baseline.as_ref())?;
7090    let (remote, remote_assets) = match baseline
7091        .as_ref()
7092        .filter(|state| v2_baseline_matches_head(&head, state))
7093    {
7094        Some(state) => (state.files.clone(), state.assets.clone()),
7095        None => (
7096            files_for_v2_view(
7097                &head,
7098                v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7099            ),
7100            v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7101        ),
7102    };
7103    verify_v2_markdown_asset_content_bindings(&remote, &remote_assets)?;
7104    let local_store = Store::open_strict(&dest).ok();
7105    // A baseline proves this path is an established checkout. Never reinterpret
7106    // a damaged/edited store marker as an empty clone destination: doing so
7107    // would let the pull half erase the local evidence before the push half can
7108    // reject it (most importantly for the generated scoped-view DB.md).
7109    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
7110    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
7111    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
7112        return Err(LinkError::ScopedViewChanged);
7113    }
7114    if let Some(view) = local_view.as_mut() {
7115        remove_scoped_projection(&head, baseline.as_ref(), view)?;
7116    }
7117    let empty_local = std::collections::BTreeMap::new();
7118    let local = local_view
7119        .as_ref()
7120        .map_or(&empty_local, |view| &view.riding);
7121    let kept_home = |path: &str| {
7122        local_view
7123            .as_ref()
7124            .is_some_and(|view| view.policy.keeps_home(path))
7125    };
7126    let empty_base = std::collections::BTreeMap::new();
7127    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
7128    let empty_base_assets = std::collections::BTreeMap::new();
7129    let base_assets = baseline
7130        .as_ref()
7131        .map_or(&empty_base_assets, |state| &state.assets);
7132    let mut local_assets = local_store
7133        .as_ref()
7134        .map(v2_local_asset_records)
7135        .transpose()?
7136        .unwrap_or_default();
7137    let mut content_merge = merge_v2_pulled_records(
7138        base,
7139        &remote,
7140        local,
7141        |file, _| (file.sha256.clone(), file.bytes),
7142        |file, _| (file.sha256.clone(), file.bytes),
7143        kept_home,
7144    );
7145    if let Some(selected) = take_remote {
7146        for path in selected {
7147            if let Some(position) = content_merge
7148                .conflicts
7149                .iter()
7150                .position(|conflict| conflict == path)
7151            {
7152                content_merge.conflicts.remove(position);
7153                content_merge.accept_remote.insert(path.clone());
7154                match remote.get(path) {
7155                    Some(file) => {
7156                        content_merge
7157                            .records
7158                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
7159                    }
7160                    None => {
7161                        content_merge.records.remove(path);
7162                    }
7163                }
7164            } else if !content_merge.accept_remote.contains(path) {
7165                return Err(LinkError::InvalidPack {
7166                    message: format!(
7167                        "take-remote path `{path}` is no longer at its conflict coordinate"
7168                    ),
7169                });
7170            }
7171        }
7172    }
7173    if !content_merge.conflicts.is_empty() {
7174        let mut conflicts = content_merge.conflicts.clone();
7175        conflicts.truncate(100);
7176        if let Some(store) = local_store.as_ref() {
7177            let (bundle, paths) = create_v2_conflict_bundle(
7178                cfg,
7179                store,
7180                &head,
7181                baseline.as_ref(),
7182                local,
7183                &remote,
7184                &conflicts,
7185            )?;
7186            return Err(LinkError::ConflictBundle { bundle, paths });
7187        }
7188        return Err(LinkError::Conflict { paths: conflicts });
7189    }
7190    let asset_merge = merge_v2_pulled_records(
7191        base_assets,
7192        &remote_assets,
7193        &local_assets,
7194        v2_asset_record,
7195        v2_asset_record,
7196        |_| false,
7197    );
7198    if !asset_merge.conflicts.is_empty() {
7199        let mut conflicts = asset_merge.conflicts.clone();
7200        conflicts.truncate(100);
7201        return Err(LinkError::Conflict { paths: conflicts });
7202    }
7203    let pointer = head.pointer.as_ref();
7204    let cache_transaction = pointer.map_or_else(
7205        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
7206        |value| value.commit_hash.clone(),
7207    );
7208    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
7209    let mut changed = match pointer {
7210        Some(pointer) => stage_v2_blobs(
7211            cfg,
7212            &head.brain_id,
7213            pointer,
7214            remote
7215                .iter()
7216                .filter(|(path, file)| {
7217                    content_merge.accept_remote.contains(*path)
7218                        && local.get(*path).map(|value| value.0.as_str())
7219                            != Some(file.sha256.as_str())
7220                })
7221                .collect(),
7222        )?,
7223        None => Vec::new(),
7224    };
7225    let mut deleted = content_merge
7226        .accept_remote
7227        .iter()
7228        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
7229        .cloned()
7230        .collect::<Vec<_>>();
7231    if local_assets != asset_merge.records {
7232        if asset_merge.records.is_empty() {
7233            deleted.push("assets.jsonl".to_string());
7234        } else {
7235            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
7236            let sha256 = content_sha256(&bytes);
7237            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
7238            changed.push(V2StagedFile {
7239                path: "assets.jsonl".to_string(),
7240                source,
7241                sha256,
7242                bytes: bytes.len() as u64,
7243            });
7244        }
7245    }
7246    if let Some(pointer) = pointer {
7247        let mut pending_assets = Vec::new();
7248        for (path, asset) in &remote_assets {
7249            if asset.disposition != "hosted"
7250                || kept_home(path)
7251                || !asset_merge.accept_remote.contains(path)
7252            {
7253                continue;
7254            }
7255            if is_markdown_asset_path(path) {
7256                // The signed content plane carries the same bytes and is
7257                // already part of this install transaction.
7258                continue;
7259            }
7260            let already_current = local_store.as_ref().is_some_and(|store| {
7261                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7262                    && store
7263                        .read_bounded(Path::new(path), asset.bytes)
7264                        .ok()
7265                        .is_some_and(|bytes| {
7266                            bytes.len() as u64 == asset.bytes
7267                                && content_sha256(&bytes) == asset.blob_sha256
7268                        })
7269            });
7270            if !already_current {
7271                pending_assets.push((path, asset));
7272            }
7273        }
7274        let mut window = Vec::new();
7275        let mut window_bytes = 0_u64;
7276        let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
7277                     window_bytes: &mut u64,
7278                     changed: &mut Vec<V2StagedFile>|
7279         -> LinkResult<()> {
7280            changed.extend(stage_v2_asset_download_window(
7281                cfg,
7282                &head.brain_id,
7283                pointer,
7284                &cache_dir,
7285                window,
7286            )?);
7287            window.clear();
7288            *window_bytes = 0;
7289            Ok(())
7290        };
7291        for item @ (_, asset) in pending_assets {
7292            if !window.is_empty()
7293                && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
7294                    || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
7295            {
7296                flush(&mut window, &mut window_bytes, &mut changed)?;
7297            }
7298            window.push(item);
7299            window_bytes = window_bytes.saturating_add(asset.bytes);
7300            if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
7301                flush(&mut window, &mut window_bytes, &mut changed)?;
7302            }
7303        }
7304        flush(&mut window, &mut window_bytes, &mut changed)?;
7305    }
7306    for (path, prior) in base_assets {
7307        if remote_assets.contains_key(path)
7308            || kept_home(path)
7309            || !asset_merge.accept_remote.contains(path)
7310        {
7311            continue;
7312        }
7313        let unchanged = local_store.as_ref().is_some_and(|store| {
7314            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7315                && store
7316                    .read_bounded(Path::new(path), prior.bytes)
7317                    .ok()
7318                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
7319        });
7320        if unchanged {
7321            deleted.push(path.clone());
7322        }
7323    }
7324    let extra_local = content_merge
7325        .records
7326        .keys()
7327        .filter(|path| !remote.contains_key(*path))
7328        .cloned()
7329        .collect::<Vec<_>>();
7330    if head.view_kind == "scoped" {
7331        for (path, bytes) in [
7332            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
7333            (
7334                ".dbmd/view.json".to_string(),
7335                scoped_view_metadata(&head, remote.len())?,
7336            ),
7337        ] {
7338            let sha256 = content_sha256(&bytes);
7339            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
7340            changed.push(V2StagedFile {
7341                path,
7342                source,
7343                sha256,
7344                bytes: bytes.len() as u64,
7345            });
7346        }
7347    }
7348    let install_changed = !changed.is_empty() || !deleted.is_empty();
7349    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
7350    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
7351        let installed_store =
7352            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
7353                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7354            })?;
7355        let installed_local = if install_changed {
7356            let hint = baseline.as_ref().and_then(|state| {
7357                state
7358                    .local_policy_digest
7359                    .as_deref()
7360                    .map(|digest| (digest, &state.scan_cache))
7361            });
7362            let mut scanned = v2_local_files_cached(&installed_store, hint)?;
7363            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
7364            scanned
7365        } else {
7366            local_view
7367                .take()
7368                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
7369        };
7370        if installed_local.riding != content_merge.records {
7371            return Err(LinkError::InvalidPack {
7372                message: "local content changed while installing the v2 pull".to_string(),
7373            });
7374        }
7375        let installed_assets = if install_changed {
7376            v2_local_asset_records(&installed_store)?
7377        } else {
7378            std::mem::take(&mut local_assets)
7379        };
7380        if installed_assets != asset_merge.records {
7381            return Err(LinkError::InvalidPack {
7382                message: "local assets changed while installing the v2 pull".to_string(),
7383            });
7384        }
7385        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
7386            installed_local.policy.keeps_home(path)
7387        })
7388            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
7389        let final_head = v2_verified_head(cfg, requested_brain)?
7390            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
7391        if !same_v2_head(&head, &final_head) {
7392            return Err(LinkError::RemoteAdvancedDuringSync);
7393        }
7394        accept_v2_head(cfg, &final_head)?;
7395        save_v2_baseline(
7396            cfg,
7397            &head.brain_id,
7398            &dest,
7399            &v2_baseline_from_head(
7400                cfg,
7401                &head,
7402                remote.clone(),
7403                remote_assets.clone(),
7404                Some(&installed_local),
7405                baseline
7406                    .as_ref()
7407                    .and_then(|current| current.checkout_id.as_deref()),
7408            )?,
7409        )?;
7410        complete_v2_pull(&dest)?;
7411        Ok((local_dirty, installed_local, installed_assets))
7412    })();
7413    let (local_dirty, installed_local, installed_assets) = match finalized {
7414        Ok(value) => value,
7415        Err(error) => {
7416            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
7417                return Err(LinkError::InvalidPack {
7418                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
7419                });
7420            }
7421            return Err(error);
7422        }
7423    };
7424    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
7425    let report = PullReport {
7426        brain: head.brain_id.clone(),
7427        slug: requested_brain.to_string(),
7428        head_seq: pointer.map_or(0, |value| value.seq),
7429        files: remote.len() + remote_assets.len(),
7430        dest: dest.to_string_lossy().into_owned(),
7431        extra_local,
7432        sync_status: if local_dirty {
7433            "local_dirty_after_install".to_string()
7434        } else {
7435            "synced".to_string()
7436        },
7437    };
7438    Ok(V2PulledSnapshot {
7439        report,
7440        head,
7441        files: remote,
7442        assets: remote_assets,
7443        local: installed_local,
7444        local_assets: installed_assets,
7445    })
7446}
7447
7448fn v2_sync_pull(
7449    cfg: &HubConfig,
7450    requested_brain: &str,
7451    head: V2VerifiedHead,
7452    out: Option<&Path>,
7453) -> LinkResult<PullReport> {
7454    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
7455}
7456
7457fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
7458    match remote {
7459        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
7460        None => json!({ "kind": "absent" }),
7461    }
7462}
7463
7464fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7465    match remote {
7466        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7467        None => json!({ "kind": "absent" }),
7468    }
7469}
7470
7471fn v2_content_withdrawal_operation(
7472    store: &Store,
7473    local_view: &V2LocalView,
7474    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7475    path: &str,
7476    reason: &str,
7477) -> LinkResult<Value> {
7478    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7479        || path == "DB.md"
7480    {
7481        return Err(LinkError::InvalidPack {
7482            message: format!("content withdrawal path `{path}` is not a record or source"),
7483        });
7484    }
7485    if !local_view.policy.keeps_home(path)
7486        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7487    {
7488        return Err(LinkError::InvalidPack {
7489            message: format!(
7490                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7491            ),
7492        });
7493    }
7494    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7495        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7496    })?;
7497    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
7498    Ok(json!({
7499        "op": "withdraw_from_hosting",
7500        "path": path,
7501        "expected": { "kind": "blob", "hash": current.sha256 },
7502        "reason": reason,
7503    }))
7504}
7505
7506fn v2_asset_withdrawal_operation(
7507    store: &Store,
7508    local_view: &V2LocalView,
7509    path: &str,
7510    local: &crate::AssetRecord,
7511    current: &V2BaselineAsset,
7512    reason: &str,
7513) -> LinkResult<Value> {
7514    if !local_view.policy.keeps_home(path)
7515        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7516    {
7517        return Err(LinkError::InvalidPack {
7518            message: format!(
7519                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7520            ),
7521        });
7522    }
7523    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7524    if current.disposition != "hosted"
7525        || current.blob_sha256 != local.sha256
7526        || current.bytes != local.bytes
7527        || current.media_type != local.media_type
7528    {
7529        return Err(LinkError::InvalidPack {
7530            message: format!(
7531                "asset withdrawal path `{path}` must preserve the currently hosted blob identity, byte count, and media type"
7532            ),
7533        });
7534    }
7535    Ok(json!({
7536        "op": "asset_withdraw",
7537        "path": path,
7538        "expected": v2_asset_expected(Some(current)),
7539        "asset": v2_asset_value(local, "withheld"),
7540        "reason": reason,
7541    }))
7542}
7543
7544// A source is immutable, but its exact bytes may move from an intake namespace
7545// to an archive namespace under the distinct `promote_source` capability. A
7546// plain filesystem move appears to sync as one absent old path plus one new
7547// path. Preserve that intent as a protocol rename only when the hash has one
7548// unambiguous source delete and one unambiguous absent-destination source put.
7549// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
7550fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7551    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7552    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7553    for (index, operation) in operations.iter().enumerate() {
7554        match operation.get("op").and_then(Value::as_str) {
7555            Some("delete") => {
7556                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7557                    continue;
7558                };
7559                let Some(hash) = operation
7560                    .get("expected")
7561                    .and_then(|value| value.get("hash"))
7562                    .and_then(Value::as_str)
7563                else {
7564                    continue;
7565                };
7566                if path.starts_with("sources/") {
7567                    deletes
7568                        .entry(hash.to_string())
7569                        .or_default()
7570                        .push((index, path.to_string()));
7571                }
7572            }
7573            Some("put" | "put_asset_content") => {
7574                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7575                    continue;
7576                };
7577                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7578                    continue;
7579                };
7580                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7581                    continue;
7582                };
7583                let destination_absent = operation
7584                    .get("expected")
7585                    .and_then(|value| value.get("kind"))
7586                    .and_then(Value::as_str)
7587                    == Some("absent");
7588                if path.starts_with("sources/") && destination_absent {
7589                    puts.entry(hash.to_string()).or_default().push((
7590                        index,
7591                        path.to_string(),
7592                        bytes,
7593                    ));
7594                }
7595            }
7596            _ => {}
7597        }
7598    }
7599    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7600    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7601    for (hash, source) in deletes {
7602        let Some(destination) = puts.get(&hash) else {
7603            continue;
7604        };
7605        if source.len() != 1 || destination.len() != 1 {
7606            continue;
7607        }
7608        let (delete_index, from) = &source[0];
7609        let (put_index, to, bytes) = &destination[0];
7610        if from == to {
7611            continue;
7612        }
7613        rename_at.insert(
7614            *delete_index,
7615            json!({
7616                "op": "rename",
7617                "from": from,
7618                "to": to,
7619                "expected_from": { "kind": "blob", "hash": hash },
7620                "expected_to": { "kind": "absent" },
7621                "blob": hash,
7622                "bytes": bytes,
7623            }),
7624        );
7625        consumed_puts.insert(*put_index);
7626    }
7627    operations
7628        .into_iter()
7629        .enumerate()
7630        .filter_map(|(index, operation)| {
7631            if let Some(rename) = rename_at.remove(&index) {
7632                Some(rename)
7633            } else if consumed_puts.contains(&index) {
7634                None
7635            } else {
7636                Some(operation)
7637            }
7638        })
7639        .collect()
7640}
7641
7642fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7643    json!({
7644        "blob_sha256": record.sha256,
7645        "bytes": record.bytes,
7646        "media_type": record.media_type,
7647        "wrappers": record.wrappers,
7648        "required": record.required,
7649        "disposition": disposition,
7650    })
7651}
7652
7653/// Apply the exact operation subset generated by this client to an already
7654/// verified manifest. This avoids downloading the same 50k-path listing again
7655/// after an unrebased commit; a rebased response still re-reads the manifest.
7656fn apply_generated_v2_operations(
7657    operations: &[Value],
7658    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7659    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7660    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7661) -> LinkResult<bool> {
7662    let mut asset_changed = false;
7663    for operation in operations {
7664        match operation.get("op").and_then(Value::as_str) {
7665            Some("put" | "put_asset_content") => {
7666                let path = operation
7667                    .get("path")
7668                    .and_then(Value::as_str)
7669                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7670                let sha256 = operation
7671                    .get("blob")
7672                    .and_then(Value::as_str)
7673                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7674                let bytes = operation
7675                    .get("bytes")
7676                    .and_then(Value::as_u64)
7677                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7678                candidate.insert(
7679                    path.to_string(),
7680                    V2BaselineFile {
7681                        sha256: sha256.to_string(),
7682                        bytes,
7683                        proof: None,
7684                    },
7685                );
7686            }
7687            Some("rename") => {
7688                let from = operation
7689                    .get("from")
7690                    .and_then(Value::as_str)
7691                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7692                let to = operation
7693                    .get("to")
7694                    .and_then(Value::as_str)
7695                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7696                let sha256 = operation
7697                    .get("blob")
7698                    .and_then(Value::as_str)
7699                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7700                let bytes = operation
7701                    .get("bytes")
7702                    .and_then(Value::as_u64)
7703                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7704                let expected_from = operation
7705                    .get("expected_from")
7706                    .and_then(|expected| expected.get("hash"))
7707                    .and_then(Value::as_str);
7708                let expected_to_absent = operation
7709                    .get("expected_to")
7710                    .and_then(|expected| expected.get("kind"))
7711                    .and_then(Value::as_str)
7712                    == Some("absent");
7713                if from == to
7714                    || !from.starts_with("sources/")
7715                    || !to.starts_with("sources/")
7716                    || expected_from != Some(sha256)
7717                    || !expected_to_absent
7718                    || candidate.contains_key(to)
7719                {
7720                    return Err(invalid_feed("generated v2 source rename is malformed"));
7721                }
7722                let source = candidate
7723                    .remove(from)
7724                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7725                if source.sha256 != sha256 || source.bytes != bytes {
7726                    return Err(invalid_feed(
7727                        "v2 rename source differs from its exact-byte claim",
7728                    ));
7729                }
7730                candidate.insert(
7731                    to.to_string(),
7732                    V2BaselineFile {
7733                        sha256: sha256.to_string(),
7734                        bytes,
7735                        proof: None,
7736                    },
7737                );
7738            }
7739            Some("delete" | "withdraw_from_hosting") => {
7740                let path = operation
7741                    .get("path")
7742                    .and_then(Value::as_str)
7743                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7744                candidate.remove(path);
7745            }
7746            Some("asset_delete") => {
7747                let path = operation
7748                    .get("path")
7749                    .and_then(Value::as_str)
7750                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7751                candidate_assets.remove(path);
7752                asset_changed = true;
7753            }
7754            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7755                let path = operation
7756                    .get("path")
7757                    .and_then(Value::as_str)
7758                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7759                let record = local_assets
7760                    .get(path)
7761                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7762                let disposition =
7763                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7764                        "withheld"
7765                    } else {
7766                        operation
7767                            .get("asset")
7768                            .and_then(|asset| asset.get("disposition"))
7769                            .and_then(Value::as_str)
7770                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7771                    };
7772                candidate_assets.insert(
7773                    path.to_string(),
7774                    V2BaselineAsset {
7775                        blob_sha256: record.sha256.clone(),
7776                        bytes: record.bytes,
7777                        media_type: record.media_type.clone(),
7778                        wrappers: record.wrappers.clone(),
7779                        required: record.required,
7780                        disposition: disposition.to_string(),
7781                        // The server assigns the signed leaf address. Callers
7782                        // that need an exact asset manifest re-read it below.
7783                        leaf_hash: String::new(),
7784                    },
7785                );
7786                asset_changed = true;
7787            }
7788            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7789        }
7790    }
7791    Ok(asset_changed)
7792}
7793
7794fn v2_riding_matches_remote(
7795    local: &std::collections::BTreeMap<String, (String, u64)>,
7796    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7797    keeps_home: impl Fn(&str) -> bool,
7798) -> bool {
7799    remote.iter().all(|(path, file)| {
7800        keeps_home(path)
7801            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7802    }) && local.iter().all(|(path, (hash, _))| {
7803        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7804    })
7805}
7806
7807fn v2_initial_content_conflicts(
7808    local: &std::collections::BTreeMap<String, (String, u64)>,
7809    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7810    resolving: bool,
7811) -> Vec<String> {
7812    if resolving {
7813        // `sync resolve` already pins the exact remote head/view and rechecks
7814        // every bundled local coordinate before it enters the push planner.
7815        // Treating this as an ordinary baseline-less push would merely mint an
7816        // identical conflict bundle forever. The operation loop below permits
7817        // only those explicit remote replacements plus safe local-only
7818        // additions, and verifies each selected local/remote pair again before
7819        // authoring the commit.
7820        return Vec::new();
7821    }
7822    remote
7823        .iter()
7824        .filter(|(path, file)| {
7825            local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7826        })
7827        .map(|(path, _)| path.clone())
7828        .collect()
7829}
7830
7831fn v2_resolution_allows_path(
7832    resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7833    path: &str,
7834    remote_present: bool,
7835) -> bool {
7836    resolution.is_none_or(|allowed| allowed.contains_key(path) || !remote_present)
7837}
7838
7839fn v2_resolution_allows_asset(
7840    resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7841    base: Option<&V2BaselineAsset>,
7842    remote: Option<&V2BaselineAsset>,
7843    local: Option<&crate::AssetRecord>,
7844) -> bool {
7845    let Some(allowed) = resolution else {
7846        return false;
7847    };
7848    let wrappers = base
7849        .into_iter()
7850        .flat_map(|asset| asset.wrappers.iter())
7851        .chain(remote.into_iter().flat_map(|asset| asset.wrappers.iter()))
7852        .chain(local.into_iter().flat_map(|asset| asset.wrappers.iter()))
7853        .collect::<BTreeSet<_>>();
7854    !wrappers.is_empty()
7855        && wrappers
7856            .iter()
7857            .all(|wrapper| allowed.contains_key(*wrapper))
7858}
7859
7860#[derive(Debug, Clone)]
7861struct V2ResolutionOverride {
7862    expected_remote: Option<String>,
7863    selected_local: Option<String>,
7864}
7865
7866#[derive(Debug, Clone)]
7867struct V2UploadSource {
7868    path: String,
7869    bytes: u64,
7870}
7871
7872struct V2SyncPushOptions<'a> {
7873    resume_local_policy: bool,
7874    bulk_confirmation: Option<&'a V2BulkConfirmation>,
7875    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7876    pulled: Option<V2PulledSnapshot>,
7877    withdrawal_paths: &'a [String],
7878    withdrawal_reason: Option<&'a str>,
7879    /// Permit one automatic DB.md-only commit before the remaining exact
7880    /// content/asset operations. The recursive second phase disables this so
7881    /// a concurrently edited contract fails for a clean retry instead of
7882    /// creating an unbounded commit chain.
7883    allow_contract_phase: bool,
7884}
7885
7886fn verify_v2_upload_source(
7887    store: &Store,
7888    path: &str,
7889    sha256: &str,
7890    expected_bytes: u64,
7891) -> LinkResult<()> {
7892    let file = store.open_regular(Path::new(path))?;
7893    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7894        return Err(LinkError::InvalidPack {
7895            message: format!("local path `{path}` changed during sync planning"),
7896        });
7897    }
7898    Ok(())
7899}
7900
7901/// One reserved object waiting to be staged, held until its whole batch can be
7902/// uploaded together.
7903struct V2PendingUpload<'a> {
7904    url: String,
7905    headers: Value,
7906    sha256: String,
7907    source: &'a V2UploadSource,
7908}
7909
7910/// How many objects a migration stages at once. Bounded so a first push does
7911/// not behave like a burst against the object store, while still being far
7912/// faster than one-at-a-time.
7913/// Workers staging one batch. With a shared pooled connection the cost of an
7914/// object is the PUT itself rather than a handshake, so a wider pool converts
7915/// directly into throughput on a migration.
7916const V2_UPLOAD_CONCURRENCY: usize = 16;
7917
7918/// Stage a reserved batch, uploading up to `V2_UPLOAD_CONCURRENCY` objects at a
7919/// time and verifying each against the store after it lands. The first failure
7920/// is returned and the remaining work is abandoned.
7921fn upload_v2_batch_concurrently(
7922    cfg: &HubConfig,
7923    store: &Store,
7924    pending: &[V2PendingUpload<'_>],
7925) -> LinkResult<()> {
7926    if pending.is_empty() {
7927        return Ok(());
7928    }
7929    let urls = pending
7930        .iter()
7931        .map(|task| task.url.as_str())
7932        .collect::<Vec<_>>();
7933    let shared = shared_staging_agent(cfg, &urls);
7934    if pending.len() == 1 {
7935        let task = &pending[0];
7936        put_presigned_source(
7937            cfg,
7938            &task.url,
7939            &task.headers,
7940            store,
7941            task.source,
7942            shared.as_ref(),
7943        )?;
7944        return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7945    }
7946    let next = std::sync::atomic::AtomicUsize::new(0);
7947    let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7948    let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7949    std::thread::scope(|scope| {
7950        for _ in 0..workers {
7951            scope.spawn(|| loop {
7952                if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7953                    return;
7954                }
7955                let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7956                let Some(task) = pending.get(index) else {
7957                    return;
7958                };
7959                let outcome = put_presigned_source(
7960                    cfg,
7961                    &task.url,
7962                    &task.headers,
7963                    store,
7964                    task.source,
7965                    shared.as_ref(),
7966                )
7967                .and_then(|()| {
7968                    verify_v2_upload_source(
7969                        store,
7970                        &task.source.path,
7971                        &task.sha256,
7972                        task.source.bytes,
7973                    )
7974                });
7975                if let Err(error) = outcome {
7976                    if let Ok(mut guard) = failure.lock() {
7977                        guard.get_or_insert(error);
7978                    }
7979                    return;
7980                }
7981            });
7982        }
7983    });
7984    match failure.into_inner() {
7985        Ok(Some(error)) => Err(error),
7986        Ok(None) => Ok(()),
7987        Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7988    }
7989}
7990
7991fn put_presigned_source(
7992    cfg: &HubConfig,
7993    raw: &str,
7994    headers: &Value,
7995    store: &Store,
7996    source: &V2UploadSource,
7997    shared: Option<&ureq::Agent>,
7998) -> LinkResult<()> {
7999    put_presigned_source_with_budget(
8000        cfg,
8001        raw,
8002        headers,
8003        store,
8004        source,
8005        shared,
8006        std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
8007    )
8008}
8009
8010fn put_presigned_source_with_budget(
8011    cfg: &HubConfig,
8012    raw: &str,
8013    headers: &Value,
8014    store: &Store,
8015    source: &V2UploadSource,
8016    shared: Option<&ureq::Agent>,
8017    total_budget: std::time::Duration,
8018) -> LinkResult<()> {
8019    // The URL's own safety checks run either way; only the connection pool is
8020    // shared, so a batch never trades a check for a warm socket.
8021    let owned = match shared {
8022        Some(_) => {
8023            checked_presigned_url(cfg, raw)?;
8024            None
8025        }
8026        None => Some(presigned_agent(cfg, raw)?),
8027    };
8028    let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
8029    let deadline = std::time::Instant::now()
8030        .checked_add(total_budget)
8031        .ok_or_else(upload_deadline_error)?;
8032    let mut attempt = 0;
8033    let result = loop {
8034        let file = store.open_regular(Path::new(&source.path))?;
8035        if file.metadata()?.len() != source.bytes {
8036            return Err(LinkError::InvalidPack {
8037                message: format!("local path `{}` changed before upload", source.path),
8038            });
8039        }
8040        // The reservation already states content-length, lowercased. The HTTP
8041        // client de-duplicates headers by exact name, not case, so setting
8042        // `Content-Length` here as well leaves both on the wire and an edge
8043        // rejects the request as malformed. Supply it only when absent.
8044        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
8045        let mut has_content_length = false;
8046        if let Some(map) = headers.as_object() {
8047            for (name, value) in map {
8048                if let Some(value) = value.as_str() {
8049                    has_content_length |= name.eq_ignore_ascii_case("content-length");
8050                    req = req.set(name, value);
8051                }
8052            }
8053        }
8054        if !has_content_length {
8055            req = req.set("Content-Length", &source.bytes.to_string());
8056        }
8057        match req.send(file) {
8058            // Every transport failure is retryable here, not only the
8059            // pre-request kinds: staging an object is idempotent (fixed content
8060            // address, exact length and checksum, write-once precondition), the
8061            // file is reopened on each attempt, and a migration holds
8062            // connections long enough that a mid-request drop is ordinary.
8063            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
8064                attempt += 1;
8065            }
8066            // A migration stages tens of thousands of immutable objects, and an
8067            // edge or object store may refuse a single one under sustained
8068            // load. The upload is content-addressed and precondition-guarded,
8069            // so replaying it is safe; failing the whole push on one refusal is
8070            // not. 412 already means "someone else stored these exact bytes".
8071            Err(ureq::Error::Status(status, _))
8072                if status != 412
8073                    && is_retryable_upload_status(status)
8074                    && wait_for_upload_retry(deadline, attempt) =>
8075            {
8076                attempt += 1;
8077            }
8078            result => break result,
8079        }
8080    };
8081    match result {
8082        Ok(response) if (200..300).contains(&response.status()) => {
8083            drain_presigned_response(response);
8084            Ok(())
8085        }
8086        Ok(response) => {
8087            // The object store states its own reason (checksum mismatch,
8088            // precondition, expiry). Carrying a bounded excerpt turns an
8089            // unactionable refusal into one an operator can diagnose; the
8090            // coordinate is the store path, never file content.
8091            let status = response.status();
8092            let detail = response
8093                .into_string()
8094                .ok()
8095                .map(|body| body.chars().take(400).collect::<String>())
8096                .filter(|body| !body.trim().is_empty());
8097            Err(LinkError::Http {
8098                what: "v2 changed-byte upload",
8099                status,
8100                message: match detail {
8101                    Some(body) => format!(
8102                        "object store rejected the upload of `{}`: {}",
8103                        source.path,
8104                        body.replace('\n', " ")
8105                    ),
8106                    None => format!("object store rejected the upload of `{}`", source.path),
8107                },
8108                code: None,
8109                details: None,
8110            })
8111        }
8112        Err(error) => match error {
8113            ureq::Error::Status(412, _) => Ok(()),
8114            ureq::Error::Status(_, response) => {
8115                let status = response.status();
8116                let detail = response
8117                    .into_string()
8118                    .ok()
8119                    .map(|body| body.chars().take(400).collect::<String>())
8120                    .filter(|body| !body.trim().is_empty());
8121                Err(LinkError::Http {
8122                    what: "v2 changed-byte upload",
8123                    status,
8124                    message: match detail {
8125                        Some(body) => format!(
8126                            "object store rejected the upload of `{}`: {}",
8127                            source.path,
8128                            body.replace('\n', " ")
8129                        ),
8130                        None => {
8131                            format!("object store rejected the upload of `{}`", source.path)
8132                        }
8133                    },
8134                    code: None,
8135                    details: None,
8136                })
8137            }
8138            ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
8139        },
8140    }
8141}
8142
8143/// The request as it is hashed and signed. A staged push carries its operations
8144/// by address rather than inline, so the signed view restores them: the request
8145/// hash covers the change itself, never the transport that carried it.
8146fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
8147    if body.get("operations").is_some() {
8148        return body.clone();
8149    }
8150    let mut value = body.clone();
8151    if let Some(map) = value.as_object_mut() {
8152        map.remove("staged_change");
8153        map.insert("operations".to_string(), Value::Array(operations.to_vec()));
8154    }
8155    value
8156}
8157
8158/// Open one staging window, waiting out a busy hub rather than abandoning the
8159/// push. The call reserves capacity and mints URLs; it commits nothing, so
8160/// asking again after a refusal costs only the wait.
8161fn reserve_upload_window(
8162    cfg: &HubConfig,
8163    path: &str,
8164    body: &Value,
8165    what: &'static str,
8166) -> LinkResult<Value> {
8167    let mut attempt = 0;
8168    loop {
8169        let pause = |attempt: usize| {
8170            std::thread::sleep(std::time::Duration::from_millis(
8171                RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
8172            ));
8173        };
8174        match request(cfg, "POST", path, Some(body), Auth::Required) {
8175            // A migration opens dozens of windows over tens of minutes, so one
8176            // response that never arrives is ordinary rather than final. The
8177            // call reserves capacity and mints URLs; it commits nothing, so a
8178            // dropped or timed-out attempt is worth asking again.
8179            Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
8180                pause(attempt);
8181                attempt += 1;
8182            }
8183            Err(error) => return Err(error),
8184            Ok(response) => {
8185                if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
8186                    pause(attempt);
8187                    attempt += 1;
8188                    continue;
8189                }
8190                return ensure_ok(response, what);
8191            }
8192        }
8193    }
8194}
8195
8196/// The exact bytes of a staged change. The hub refuses a manifest carrying any
8197/// other key, so this is the whole contract: operations, and the blob
8198/// references they need, and nothing else.
8199fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
8200    let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
8201        .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
8202    if bytes.len() > MAX_STAGED_CHANGE_BYTES {
8203        return Err(LinkError::PushTooLarge {
8204            detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
8205        });
8206    }
8207    Ok(bytes)
8208}
8209
8210/// State one change's operations and blob references through the same
8211/// content-addressed lane its bytes already ride.
8212///
8213/// A first push of a real store names one operation and one blob reference per
8214/// file, which alone outgrows the bounded commit request. Staging that metadata
8215/// and addressing it by hash keeps the push a single atomic commit: the hub
8216/// verifies the address, splices the manifest in, and validates exactly what it
8217/// would have validated inline. The alternative — many partial commits — would
8218/// make a failed migration leave a half-populated brain readable.
8219fn stage_v2_change(
8220    cfg: &HubConfig,
8221    requested_brain: &str,
8222    operations: &[Value],
8223    blobs: Value,
8224) -> LinkResult<Value> {
8225    let bytes = v2_change_manifest(operations, blobs)?;
8226    let sha256 = content_sha256(&bytes);
8227    let reserved = reserve_upload_window(
8228        cfg,
8229        &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8230        &json!({
8231            "blobs": [{
8232                "sha256": sha256,
8233                "bytes": bytes.len(),
8234                "kind": "staged_change",
8235            }],
8236        }),
8237        "stage the v2 change",
8238    )?;
8239    let items = reserved
8240        .get("uploads")
8241        .and_then(Value::as_array)
8242        .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
8243    let [item] = items.as_slice() else {
8244        return Err(invalid_feed(
8245            "v2 change staging response changed the requested set",
8246        ));
8247    };
8248    let reservation_id = item
8249        .get("reservation_id")
8250        .and_then(Value::as_str)
8251        .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
8252    if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
8253        || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
8254        || !crate::ulid::is_ulid(reservation_id)
8255    {
8256        return Err(invalid_feed("v2 change staging item is inconsistent"));
8257    }
8258    match item.get("status").and_then(Value::as_str) {
8259        Some("upload") => put_presigned(
8260            cfg,
8261            item.get("url")
8262                .and_then(Value::as_str)
8263                .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
8264            item.get("headers").unwrap_or(&Value::Null),
8265            &bytes,
8266        )?,
8267        Some("already_present") => {}
8268        _ => return Err(invalid_feed("v2 change staging has an unknown status")),
8269    }
8270    Ok(json!({
8271        "sha256": sha256,
8272        "bytes": bytes.len(),
8273        "reservation_id": reservation_id,
8274    }))
8275}
8276
8277/// Move a commit request's operations and blob references off the wire body
8278/// when they no longer fit in it. Idempotent by construction: the mutation ID
8279/// is derived from the change, never from how the change was transported.
8280fn stage_oversized_v2_change(
8281    cfg: &HubConfig,
8282    requested_brain: &str,
8283    operations: &[Value],
8284    body: &mut Value,
8285) -> LinkResult<()> {
8286    if body.to_string().len() <= MAX_PUSH_BYTES {
8287        return Ok(());
8288    }
8289    let staged = stage_v2_change(
8290        cfg,
8291        requested_brain,
8292        operations,
8293        body.get("blobs")
8294            .cloned()
8295            .unwrap_or(Value::Array(Vec::new())),
8296    )?;
8297    let map = body
8298        .as_object_mut()
8299        .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
8300    map.remove("operations");
8301    map.remove("blobs");
8302    map.insert("staged_change".to_string(), staged);
8303    Ok(())
8304}
8305
8306fn v2_sync_push(
8307    cfg: &HubConfig,
8308    requested_brain: &str,
8309    store: &Store,
8310    head: V2VerifiedHead,
8311    options: V2SyncPushOptions<'_>,
8312) -> LinkResult<Value> {
8313    let V2SyncPushOptions {
8314        resume_local_policy,
8315        bulk_confirmation,
8316        resolution,
8317        pulled,
8318        withdrawal_paths,
8319        withdrawal_reason,
8320        allow_contract_phase,
8321    } = options;
8322    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
8323    let head = v2_verified_head(cfg, requested_brain)?
8324        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
8325    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
8326    ensure_v2_view_compatible(&head, baseline.as_ref())?;
8327    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
8328        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
8329            Some(snapshot) => (
8330                snapshot.files,
8331                snapshot.assets,
8332                Some(snapshot.local),
8333                Some(snapshot.local_assets),
8334            ),
8335            None => match baseline
8336                .as_ref()
8337                .filter(|state| v2_baseline_matches_head(&head, state))
8338            {
8339                Some(state) => (state.files.clone(), state.assets.clone(), None, None),
8340                None => (
8341                    files_for_v2_view(
8342                        &head,
8343                        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
8344                    ),
8345                    v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
8346                    None,
8347                    None,
8348                ),
8349            },
8350        };
8351    if head.view_kind == "scoped" && baseline.is_none() {
8352        return Err(LinkError::ScopedViewChanged);
8353    }
8354    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
8355    let local = &local_view.riding;
8356    let local_assets = match carried_local_assets {
8357        Some(assets) => assets,
8358        None => v2_local_asset_records(store)?,
8359    };
8360    if withdrawal_paths.len() > MAX_PUSH_FILES {
8361        return Err(LinkError::PushTooLarge {
8362            detail: "too many explicit withdrawal paths".to_string(),
8363        });
8364    }
8365    let withdrawal_reason = if withdrawal_paths.is_empty() {
8366        None
8367    } else {
8368        let reason = withdrawal_reason
8369            .map(str::trim)
8370            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
8371            .ok_or_else(|| LinkError::InvalidPack {
8372                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
8373            })?;
8374        Some(reason)
8375    };
8376    let mut withdrawals = withdrawal_paths
8377        .iter()
8378        .map(|path| {
8379            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
8380                path: error.to_string(),
8381            })
8382        })
8383        .collect::<LinkResult<Vec<_>>>()?;
8384    withdrawals.sort();
8385    withdrawals.dedup();
8386    if withdrawals.len() != withdrawal_paths.len() {
8387        return Err(LinkError::InvalidPack {
8388            message: "explicit withdrawal paths must be unique".to_string(),
8389        });
8390    }
8391    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
8392    let mut consumed_withdrawals = BTreeSet::new();
8393    if let Some(previous) = baseline.as_ref() {
8394        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
8395            && !resume_local_policy
8396        {
8397            let mut newly_eligible = previous
8398                .local_eligibility
8399                .iter()
8400                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
8401                .map(|(path, _)| path.clone())
8402                .collect::<Vec<_>>();
8403            if !newly_eligible.is_empty() {
8404                newly_eligible.truncate(100);
8405                return Err(LinkError::LocalPolicyTransition {
8406                    paths: newly_eligible,
8407                });
8408            }
8409        }
8410    }
8411    // An accepted commit can outlive the response path that would have saved
8412    // its local baseline. If the complete current riding tree and asset set
8413    // already match the independently verified new head, fast-forward the
8414    // private baseline by proof. This is not force and cannot hide a conflict:
8415    // any differing path falls through to the ordinary three-way algorithm.
8416    if baseline
8417        .as_ref()
8418        .is_some_and(|state| !v2_baseline_matches_head(&head, state))
8419        && resolution.is_none()
8420        && withdrawal_paths.is_empty()
8421        && v2_riding_matches_remote(local, &remote, |path| local_view.policy.keeps_home(path))
8422        && v2_asset_records_match_remote(&local_assets, &remote_assets)
8423    {
8424        let final_head = v2_verified_head(cfg, requested_brain)?
8425            .ok_or_else(|| invalid_feed("v2 head disappeared during baseline recovery"))?;
8426        if !same_v2_head(&head, &final_head) {
8427            return Err(LinkError::RemoteAdvancedDuringSync);
8428        }
8429        let mut final_local = v2_local_files_cached(
8430            store,
8431            Some((&local_view.policy.digest, &local_view.scan_cache)),
8432        )?;
8433        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8434        let final_assets = v2_local_asset_records(store)?;
8435        if final_local.riding != local_view.riding || final_assets != local_assets {
8436            return Err(LinkError::RemoteAdvancedDuringSync);
8437        }
8438        let checkout_pseudonym = v2_checkout_id(
8439            baseline
8440                .as_ref()
8441                .and_then(|current| current.checkout_id.as_deref()),
8442        )?;
8443        let next = v2_baseline_from_head(
8444            cfg,
8445            &head,
8446            remote,
8447            remote_assets,
8448            Some(&final_local),
8449            Some(&checkout_pseudonym),
8450        )?;
8451        let split_count = next.remote_copy_remains.len();
8452        accept_v2_head(cfg, &final_head)?;
8453        refresh_scoped_view_marker(store, &head, next.files.len())?;
8454        save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8455        return Ok(json!({
8456            "v": 2,
8457            "outcome": "no_change",
8458            "sync_status": "synced",
8459            "baseline_recovered": true,
8460            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8461            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8462            "local_policy": {
8463                "remote_copy_remains": split_count,
8464            },
8465        }));
8466    }
8467    let base = match baseline.as_ref() {
8468        Some(state) => &state.files,
8469        None if remote.is_empty() => &remote,
8470        None => {
8471            let mut conflicts = v2_initial_content_conflicts(local, &remote, resolution.is_some());
8472            if !conflicts.is_empty() {
8473                conflicts.truncate(100);
8474                let (bundle, paths) =
8475                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
8476                return Err(LinkError::ConflictBundle { bundle, paths });
8477            }
8478            &remote
8479        }
8480    };
8481    let all_paths = base
8482        .keys()
8483        .chain(remote.keys())
8484        .chain(local.keys())
8485        .cloned()
8486        .collect::<std::collections::BTreeSet<_>>();
8487    let mut conflicts = Vec::new();
8488    let mut operations = Vec::new();
8489    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
8490    for path in all_paths {
8491        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
8492        let remote_file = remote.get(&path);
8493        let remote_hash = remote_file.map(|file| file.sha256.as_str());
8494        let local_file = local.get(&path);
8495        let local_hash = local_file.map(|file| file.0.as_str());
8496        // A remote change that already equals the exact local intent is
8497        // converged, even when the private baseline is older. Emitting a
8498        // redundant put here can turn a completed DB.md contract phase back
8499        // into another mixed contract/content mutation.
8500        if local_hash == remote_hash {
8501            continue;
8502        }
8503        if local_hash == base_hash {
8504            continue;
8505        }
8506        if !v2_resolution_allows_path(resolution, &path, remote_file.is_some()) {
8507            continue;
8508        }
8509        if local_view.policy.keeps_home(&path) {
8510            // Kept-home is a local transfer exclusion, never an implicit
8511            // delete of the company's already-hosted coordinate.
8512            continue;
8513        }
8514        if remote_hash != base_hash && local_hash != remote_hash {
8515            let explicitly_resolved = resolution
8516                .and_then(|allowed| allowed.get(&path))
8517                .is_some_and(|selected| {
8518                    selected.expected_remote.as_deref() == remote_hash
8519                        && selected.selected_local.as_deref() == local_hash
8520                });
8521            if !explicitly_resolved {
8522                conflicts.push(path);
8523                continue;
8524            }
8525        }
8526        match local_file {
8527            Some((sha256, byte_count)) => {
8528                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
8529                operations.push(json!({
8530                    "op": v2_content_put_operation_kind(&path, &local_assets),
8531                    "path": path,
8532                    "expected": v2_expected(remote_file),
8533                    "blob": sha256,
8534                    "bytes": byte_count,
8535                }));
8536                upload_sources
8537                    .entry(sha256.clone())
8538                    .or_insert_with(|| V2UploadSource {
8539                        path: path.clone(),
8540                        bytes: *byte_count,
8541                    });
8542            }
8543            None => {
8544                let Some(current) = remote_file else {
8545                    continue;
8546                };
8547                operations.push(json!({
8548                    "op": "delete",
8549                    "path": path,
8550                    "expected": { "kind": "blob", "hash": current.sha256 },
8551                }));
8552            }
8553        }
8554    }
8555    operations = infer_exact_source_promotions(operations);
8556    for path in &withdrawals {
8557        if !v2_withdrawal_includes_content(path, &local_assets) {
8558            continue;
8559        }
8560        operations.push(v2_content_withdrawal_operation(
8561            store,
8562            &local_view,
8563            &remote,
8564            path,
8565            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8566        )?);
8567        consumed_withdrawals.insert(path.clone());
8568    }
8569    if !conflicts.is_empty() {
8570        conflicts.truncate(100);
8571        let (bundle, paths) = create_v2_conflict_bundle(
8572            cfg,
8573            store,
8574            &head,
8575            baseline.as_ref(),
8576            local,
8577            &remote,
8578            &conflicts,
8579        )?;
8580        return Err(LinkError::ConflictBundle { bundle, paths });
8581    }
8582    let base_assets = match baseline.as_ref() {
8583        Some(state) => &state.assets,
8584        None if remote_assets.is_empty() => &remote_assets,
8585        None => {
8586            let mismatched = remote_assets
8587                .keys()
8588                .chain(local_assets.keys())
8589                .collect::<BTreeSet<_>>()
8590                .into_iter()
8591                .filter(|path| {
8592                    remote_assets
8593                        .get(*path)
8594                        .map(|asset| v2_asset_record(asset, path))
8595                        .as_ref()
8596                        != local_assets.get(*path)
8597                })
8598                .collect::<Vec<_>>();
8599            let resolution_covers_all = !mismatched.is_empty()
8600                && mismatched.iter().all(|path| {
8601                    v2_resolution_allows_asset(
8602                        resolution,
8603                        None,
8604                        remote_assets.get(*path),
8605                        local_assets.get(*path),
8606                    )
8607                });
8608            if !mismatched.is_empty() && !resolution_covers_all {
8609                return Err(LinkError::Conflict {
8610                    paths: vec!["assets.jsonl".to_string()],
8611                });
8612            }
8613            &remote_assets
8614        }
8615    };
8616    let asset_paths = base_assets
8617        .keys()
8618        .chain(remote_assets.keys())
8619        .chain(local_assets.keys())
8620        .cloned()
8621        .collect::<std::collections::BTreeSet<_>>();
8622    let mut asset_policy_transitions = Vec::new();
8623    let mut asset_withdrawal_transitions = Vec::new();
8624    for path in asset_paths {
8625        let base_record = base_assets
8626            .get(&path)
8627            .map(|asset| v2_asset_record(asset, &path));
8628        let remote = remote_assets.get(&path);
8629        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8630        let local_record = local_assets.get(&path);
8631        if withdrawal_set.contains(&path) {
8632            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8633                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8634            })?;
8635            let current = remote.ok_or_else(|| LinkError::InvalidPack {
8636                message: format!(
8637                    "asset withdrawal path `{path}` has no readable hosted coordinate"
8638                ),
8639            })?;
8640            operations.push(v2_asset_withdrawal_operation(
8641                store,
8642                &local_view,
8643                &path,
8644                record,
8645                current,
8646                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8647            )?);
8648            consumed_withdrawals.insert(path.clone());
8649            continue;
8650        }
8651        let mut raw_present = false;
8652        let mut disposition = "withheld";
8653        let mut resumes_hosting = false;
8654        if let Some(record) = local_record {
8655            crate::linkmd_v2::normalize_path(&record.path)
8656                .map_err(|error| invalid_feed(error.to_string()))?;
8657            let kept_home = local_view.policy.keeps_home(&path);
8658            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8659            disposition = if kept_home || !raw_present {
8660                "withheld"
8661            } else {
8662                "hosted"
8663            };
8664            let inherits_withheld_absence = v2_asset_inherits_withheld_absence(
8665                base_assets.get(&path),
8666                base_record.as_ref(),
8667                local_record,
8668                raw_present,
8669            );
8670            if !raw_present && record.required && !kept_home && !inherits_withheld_absence {
8671                return Err(LinkError::InvalidPack {
8672                    message: format!("required asset {path} is missing"),
8673                });
8674            }
8675            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8676            if remote.is_some_and(|asset| asset.disposition == "hosted")
8677                && disposition == "withheld"
8678            {
8679                asset_withdrawal_transitions.push(path.clone());
8680                continue;
8681            }
8682        }
8683        if local_record == base_record.as_ref() && !resumes_hosting {
8684            continue;
8685        }
8686        if remote_record != base_record
8687            && local_record != remote_record.as_ref()
8688            && !v2_resolution_allows_asset(resolution, base_assets.get(&path), remote, local_record)
8689        {
8690            conflicts.push(path);
8691            continue;
8692        }
8693        let Some(record) = local_record else {
8694            if let Some(remote) = remote {
8695                operations.push(json!({
8696                    "op": "asset_delete",
8697                    "path": path,
8698                    "expected": v2_asset_expected(Some(remote)),
8699                }));
8700            }
8701            continue;
8702        };
8703        let raw = if raw_present {
8704            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8705            Some(())
8706        } else {
8707            None
8708        };
8709        let op = if resumes_hosting {
8710            if !resume_local_policy {
8711                asset_policy_transitions.push(path);
8712                continue;
8713            }
8714            "asset_resume"
8715        } else {
8716            "asset_put"
8717        };
8718        operations.push(json!({
8719            "op": op,
8720            "path": path,
8721            "expected": v2_asset_expected(remote),
8722            "asset": v2_asset_value(record, disposition),
8723        }));
8724        if disposition == "hosted" {
8725            raw.expect("hosted asset was checked present");
8726            upload_sources
8727                .entry(record.sha256.clone())
8728                .or_insert_with(|| V2UploadSource {
8729                    path: path.clone(),
8730                    bytes: record.bytes,
8731                });
8732        }
8733    }
8734    if consumed_withdrawals != withdrawal_set {
8735        let missing = withdrawal_set
8736            .difference(&consumed_withdrawals)
8737            .next()
8738            .expect("different withdrawal sets have one member");
8739        return Err(LinkError::InvalidPack {
8740            message: format!(
8741                "withdrawal path `{missing}` is not a readable content or asset coordinate"
8742            ),
8743        });
8744    }
8745    if !conflicts.is_empty() {
8746        conflicts.truncate(100);
8747        return Err(LinkError::Conflict { paths: conflicts });
8748    }
8749    if !asset_policy_transitions.is_empty() {
8750        asset_policy_transitions.truncate(100);
8751        return Err(LinkError::LocalPolicyTransition {
8752            paths: asset_policy_transitions,
8753        });
8754    }
8755    if !asset_withdrawal_transitions.is_empty() {
8756        asset_withdrawal_transitions.truncate(100);
8757        return Err(LinkError::AssetWithdrawalRequired {
8758            paths: asset_withdrawal_transitions,
8759        });
8760    }
8761    let contract_phase = operations.len() > 1
8762        && operations.iter().any(|operation| {
8763            operation.get("path").and_then(Value::as_str) == Some("DB.md")
8764                && !operation
8765                    .get("op")
8766                    .and_then(Value::as_str)
8767                    .is_some_and(|kind| kind.starts_with("asset_"))
8768        });
8769    if contract_phase {
8770        if !allow_contract_phase {
8771            return Err(LinkError::RemoteAdvancedDuringSync);
8772        }
8773        operations.retain(|operation| {
8774            operation.get("path").and_then(Value::as_str) == Some("DB.md")
8775                && !operation
8776                    .get("op")
8777                    .and_then(Value::as_str)
8778                    .is_some_and(|kind| kind.starts_with("asset_"))
8779        });
8780        let contract_blobs = operations
8781            .iter()
8782            .filter_map(|operation| operation.get("blob").and_then(Value::as_str))
8783            .collect::<BTreeSet<_>>();
8784        upload_sources.retain(|sha256, _| contract_blobs.contains(sha256.as_str()));
8785    }
8786    let touched_sources = operations
8787        .iter()
8788        .filter_map(
8789            |operation| match operation.get("op").and_then(Value::as_str) {
8790                Some("put" | "put_asset_content" | "restore") => {
8791                    operation.get("path").and_then(Value::as_str)
8792                }
8793                Some("rename") => operation.get("to").and_then(Value::as_str),
8794                _ => None,
8795            },
8796        )
8797        .collect::<std::collections::BTreeSet<_>>();
8798    let withheld_links = local_view
8799        .withheld_links
8800        .iter()
8801        .filter(|link| touched_sources.contains(link.source.as_str()))
8802        .collect::<Vec<_>>();
8803    let checkout_pseudonym = v2_checkout_id(
8804        baseline
8805            .as_ref()
8806            .and_then(|current| current.checkout_id.as_deref()),
8807    )?;
8808    let checkout_id = if withheld_links.is_empty() {
8809        None
8810    } else {
8811        Some(checkout_pseudonym.clone())
8812    };
8813    if operations.is_empty() {
8814        let final_head = v2_verified_head(cfg, requested_brain)?
8815            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8816        if !same_v2_head(&head, &final_head) {
8817            return Err(LinkError::RemoteAdvancedDuringSync);
8818        }
8819        let mut final_local = v2_local_files_cached(
8820            store,
8821            Some((&local_view.policy.digest, &local_view.scan_cache)),
8822        )?;
8823        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8824        let final_assets = v2_local_asset_records(store)?;
8825        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8826        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8827            final_local.policy.keeps_home(path)
8828        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8829        let next = v2_baseline_from_head(
8830            cfg,
8831            &head,
8832            remote,
8833            remote_assets,
8834            Some(&final_local),
8835            Some(&checkout_pseudonym),
8836        )?;
8837        let split_count = next.remote_copy_remains.len();
8838        accept_v2_head(cfg, &final_head)?;
8839        if !remote_ahead {
8840            refresh_scoped_view_marker(store, &head, next.files.len())?;
8841            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8842        }
8843        return Ok(json!({
8844            "v": 2,
8845            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8846            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8847            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8848            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8849            "local_policy": {
8850                "remote_copy_remains": split_count,
8851            },
8852        }));
8853    }
8854    let includes_contract = operations
8855        .iter()
8856        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8857    let rebase = if head.pointer.is_none() || includes_contract {
8858        "strict"
8859    } else {
8860        "disjoint"
8861    };
8862    let base_value = head.pointer.as_ref().map(|pointer| {
8863        json!({
8864            "seq": pointer.seq,
8865            "commit_hash": pointer.commit_hash,
8866            "content_root": pointer.content_root,
8867            "asset_root": pointer.asset_root,
8868        })
8869    });
8870    // The mutation coordinate is stable for the exact observed base and local
8871    // operation set. A lost response can therefore be retried without a
8872    // duplicate commit/proposal even across process restarts.
8873    let entropy = format!(
8874        "{}\0{}\0{}\0{}\0{}\0{}",
8875        normalized_origin(&cfg.hub)?,
8876        head.brain_id,
8877        serde_json::to_string(&base_value).unwrap_or_default(),
8878        serde_json::to_string(&operations).unwrap_or_default(),
8879        serde_json::to_string(&withheld_links).unwrap_or_default(),
8880        checkout_id.as_deref().unwrap_or("")
8881    );
8882    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8883    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8884        total
8885            .checked_add(source.bytes)
8886            .ok_or_else(|| LinkError::PushTooLarge {
8887                detail: "v2 changed-byte total overflow".to_string(),
8888            })
8889    })?;
8890    let inline = changed_bytes <= 3 * 1024 * 1024;
8891    let inline_blobs = if inline {
8892        upload_sources
8893            .iter()
8894            .map(|(sha256, source)| {
8895                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8896                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8897                    return Err(LinkError::InvalidPack {
8898                        message: format!("local path `{}` changed before upload", source.path),
8899                    });
8900                }
8901                Ok(json!({
8902                    "sha256": sha256,
8903                    "bytes": source.bytes,
8904                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8905                }))
8906            })
8907            .collect::<LinkResult<Vec<_>>>()?
8908    } else {
8909        Vec::new()
8910    };
8911    let mut body = json!({
8912        "mutation_id": mutation_id,
8913        "base": base_value,
8914        "rebase": rebase,
8915        "reason": "dbmd sync",
8916        "operations": operations,
8917        "blobs": inline_blobs,
8918    });
8919    if !withheld_links.is_empty() {
8920        body["withheld_links"] = serde_json::to_value(&withheld_links)
8921            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8922        body["checkout_id"] =
8923            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8924    }
8925    if let Some(confirmation) = bulk_confirmation {
8926        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8927            return Err(LinkError::InvalidPack {
8928                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8929                    .to_string(),
8930            });
8931        }
8932        // A preview is bound to the exact observed head. Confirmation can
8933        // therefore never use disjoint rebase semantics, even when the normal
8934        // small-mutation path could.
8935        body["rebase"] = Value::String("strict".to_string());
8936        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8937        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8938    }
8939    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8940        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8941        for operation in &operations {
8942            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8943                return Err(invalid_feed("v2 upload operation has no kind"));
8944            };
8945            let hash = match kind {
8946                "put" | "put_asset_content" | "restore" | "rename" => {
8947                    operation.get("blob").and_then(Value::as_str)
8948                }
8949                "asset_put" | "asset_resume" => operation
8950                    .get("asset")
8951                    .and_then(|asset| asset.get("blob_sha256"))
8952                    .and_then(Value::as_str),
8953                _ => None,
8954            };
8955            let Some(hash) = hash else { continue };
8956            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8957            if kind == "rename" {
8958                for field in ["from", "to"] {
8959                    coordinates.insert(
8960                        operation
8961                            .get(field)
8962                            .and_then(Value::as_str)
8963                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8964                            .to_string(),
8965                    );
8966                }
8967            } else {
8968                let path = operation
8969                    .get("path")
8970                    .and_then(Value::as_str)
8971                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8972                coordinates.insert(if kind.starts_with("asset_") {
8973                    format!("assets/{path}")
8974                } else {
8975                    path.to_string()
8976                });
8977            }
8978        }
8979        let declarations = upload_sources
8980            .iter()
8981            .map(|(sha256, source)| {
8982                json!({
8983                    "sha256": sha256,
8984                    "bytes": source.bytes,
8985                    "coordinates": coordinates_by_hash
8986                        .get(sha256)
8987                        .into_iter()
8988                        .flatten()
8989                        .collect::<Vec<_>>(),
8990                })
8991            })
8992            .collect::<Vec<_>>();
8993        let mut references = Vec::with_capacity(upload_sources.len());
8994        let mut seen = std::collections::BTreeSet::new();
8995        let mut reserved_count = 0usize;
8996        let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8997        // A reservation's presigned URL is short-lived, so each batch's bytes
8998        // ride before the next batch is reserved. Reserving every blob first
8999        // and uploading afterwards expires the earliest URLs on a real store.
9000        for batch in batch_upload_declarations(declarations) {
9001            let batch_len = batch.len();
9002            let reserved = reserve_upload_window(
9003                cfg,
9004                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
9005                &json!({ "blobs": batch }),
9006                "prepare v2 changed-byte uploads",
9007            )?;
9008            let items = reserved
9009                .get("uploads")
9010                .and_then(Value::as_array)
9011                .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
9012            if items.len() != batch_len {
9013                return Err(invalid_feed(
9014                    "v2 upload reservation response changed the requested set",
9015                ));
9016            }
9017            reserved_count += items.len();
9018            for item in items {
9019                let sha256 = item
9020                    .get("sha256")
9021                    .and_then(Value::as_str)
9022                    .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
9023                let source = upload_sources
9024                    .get(sha256)
9025                    .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
9026                let declared_bytes = item
9027                    .get("bytes")
9028                    .and_then(Value::as_u64)
9029                    .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
9030                let reservation_id = item
9031                    .get("reservation_id")
9032                    .and_then(Value::as_str)
9033                    .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
9034                let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
9035                    invalid_feed("v2 upload reservation has no coordinate binding")
9036                })?;
9037                let returned_coordinates = item
9038                    .get("coordinates")
9039                    .and_then(Value::as_array)
9040                    .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
9041                if declared_bytes != source.bytes
9042                    || !crate::ulid::is_ulid(reservation_id)
9043                    || !seen.insert(sha256.to_string())
9044                    || returned_coordinates.len() != expected_coordinates.len()
9045                    || returned_coordinates
9046                        .iter()
9047                        .zip(expected_coordinates)
9048                        .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
9049                {
9050                    return Err(invalid_feed("v2 upload reservation item is inconsistent"));
9051                }
9052                match item.get("status").and_then(Value::as_str) {
9053                    Some("upload") => {
9054                        let url = item
9055                            .get("url")
9056                            .and_then(Value::as_str)
9057                            .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
9058                        pending_uploads.push(V2PendingUpload {
9059                            url: url.to_string(),
9060                            headers: item.get("headers").cloned().unwrap_or(Value::Null),
9061                            sha256: sha256.to_string(),
9062                            source,
9063                        });
9064                    }
9065                    Some("already_present") => {}
9066                    _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
9067                }
9068                references.push(json!({
9069                    "sha256": sha256,
9070                    "bytes": source.bytes,
9071                    "reservation_id": reservation_id,
9072                }));
9073            }
9074            // Stage this batch's bytes concurrently. Sequential PUTs move a real
9075            // store at a few objects a second, which both takes hours and pushes
9076            // the batch past its reservation's upload window; the object store
9077            // is content-addressed and precondition-guarded, so parallel writes
9078            // stay safe.
9079            upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
9080            pending_uploads.clear();
9081        }
9082        if reserved_count != upload_sources.len() {
9083            return Err(invalid_feed(
9084                "v2 upload reservation response changed the requested set",
9085            ));
9086        }
9087        body["blobs"] = Value::Array(references);
9088    }
9089    stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
9090    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
9091    let mut candidate_hub_signer: Option<String> = None;
9092    let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
9093    let bulk_preview_required = !(200..300).contains(&response.status)
9094        && response.body.as_ref().is_some_and(|value| {
9095            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
9096                || value
9097                    .get("details")
9098                    .and_then(|details| details.get("code"))
9099                    .and_then(Value::as_str)
9100                    == Some("bulk_preview_required")
9101        });
9102    if bulk_preview_required && bulk_confirmation.is_none() {
9103        body["rebase"] = Value::String("strict".to_string());
9104        body["preview_only"] = Value::Bool(true);
9105        let preview = ensure_ok(
9106            request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
9107            "v2 bulk preview",
9108        )?;
9109        let preview_code = preview.get("code").and_then(Value::as_str);
9110        let required = preview.get("required").and_then(Value::as_bool);
9111        if preview.get("v").and_then(Value::as_u64) != Some(2)
9112            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
9113            || !matches!(
9114                preview_code,
9115                Some("bulk_preview_created" | "bulk_preview_not_required")
9116            )
9117            || required.is_none()
9118        {
9119            return Err(invalid_feed(
9120                "bulk preview response is not bound to the requested mutation",
9121            ));
9122        }
9123        if required == Some(true) {
9124            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
9125            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
9126            if preview_code != Some("bulk_preview_created")
9127                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
9128                || preview_digest.is_none_or(|value| !is_sha256(value))
9129                || preview.get("expires_at").and_then(Value::as_str).is_none()
9130                || !preview.get("impact").is_some_and(Value::is_object)
9131            {
9132                return Err(invalid_feed("bulk preview receipt is malformed"));
9133            }
9134            return Err(LinkError::BulkPreviewRequired { preview });
9135        }
9136        if preview_code != Some("bulk_preview_not_required") {
9137            return Err(invalid_feed("bulk preview requirement is inconsistent"));
9138        }
9139        // The rolling activity window can expire between the refusal and
9140        // preview. Retry once, still pinned strictly to the observed head.
9141        body.as_object_mut()
9142            .expect("v2 commit request is an object")
9143            .remove("preview_only");
9144        response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
9145    }
9146    let mut result = ensure_ok(response, "v2 sync push")?;
9147    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
9148        if let Some(object) = result.as_object_mut() {
9149            object.insert(
9150                "sync_status".to_string(),
9151                Value::String("proposal_pending".to_string()),
9152            );
9153        }
9154        return Ok(result);
9155    }
9156    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
9157        let request_id = result
9158            .get("request_id")
9159            .and_then(Value::as_str)
9160            .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
9161            .to_string();
9162        let challenge = result
9163            .get("signing_challenge")
9164            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
9165        let mut expected_candidate = remote.clone();
9166        let mut expected_candidate_assets = remote_assets.clone();
9167        apply_generated_v2_operations(
9168            &operations,
9169            &local_assets,
9170            &mut expected_candidate,
9171            &mut expected_candidate_assets,
9172        )?;
9173        verify_v2_markdown_asset_content_bindings(&expected_candidate, &expected_candidate_assets)?;
9174        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
9175            cfg,
9176            &head,
9177            &expected_candidate,
9178            &expected_candidate_assets,
9179            &mutation_id,
9180            &v2_signed_request_view(&body, &operations),
9181            challenge,
9182        )?;
9183        body["signing_challenge_id"] = Value::String(challenge_id);
9184        body["signature_base64url"] = Value::String(signature);
9185        candidate_hub_signer = Some(actor_signer);
9186        result = ensure_ok(
9187            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
9188            "v2 self-custody commit",
9189        )?;
9190    }
9191    let refreshed = v2_verified_head(cfg, requested_brain)?
9192        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
9193    if candidate_hub_signer
9194        .as_ref()
9195        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
9196    {
9197        return Err(invalid_feed(
9198            "self-custody actor signer differs from the committed hub pointer signer",
9199        ));
9200    }
9201    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
9202    if refreshed
9203        .pointer
9204        .as_ref()
9205        .map(|pointer| pointer.commit_hash.as_str())
9206        != accepted_hash
9207    {
9208        return Err(LinkError::RemoteAdvancedDuringSync);
9209    }
9210    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
9211    let rebased = result
9212        .get("rebased")
9213        .and_then(Value::as_bool)
9214        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
9215    let (refreshed_files, refreshed_assets) = if rebased {
9216        (
9217            files_for_v2_view(
9218                &refreshed,
9219                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
9220            ),
9221            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
9222        )
9223    } else {
9224        let asset_changed = apply_generated_v2_operations(
9225            &operations,
9226            &local_assets,
9227            &mut remote,
9228            &mut remote_assets,
9229        )?;
9230        let assets = if asset_changed {
9231            // Asset leaf hashes are server-generated signed coordinates; only
9232            // asset-changing commits need this usually tiny post-commit read.
9233            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
9234        } else {
9235            remote_assets
9236        };
9237        (remote, assets)
9238    };
9239    verify_v2_markdown_asset_content_bindings(&refreshed_files, &refreshed_assets)?;
9240    let mut final_local = v2_local_files_cached(
9241        store,
9242        Some((&local_view.policy.digest, &local_view.scan_cache)),
9243    )?;
9244    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
9245    let final_assets = v2_local_asset_records(store)?;
9246    let local_dirty = final_local.riding != local_view.riding
9247        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
9248            final_local.policy.keeps_home(path)
9249        })
9250        || final_assets != local_assets
9251        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
9252    // A required DB.md-only first phase can hand its already verified remote
9253    // manifests and local stat/hash view directly to the second phase. This is
9254    // an in-memory optimization only; v2_sync_push re-verifies the hosted head
9255    // under the operation lock, upload sources are re-opened before use, and
9256    // the ordinary final scan still detects concurrent local edits.
9257    let contract_handoff = contract_phase.then(|| V2PulledSnapshot {
9258        report: PullReport {
9259            brain: refreshed.brain_id.clone(),
9260            slug: requested_brain.to_string(),
9261            head_seq: refreshed.pointer.as_ref().map_or(0, |pointer| pointer.seq),
9262            files: refreshed_files.len(),
9263            dest: store.root.to_string_lossy().into_owned(),
9264            extra_local: Vec::new(),
9265            sync_status: "contract_phase".to_string(),
9266        },
9267        head: refreshed.clone(),
9268        files: refreshed_files.clone(),
9269        assets: refreshed_assets.clone(),
9270        local: final_local.clone(),
9271        local_assets: final_assets.clone(),
9272    });
9273    let next = v2_baseline_from_head(
9274        cfg,
9275        &refreshed,
9276        refreshed_files,
9277        refreshed_assets,
9278        Some(&final_local),
9279        Some(&checkout_pseudonym),
9280    )?;
9281    let split_count = next.remote_copy_remains.len();
9282    accept_v2_head(cfg, &refreshed)?;
9283    if !local_dirty {
9284        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
9285    }
9286    // The verified accepted head remains the correct three-way base when a
9287    // local writer races the final scan. v2_baseline_from_head kept cache
9288    // entries only for exact local/remote matches, so saving here neither
9289    // hides those edits nor trusts their stale hashes.
9290    save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
9291    if let Some(object) = result.as_object_mut() {
9292        object.insert(
9293            "local_policy".to_string(),
9294            json!({ "remote_copy_remains": split_count }),
9295        );
9296        object.insert(
9297            "sync_status".to_string(),
9298            Value::String(if local_dirty {
9299                "remote_committed_local_dirty".to_string()
9300            } else {
9301                "synced".to_string()
9302            }),
9303        );
9304    }
9305    if contract_phase && result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9306        let contract_receipt = result;
9307        // The hub deliberately requires DB.md authority changes to be a sole,
9308        // strict-head mutation. Release the checkout-wide operation lock, then
9309        // recompute the remaining delta from the independently verified new
9310        // head. No local byte or permission conclusion is carried across.
9311        drop(_operation_lock);
9312        let mut remaining = v2_sync_push(
9313            cfg,
9314            requested_brain,
9315            store,
9316            refreshed,
9317            V2SyncPushOptions {
9318                resume_local_policy,
9319                bulk_confirmation,
9320                resolution,
9321                pulled: contract_handoff,
9322                withdrawal_paths,
9323                withdrawal_reason,
9324                allow_contract_phase: false,
9325            },
9326        )?;
9327        if let Some(object) = remaining.as_object_mut() {
9328            object.insert("contract_phase".to_string(), contract_receipt);
9329        }
9330        return Ok(remaining);
9331    }
9332    if contract_phase {
9333        if let Some(object) = result.as_object_mut() {
9334            object.insert("contract_phase_pending".to_string(), Value::Bool(true));
9335        }
9336    }
9337    Ok(result)
9338}
9339
9340/// Negotiate v2 and send only local changes. A v1 hub retains the existing
9341/// whole-snapshot behavior until its brain advertises the new profile.
9342pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
9343    sync_push_incremental_with_policy(cfg, brain, store, false)
9344}
9345
9346/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
9347/// path that became eligible because local policy was weakened or removed.
9348pub fn sync_push_incremental_with_policy(
9349    cfg: &HubConfig,
9350    brain: &str,
9351    store: &Store,
9352    resume_local_policy: bool,
9353) -> LinkResult<Value> {
9354    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
9355}
9356
9357/// Incremental push with both local-policy adoption and an optional exact
9358/// permissioned bulk-preview confirmation.
9359pub fn sync_push_incremental_with_options(
9360    cfg: &HubConfig,
9361    brain: &str,
9362    store: &Store,
9363    resume_local_policy: bool,
9364    bulk_confirmation: Option<&V2BulkConfirmation>,
9365) -> LinkResult<Value> {
9366    sync_push_incremental_with_controls(
9367        cfg,
9368        brain,
9369        store,
9370        resume_local_policy,
9371        bulk_confirmation,
9372        &[],
9373        None,
9374    )
9375}
9376
9377/// Incremental push with explicit permissioned current-host withdrawal.
9378pub fn sync_push_incremental_with_controls(
9379    cfg: &HubConfig,
9380    brain: &str,
9381    store: &Store,
9382    resume_local_policy: bool,
9383    bulk_confirmation: Option<&V2BulkConfirmation>,
9384    withdrawal_paths: &[String],
9385    withdrawal_reason: Option<&str>,
9386) -> LinkResult<Value> {
9387    require_safe_ref(brain)?;
9388    if let Some(head) = v2_verified_head(cfg, brain)? {
9389        return v2_sync_push(
9390            cfg,
9391            brain,
9392            store,
9393            head,
9394            V2SyncPushOptions {
9395                resume_local_policy,
9396                bulk_confirmation,
9397                resolution: None,
9398                pulled: None,
9399                withdrawal_paths,
9400                withdrawal_reason,
9401                allow_contract_phase: true,
9402            },
9403        );
9404    }
9405    if !withdrawal_paths.is_empty() {
9406        return Err(LinkError::InvalidPack {
9407            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
9408        });
9409    }
9410    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
9411}
9412
9413/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
9414/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
9415/// a damaged `DB.md` makes strict store-open fail.
9416pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
9417    require_safe_ref(brain)?;
9418    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
9419}
9420
9421/// Rebind one verified incremental baseline after its checkout directory was
9422/// moved without changing the checkout bytes. Baselines deliberately live in
9423/// private trust state and are path-bound, so callers performing an atomic
9424/// staged publish must cross this explicit boundary instead of silently
9425/// turning the moved checkout into a baseline-less clone.
9426pub fn relocate_v2_sync_baseline(
9427    cfg: &HubConfig,
9428    brain: &str,
9429    from: &Path,
9430    to: &Path,
9431) -> LinkResult<Value> {
9432    require_hardened_filesystem("verified link.md v2 baseline relocation")?;
9433    require_safe_ref(brain)?;
9434    if !crate::ulid::is_ulid(brain) {
9435        return Err(invalid_feed(
9436            "v2 baseline relocation requires the canonical brain id",
9437        ));
9438    }
9439    let from_absolute = if from.is_absolute() {
9440        from.to_path_buf()
9441    } else {
9442        std::env::current_dir()?.join(from)
9443    };
9444    let to_absolute = if to.is_absolute() {
9445        to.to_path_buf()
9446    } else {
9447        std::env::current_dir()?.join(to)
9448    };
9449    let source_name = v2_baseline_name(cfg, brain, &from_absolute)?;
9450    let target_name = v2_baseline_name(cfg, brain, &to_absolute)?;
9451    if source_name == target_name {
9452        return Err(invalid_feed(
9453            "v2 baseline relocation source and destination are the same checkout",
9454        ));
9455    }
9456    match std::fs::symlink_metadata(&from_absolute) {
9457        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
9458        Err(error) => return Err(error.into()),
9459        Ok(_) => {
9460            return Err(LinkError::InvalidPack {
9461                message: "the old checkout still exists; move it before relocating its baseline"
9462                    .to_string(),
9463            })
9464        }
9465    }
9466    let store = Store::open_strict(&to_absolute).map_err(|error| LinkError::InvalidPack {
9467        message: format!("relocated checkout is not a valid db.md store: {error}"),
9468    })?;
9469    let _operation_lock = lock_v2_sync_operation(cfg, brain)?;
9470
9471    #[cfg(any(unix, windows))]
9472    {
9473        let directory = open_trust_dir(cfg)?;
9474        let mut lock_names = [source_name.as_str(), target_name.as_str()];
9475        lock_names.sort();
9476        let _locks = lock_names
9477            .iter()
9478            .map(|name| lock_trust_name(&directory, name))
9479            .collect::<LinkResult<Vec<_>>>()?;
9480        let source = load_v2_baseline_in(cfg, brain, &directory, &source_name)?;
9481        let target = load_v2_baseline_in(cfg, brain, &directory, &target_name)?;
9482        let (baseline, already_relocated) = match (source, target) {
9483            (Some(source), None) => (source, false),
9484            (None, Some(target)) => (target, true),
9485            (Some(_), Some(_)) => {
9486                return Err(LinkError::InvalidPack {
9487                    message: "both old and new checkout paths already have private sync baselines"
9488                        .to_string(),
9489                })
9490            }
9491            (None, None) => {
9492                return Err(LinkError::InvalidPack {
9493                    message: "the old checkout has no verified incremental baseline to relocate"
9494                        .to_string(),
9495                })
9496            }
9497        };
9498
9499        let mut local = v2_local_files(&store)?;
9500        if baseline.view_kind.as_deref() == Some("scoped") {
9501            let expected = baseline
9502                .projection_sha256
9503                .as_deref()
9504                .ok_or_else(|| invalid_feed("scoped baseline has no projection hash"))?;
9505            if local.riding.get("DB.md").map(|value| value.0.as_str()) != Some(expected) {
9506                return Err(LinkError::ScopedProjectionModified);
9507            }
9508            local.riding.remove("DB.md");
9509        }
9510        if baseline.local_policy_digest.as_deref() != Some(local.policy.digest.as_str())
9511            || !v2_riding_matches_remote(&local.riding, &baseline.files, |path| {
9512                local.policy.keeps_home(path)
9513            })
9514            || !v2_asset_records_match_remote(&v2_local_asset_records(&store)?, &baseline.assets)
9515        {
9516            return Err(LinkError::InvalidPack {
9517                message: "the moved checkout no longer matches its verified incremental baseline"
9518                    .to_string(),
9519            });
9520        }
9521        if !already_relocated {
9522            crate::fsx::rename_beneath(
9523                &directory,
9524                Path::new(&source_name),
9525                Path::new(&target_name),
9526            )?;
9527            directory.sync_all()?;
9528        }
9529        Ok(json!({
9530            "v": 2,
9531            "class": "checkout_baseline_relocated",
9532            "brain": baseline.brain,
9533            "from": from_absolute,
9534            "to": to_absolute,
9535            "headSeq": baseline.head_seq.unwrap_or(0),
9536            "commitHash": baseline.commit_hash,
9537            "moved": !already_relocated,
9538        }))
9539    }
9540
9541    #[cfg(not(any(unix, windows)))]
9542    Err(LinkError::UnsupportedPlatform {
9543        operation: "verified link.md v2 baseline relocation",
9544    })
9545}
9546
9547#[cfg(windows)]
9548fn legacy_sync_push_incremental(
9549    _cfg: &HubConfig,
9550    _brain: &str,
9551    _store: &Store,
9552    _resume_local_policy: bool,
9553    _bulk_confirmation: Option<&V2BulkConfirmation>,
9554) -> LinkResult<Value> {
9555    Err(LinkError::UnsupportedPlatform {
9556        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
9557    })
9558}
9559
9560#[cfg(not(windows))]
9561fn legacy_sync_push_incremental(
9562    cfg: &HubConfig,
9563    brain: &str,
9564    store: &Store,
9565    resume_local_policy: bool,
9566    bulk_confirmation: Option<&V2BulkConfirmation>,
9567) -> LinkResult<Value> {
9568    if resume_local_policy || bulk_confirmation.is_some() {
9569        return Err(LinkError::InvalidPack {
9570            message: "v2 sync options require a link.md v2 brain".to_string(),
9571        });
9572    }
9573    let files = collect_push_files(store)?;
9574    sync_push(cfg, brain, &files)
9575}
9576
9577/// One explicit resolution for a previously preserved private conflict bundle.
9578#[derive(Debug, Clone)]
9579pub enum V2ConflictChoice {
9580    KeepLocal,
9581    TakeRemote,
9582    From(PathBuf),
9583}
9584
9585fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
9586    if !crate::ulid::is_ulid(bundle) {
9587        return Err(LinkError::InvalidPack {
9588            message: "conflict bundle must be a lowercase ULID".to_string(),
9589        });
9590    }
9591    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
9592    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
9593        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
9594    if plan.v != 2
9595        || plan.class != "content_resolution_required"
9596        || plan.bundle != bundle
9597        || !crate::ulid::is_ulid(&plan.brain)
9598        || plan.files.is_empty()
9599        || plan.files.len() > 100
9600        || plan.files.iter().any(|file| {
9601            crate::linkmd_v2::normalize_path(&file.path).is_err()
9602                || [&file.base, &file.local, &file.remote]
9603                    .into_iter()
9604                    .any(|coordinate| {
9605                        coordinate
9606                            .sha256
9607                            .as_deref()
9608                            .is_some_and(|hash| !is_sha256(hash))
9609                            || coordinate.file.as_deref().is_some_and(|name| {
9610                                name.starts_with('/')
9611                                    || name
9612                                        .split('/')
9613                                        .any(|part| part.is_empty() || part == "." || part == "..")
9614                            })
9615                    })
9616        })
9617    {
9618        return Err(invalid_feed("private conflict plan failed validation"));
9619    }
9620    Ok(plan)
9621}
9622
9623/// Inspect or prune private conflict-control state. Ordinary pruning removes
9624/// only expired completed bundles and interrupted bundles with no `plan.json`;
9625/// corrupt completed plans fail closed. `all` is an explicit local discard of
9626/// every well-addressed bundle and never changes hosted brain data.
9627pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
9628    require_hardened_filesystem("private conflict maintenance")?;
9629    if all && !prune {
9630        return Err(LinkError::InvalidPack {
9631            message: "discarding all conflict bundles requires prune=true".to_string(),
9632        });
9633    }
9634    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9635        message: format!("conflict checkout is not a valid db.md store: {error}"),
9636    })?;
9637    let _transaction = store.transaction()?;
9638    let root = Path::new(".dbmd/conflicts");
9639    let names = match store.directory_names(root) {
9640        Ok(names) => names,
9641        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
9642        Err(error) => return Err(error.into()),
9643    };
9644    let now = SystemTime::now()
9645        .duration_since(UNIX_EPOCH)
9646        .unwrap_or_default()
9647        .as_secs();
9648    let mut bundles = Vec::new();
9649    let mut pruned = 0_u64;
9650    for name in names {
9651        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
9652            continue;
9653        };
9654        let plan_path = v2_conflict_relative(bundle, "plan.json");
9655        let plan_exists = store.regular_file_exists(&plan_path)?;
9656        let expired = if plan_exists {
9657            match load_v2_conflict_plan(&store, bundle) {
9658                Ok(plan) => plan.expires_unix < now,
9659                Err(error) if all => {
9660                    let _ = error;
9661                    true
9662                }
9663                Err(error) => return Err(error),
9664            }
9665        } else {
9666            true
9667        };
9668        if prune && (all || expired) {
9669            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9670            pruned += 1;
9671            continue;
9672        }
9673        bundles.push(json!({
9674            "bundle": bundle,
9675            "complete": plan_exists,
9676            "expired": expired,
9677        }));
9678    }
9679    Ok(json!({
9680        "v": 2,
9681        "class": "private_conflict_state",
9682        "bundles": bundles.len(),
9683        "pruned": pruned,
9684        "items": bundles,
9685    }))
9686}
9687
9688/// Resolve one exact private conflict plan. Drift never lowers a precondition:
9689/// the remote head/view and every original local coordinate are rechecked
9690/// before either an explicit local install or a fresh normal commit.
9691pub fn sync_resolve_conflict(
9692    cfg: &HubConfig,
9693    checkout: &Path,
9694    bundle: &str,
9695    choice: V2ConflictChoice,
9696    bulk_confirmation: Option<&V2BulkConfirmation>,
9697) -> LinkResult<Value> {
9698    require_hardened_filesystem("conflict resolution")?;
9699    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9700        message: format!("conflict checkout is not a valid db.md store: {error}"),
9701    })?;
9702    let plan = load_v2_conflict_plan(&store, bundle)?;
9703    if plan.origin != normalized_origin(&cfg.hub)? {
9704        return Err(invalid_feed(
9705            "conflict bundle belongs to another hub origin",
9706        ));
9707    }
9708    let now = SystemTime::now()
9709        .duration_since(UNIX_EPOCH)
9710        .unwrap_or_default()
9711        .as_secs();
9712    if now > plan.expires_unix {
9713        return Err(LinkError::InvalidPack {
9714            message: "conflict bundle expired; rerun sync to obtain current coordinates"
9715                .to_string(),
9716        });
9717    }
9718    let head = v2_verified_head(cfg, &plan.brain)?
9719        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
9720    let pointer = head.pointer.as_ref();
9721    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
9722        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
9723        || pointer.and_then(|value| value.content_root.as_deref())
9724            != plan.remote_content_root.as_deref()
9725        || head.view_kind != plan.view_kind
9726        || head.view_revision != plan.view_revision
9727    {
9728        return Err(LinkError::RemoteAdvancedDuringSync);
9729    }
9730
9731    // A resolution never overwrites a post-bundle local edit.
9732    for file in &plan.files {
9733        let actual = match store.regular_file_exists(Path::new(&file.path))? {
9734            true => Some(content_sha256(&store.read_bounded(
9735                Path::new(&file.path),
9736                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
9737            )?)),
9738            false => None,
9739        };
9740        if actual.as_deref() != file.local.sha256.as_deref() {
9741            return Err(LinkError::InvalidPack {
9742                message: format!(
9743                    "local conflict path `{}` changed after the bundle was created",
9744                    file.path
9745                ),
9746            });
9747        }
9748    }
9749
9750    let from_source = match &choice {
9751        V2ConflictChoice::From(source) => Some(source.clone()),
9752        _ => None,
9753    };
9754    let result = match choice {
9755        V2ConflictChoice::TakeRemote => {
9756            if bulk_confirmation.is_some() {
9757                return Err(LinkError::InvalidPack {
9758                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
9759                });
9760            }
9761            // Re-read the exact current manifest so every selected remote byte
9762            // retains its signed inclusion proof. The private bundle is useful
9763            // for inspection, but is never promoted into live state as trust.
9764            let current_remote =
9765                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
9766            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
9767            let selected = plan
9768                .files
9769                .iter()
9770                .map(|file| file.path.clone())
9771                .collect::<std::collections::BTreeSet<_>>();
9772            serde_json::to_value(
9773                v2_sync_pull_with_resolution(
9774                    cfg,
9775                    &plan.brain,
9776                    head,
9777                    Some(checkout),
9778                    Some(&selected),
9779                )?
9780                .report,
9781            )
9782            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
9783        }
9784        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
9785            if let Some(source) = from_source.as_ref() {
9786                if plan.files.len() != 1 {
9787                    return Err(LinkError::InvalidPack {
9788                        message: "--from requires a bundle with exactly one conflict".to_string(),
9789                    });
9790                }
9791                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
9792                if std::str::from_utf8(&candidate).is_err() {
9793                    return Err(LinkError::NotUtf8 {
9794                        path: source.display().to_string(),
9795                    });
9796                }
9797                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
9798            }
9799            let refreshed_store =
9800                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9801                    message: format!("resolved checkout is not a valid db.md store: {error}"),
9802                })?;
9803            let mut overrides = std::collections::BTreeMap::new();
9804            for file in &plan.files {
9805                let selected_local = match refreshed_store
9806                    .regular_file_exists(Path::new(&file.path))?
9807                {
9808                    true => Some(content_sha256(
9809                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
9810                    )),
9811                    false => None,
9812                };
9813                overrides.insert(
9814                    file.path.clone(),
9815                    V2ResolutionOverride {
9816                        expected_remote: file.remote.sha256.clone(),
9817                        selected_local,
9818                    },
9819                );
9820            }
9821            v2_sync_push(
9822                cfg,
9823                &plan.brain,
9824                &refreshed_store,
9825                head,
9826                V2SyncPushOptions {
9827                    resume_local_policy: true,
9828                    bulk_confirmation,
9829                    resolution: Some(&overrides),
9830                    pulled: None,
9831                    withdrawal_paths: &[],
9832                    withdrawal_reason: None,
9833                    allow_contract_phase: true,
9834                },
9835            )?
9836        }
9837    };
9838
9839    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9840        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9841            message: format!("resolved checkout is not a valid db.md store: {error}"),
9842        })?;
9843        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9844    }
9845    Ok(json!({
9846        "v": 2,
9847        "class": "auto_converged",
9848        "bundle": bundle,
9849        "receipt": result,
9850    }))
9851}
9852
9853/// Converge one established permissioned-v2 checkout in both directions.
9854///
9855/// The pull half first performs the ordinary three-way conflict check and
9856/// crash-safe install against the accepted baseline. The push half then opens
9857/// that exact installed checkout, takes its local transaction guard, computes
9858/// a fresh three-way delta, and submits it with mandatory preconditions. A
9859/// remote movement between halves is therefore a normal rebase/conflict input,
9860/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
9861/// its whole-pack replacement semantics are never presented as granular
9862/// convergence.
9863pub fn sync_converge(
9864    cfg: &HubConfig,
9865    brain: &str,
9866    checkout: &Path,
9867    resume_local_policy: bool,
9868) -> LinkResult<Value> {
9869    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9870}
9871
9872/// Bidirectional convergence with optional exact bulk-preview confirmation.
9873pub fn sync_converge_with_options(
9874    cfg: &HubConfig,
9875    brain: &str,
9876    checkout: &Path,
9877    resume_local_policy: bool,
9878    bulk_confirmation: Option<&V2BulkConfirmation>,
9879) -> LinkResult<Value> {
9880    sync_converge_with_controls(
9881        cfg,
9882        brain,
9883        checkout,
9884        resume_local_policy,
9885        bulk_confirmation,
9886        &[],
9887        None,
9888    )
9889}
9890
9891/// Bidirectional convergence with optional exact withdrawal intents.
9892pub fn sync_converge_with_controls(
9893    cfg: &HubConfig,
9894    brain: &str,
9895    checkout: &Path,
9896    resume_local_policy: bool,
9897    bulk_confirmation: Option<&V2BulkConfirmation>,
9898    withdrawal_paths: &[String],
9899    withdrawal_reason: Option<&str>,
9900) -> LinkResult<Value> {
9901    require_hardened_filesystem("bidirectional sync")?;
9902    require_safe_ref(brain)?;
9903    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9904        message:
9905            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9906                .to_string(),
9907    })?;
9908    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9909    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9910        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9911    })?;
9912    let _transaction = store.transaction()?;
9913    let pulled_report = pulled.report.clone();
9914    let pulled_head = pulled.head.clone();
9915    let mut result = v2_sync_push(
9916        cfg,
9917        brain,
9918        &store,
9919        pulled_head,
9920        V2SyncPushOptions {
9921            resume_local_policy,
9922            bulk_confirmation,
9923            resolution: None,
9924            pulled: Some(pulled),
9925            withdrawal_paths,
9926            withdrawal_reason,
9927            allow_contract_phase: true,
9928        },
9929    )?;
9930    if let Some(object) = result.as_object_mut() {
9931        object.insert("pulled_files".to_string(), json!(pulled_report.files));
9932        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9933        object.insert(
9934            "mode".to_string(),
9935            Value::String("bidirectional".to_string()),
9936        );
9937    }
9938    Ok(result)
9939}
9940
9941/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
9942/// exported path is safety-gated before it touches disk; files are written
9943/// atomically; nothing local is ever deleted (locals the export lacks are
9944/// *reported* in `extra_local` instead). Returns the report; rebuilding the
9945/// local index catalog afterwards is the caller's (cheap, optional) step.
9946pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9947    require_hardened_filesystem("sync pull")?;
9948    require_safe_ref(brain)?;
9949    if let Some(head) = v2_verified_head(cfg, brain)? {
9950        return v2_sync_pull(cfg, brain, head, out);
9951    }
9952    legacy_sync_pull(cfg, brain, out)
9953}
9954
9955#[cfg(windows)]
9956fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9957    Err(LinkError::UnsupportedPlatform {
9958        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9959    })
9960}
9961
9962#[cfg(not(windows))]
9963fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9964    let remote = verified_remote_head(cfg, brain, false)?;
9965    if !remote.head.verified {
9966        return Err(invalid_feed(
9967            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9968        ));
9969    }
9970    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9971    let path = format!(
9972        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9973        remote.head.seq
9974    );
9975    let body = ensure_ok(
9976        request(cfg, "GET", &path, None, Auth::Required)?,
9977        "sync pull",
9978    )?;
9979    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9980        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9981    {
9982        return Err(invalid_feed(
9983            "export response is not bound to the verified snapshot token",
9984        ));
9985    }
9986
9987    let remote_slug = body
9988        .get("slug")
9989        .and_then(Value::as_str)
9990        .filter(|slug| is_safe_slug(slug));
9991    let slug = remote_slug
9992        .or_else(|| is_safe_slug(brain).then_some(brain))
9993        .unwrap_or("brain")
9994        .to_string();
9995    let brain_id = body
9996        .get("brain")
9997        .and_then(Value::as_str)
9998        .unwrap_or(&remote.head.brain)
9999        .to_string();
10000    if brain_id != remote.head.brain {
10001        return Err(invalid_feed(
10002            "export response names a different brain than the verified head",
10003        ));
10004    }
10005    let head_seq = remote.head.seq;
10006    let dest: PathBuf = match out {
10007        Some(p) => p.to_path_buf(),
10008        None => PathBuf::from(&slug),
10009    };
10010    let entries = if head_seq == 0 {
10011        let files = body
10012            .get("files")
10013            .and_then(Value::as_array)
10014            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
10015        if !files.is_empty() || body.get("url").is_some() {
10016            return Err(invalid_feed(
10017                "empty signed feed cannot authorize non-empty exported content",
10018            ));
10019        }
10020        Vec::new()
10021    } else {
10022        let signed_head = remote
10023            .head_entry
10024            .as_ref()
10025            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
10026        let expected = &signed_head.entry.pack_sha256;
10027        if !is_sha256(expected) {
10028            return Err(invalid_feed(
10029                "signed head carries an invalid snapshot pack digest",
10030            ));
10031        }
10032        if let Some(url) = body.get("url").and_then(Value::as_str) {
10033            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
10034                return Err(invalid_feed(
10035                    "export pack digest does not match the signed head entry",
10036                ));
10037            }
10038            let bytes = get_presigned(cfg, url)?;
10039            let actual = format!("{:x}", Sha256::digest(&bytes));
10040            if actual != *expected {
10041                return Err(LinkError::InvalidPack {
10042                    message: "downloaded pack does not match the signed snapshot digest"
10043                        .to_string(),
10044                });
10045            }
10046            let entries = parse_store_pack(bytes)?;
10047            if signed_head.entry.kind == "push" {
10048                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
10049            }
10050            entries
10051        } else {
10052            if signed_head.entry.kind != "push" {
10053                return Err(invalid_feed(
10054                    "delta snapshots must export the exact signed pack",
10055                ));
10056            }
10057            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
10058                invalid_feed("verified snapshot export carried neither a pack nor files")
10059            })?;
10060            let mut entries = Vec::with_capacity(files.len());
10061            for file in files {
10062                let path = file
10063                    .get("path")
10064                    .and_then(Value::as_str)
10065                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
10066                let content = file
10067                    .get("content")
10068                    .and_then(Value::as_str)
10069                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
10070                entries.push((path.to_string(), content.as_bytes().to_vec()));
10071            }
10072            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
10073            entries
10074        }
10075    };
10076
10077    // Gate the complete manifest before the first filesystem mutation.
10078    let mut seen = std::collections::HashSet::new();
10079    for (path, _) in &entries {
10080        if !safe_store_rel_path(path) {
10081            return Err(LinkError::UnsafePath { path: path.clone() });
10082        }
10083        if !seen.insert(path) {
10084            return Err(LinkError::InvalidPack {
10085                message: format!("duplicate path `{path}`"),
10086            });
10087        }
10088    }
10089    // Compute divergence against the still-live tree. The staged clone keeps
10090    // these extra locals byte-for-byte while overlaying the signed snapshot.
10091    let pulled: std::collections::BTreeSet<&str> =
10092        entries.iter().map(|(p, _)| p.as_str()).collect();
10093    let mut extra_local = Vec::new();
10094    if let Ok(store) = Store::open(&dest) {
10095        if let Ok(walked) = store.walk() {
10096            for rel in walked {
10097                let rel_str = rel.to_string_lossy().replace('\\', "/");
10098                if !pulled.contains(rel_str.as_str()) {
10099                    extra_local.push(rel_str);
10100                }
10101            }
10102        }
10103    }
10104    #[cfg(unix)]
10105    install_pulled_snapshot(&dest, &entries)?;
10106
10107    Ok(PullReport {
10108        brain: brain_id,
10109        slug,
10110        head_seq,
10111        files: entries.len(),
10112        dest: dest.to_string_lossy().into_owned(),
10113        extra_local,
10114        sync_status: "synced".to_string(),
10115    })
10116}
10117
10118#[cfg(unix)]
10119fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
10120    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
10121        path: display.to_string(),
10122    })
10123}
10124
10125#[cfg(unix)]
10126fn open_dir_at(
10127    parent: std::os::fd::RawFd,
10128    name: &std::ffi::CStr,
10129    display: &str,
10130) -> LinkResult<std::fs::File> {
10131    use std::os::fd::FromRawFd as _;
10132    let fd = unsafe {
10133        libc::openat(
10134            parent,
10135            name.as_ptr(),
10136            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10137        )
10138    };
10139    if fd < 0 {
10140        return Err(LinkError::UnsafePath {
10141            path: display.to_string(),
10142        });
10143    }
10144    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
10145}
10146
10147/// Open (and, where absent, create) a directory path without following a
10148/// symlink in any component. The returned directory capability remains bound
10149/// to the opened inode even if an attacker renames or replaces an ancestor.
10150#[cfg(unix)]
10151fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
10152    use std::os::fd::AsRawFd as _;
10153
10154    // macOS exposes a few system-owned compatibility symlinks at the root.
10155    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
10156    // caller-controlled ancestor.
10157    #[cfg(target_os = "macos")]
10158    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
10159        .into_iter()
10160        .find_map(|(alias, real)| {
10161            path.strip_prefix(alias)
10162                .ok()
10163                .map(|rest| Path::new(real).join(rest))
10164        })
10165        .unwrap_or_else(|| path.to_path_buf());
10166    #[cfg(not(target_os = "macos"))]
10167    let normalized = path.to_path_buf();
10168
10169    let start = if normalized.is_absolute() {
10170        std::fs::File::open("/")?
10171    } else {
10172        std::fs::File::open(".")?
10173    };
10174    let mut directory = start;
10175    for component in normalized.components() {
10176        use std::path::Component;
10177        let name = match component {
10178            Component::RootDir | Component::CurDir => continue,
10179            Component::Normal(name) => name,
10180            Component::ParentDir | Component::Prefix(_) => {
10181                return Err(LinkError::UnsafePath {
10182                    path: path.display().to_string(),
10183                });
10184            }
10185        };
10186        use std::os::unix::ffi::OsStrExt as _;
10187        let name = c_name(name.as_bytes(), &path.display().to_string())?;
10188        if create {
10189            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10190            if made != 0 {
10191                let error = std::io::Error::last_os_error();
10192                if error.raw_os_error() != Some(libc::EEXIST) {
10193                    return Err(error.into());
10194                }
10195            }
10196        }
10197        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
10198    }
10199    Ok(directory)
10200}
10201
10202#[cfg(unix)]
10203fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
10204    open_dir_path_nofollow(path, true)
10205}
10206
10207#[cfg(unix)]
10208fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
10209    open_dir_path_nofollow(path, false)
10210}
10211
10212#[cfg(unix)]
10213fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
10214    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
10215    let result =
10216        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
10217    if result == 0 {
10218        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
10219    }
10220    let error = std::io::Error::last_os_error();
10221    if error.kind() == std::io::ErrorKind::NotFound {
10222        Ok(None)
10223    } else {
10224        Err(error.into())
10225    }
10226}
10227
10228#[cfg(unix)]
10229fn create_dir_exclusive_at(
10230    parent: std::os::fd::RawFd,
10231    name: &std::ffi::CStr,
10232    display: &str,
10233) -> LinkResult<std::fs::File> {
10234    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
10235    if made != 0 {
10236        return Err(LinkError::UnsafePath {
10237            path: display.to_string(),
10238        });
10239    }
10240    open_dir_at(parent, name, display)
10241}
10242
10243#[cfg(unix)]
10244fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
10245    use std::os::fd::AsRawFd as _;
10246
10247    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
10248    if duplicate < 0 {
10249        return Err(std::io::Error::last_os_error().into());
10250    }
10251    let stream = unsafe { libc::fdopendir(duplicate) };
10252    if stream.is_null() {
10253        let error = std::io::Error::last_os_error();
10254        unsafe {
10255            libc::close(duplicate);
10256        }
10257        return Err(error.into());
10258    }
10259    let mut names = Vec::new();
10260    loop {
10261        let entry = unsafe { libc::readdir(stream) };
10262        if entry.is_null() {
10263            break;
10264        }
10265        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
10266        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
10267            names.push(raw.to_owned());
10268        }
10269    }
10270    if unsafe { libc::closedir(stream) } != 0 {
10271        return Err(std::io::Error::last_os_error().into());
10272    }
10273    Ok(names)
10274}
10275
10276/// Remove an entry tree relative to a held directory capability. Symlinks are
10277/// unlinked, never traversed, even when an old mirror contains hostile names.
10278#[cfg(unix)]
10279fn remove_tree_at(
10280    parent: std::os::fd::RawFd,
10281    name: &std::ffi::CStr,
10282    display: &str,
10283) -> LinkResult<()> {
10284    use std::os::fd::AsRawFd as _;
10285
10286    match entry_is_dir_at(parent, name)? {
10287        None => return Ok(()),
10288        Some(false) => {
10289            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
10290                return Err(std::io::Error::last_os_error().into());
10291            }
10292        }
10293        Some(true) => {
10294            let directory = open_dir_at(parent, name, display)?;
10295            for child in directory_entry_names(&directory)? {
10296                let child_display =
10297                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
10298                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
10299            }
10300            drop(directory);
10301            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
10302                return Err(std::io::Error::last_os_error().into());
10303            }
10304        }
10305    }
10306    Ok(())
10307}
10308
10309/// Clone a live destination into a private sibling stage without following
10310/// any symlink. Regular files are streamed between held descriptors; symlinks
10311/// are reproduced as links, never opened. Special files are refused.
10312#[cfg(unix)]
10313fn clone_tree_contents(
10314    source: &std::fs::File,
10315    destination: &std::fs::File,
10316    display: &str,
10317) -> LinkResult<()> {
10318    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10319
10320    for name in directory_entry_names(source)? {
10321        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
10322        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
10323        if unsafe {
10324            libc::fstatat(
10325                source.as_raw_fd(),
10326                name.as_ptr(),
10327                &mut stat,
10328                libc::AT_SYMLINK_NOFOLLOW,
10329            )
10330        } != 0
10331        {
10332            return Err(std::io::Error::last_os_error().into());
10333        }
10334        match stat.st_mode & libc::S_IFMT {
10335            libc::S_IFDIR => {
10336                if unsafe {
10337                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
10338                } != 0
10339                {
10340                    return Err(std::io::Error::last_os_error().into());
10341                }
10342                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
10343                let destination_child =
10344                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
10345                clone_tree_contents(&source_child, &destination_child, &child_display)?;
10346                destination_child.sync_all()?;
10347            }
10348            libc::S_IFREG => {
10349                let source_fd = unsafe {
10350                    libc::openat(
10351                        source.as_raw_fd(),
10352                        name.as_ptr(),
10353                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10354                    )
10355                };
10356                if source_fd < 0 {
10357                    return Err(std::io::Error::last_os_error().into());
10358                }
10359                let destination_fd = unsafe {
10360                    libc::openat(
10361                        destination.as_raw_fd(),
10362                        name.as_ptr(),
10363                        libc::O_WRONLY
10364                            | libc::O_CREAT
10365                            | libc::O_EXCL
10366                            | libc::O_CLOEXEC
10367                            | libc::O_NOFOLLOW,
10368                        (stat.st_mode & 0o777) as libc::c_uint,
10369                    )
10370                };
10371                if destination_fd < 0 {
10372                    unsafe {
10373                        libc::close(source_fd);
10374                    }
10375                    return Err(std::io::Error::last_os_error().into());
10376                }
10377                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
10378                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
10379                std::io::copy(&mut input, &mut output)?;
10380                output.sync_all()?;
10381            }
10382            libc::S_IFLNK => {
10383                let mut target = vec![0_u8; 4097];
10384                let length = unsafe {
10385                    libc::readlinkat(
10386                        source.as_raw_fd(),
10387                        name.as_ptr(),
10388                        target.as_mut_ptr().cast(),
10389                        target.len(),
10390                    )
10391                };
10392                if length < 0 || length as usize >= target.len() {
10393                    return Err(LinkError::UnsafePath {
10394                        path: child_display,
10395                    });
10396                }
10397                target.truncate(length as usize);
10398                let target = c_name(&target, &child_display)?;
10399                if unsafe {
10400                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
10401                } != 0
10402                {
10403                    return Err(std::io::Error::last_os_error().into());
10404                }
10405            }
10406            _ => {
10407                return Err(LinkError::UnsafePath {
10408                    path: child_display,
10409                });
10410            }
10411        }
10412    }
10413    destination.sync_all()?;
10414    Ok(())
10415}
10416
10417#[cfg(target_os = "linux")]
10418fn install_stage_at(
10419    parent: std::os::fd::RawFd,
10420    stage: &std::ffi::CStr,
10421    dest: &std::ffi::CStr,
10422    dest_exists: bool,
10423) -> LinkResult<()> {
10424    let flags = if dest_exists {
10425        libc::RENAME_EXCHANGE
10426    } else {
10427        libc::RENAME_NOREPLACE
10428    };
10429    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
10430    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
10431    // atomic exchange/no-replace boundary compiles for glibc and musl.
10432    let result = unsafe {
10433        libc::syscall(
10434            libc::SYS_renameat2,
10435            parent,
10436            stage.as_ptr(),
10437            parent,
10438            dest.as_ptr(),
10439            flags,
10440        )
10441    };
10442    if result == 0 {
10443        Ok(())
10444    } else {
10445        Err(std::io::Error::last_os_error().into())
10446    }
10447}
10448
10449#[cfg(target_os = "macos")]
10450fn install_stage_at(
10451    parent: std::os::fd::RawFd,
10452    stage: &std::ffi::CStr,
10453    dest: &std::ffi::CStr,
10454    dest_exists: bool,
10455) -> LinkResult<()> {
10456    let flags = if dest_exists {
10457        libc::RENAME_SWAP
10458    } else {
10459        libc::RENAME_EXCL
10460    };
10461    let result =
10462        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
10463    if result == 0 {
10464        Ok(())
10465    } else {
10466        Err(std::io::Error::last_os_error().into())
10467    }
10468}
10469
10470#[cfg(unix)]
10471fn write_pull_entries_beneath_dir(
10472    root: &std::fs::File,
10473    entries: &[(String, Vec<u8>)],
10474) -> LinkResult<()> {
10475    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10476
10477    for (path, content) in entries {
10478        let components: Vec<&str> = path.split('/').collect();
10479        let (leaf, parents) = components
10480            .split_last()
10481            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10482        let mut directory = root.try_clone()?;
10483        for component in parents {
10484            let name = c_name(component.as_bytes(), path)?;
10485            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10486            if made != 0 {
10487                let error = std::io::Error::last_os_error();
10488                if error.raw_os_error() != Some(libc::EEXIST) {
10489                    return Err(error.into());
10490                }
10491            }
10492            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10493        }
10494
10495        let leaf_name = c_name(leaf.as_bytes(), path)?;
10496        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
10497        let inspected = unsafe {
10498            libc::fstatat(
10499                directory.as_raw_fd(),
10500                leaf_name.as_ptr(),
10501                &mut existing,
10502                libc::AT_SYMLINK_NOFOLLOW,
10503            )
10504        };
10505        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
10506            return Err(LinkError::UnsafePath { path: path.clone() });
10507        }
10508
10509        let nonce = std::time::SystemTime::now()
10510            .duration_since(std::time::UNIX_EPOCH)
10511            .unwrap_or_default()
10512            .as_nanos();
10513        let temp_name = format!(
10514            ".dbmd-pull-{}-{nonce}-{}",
10515            std::process::id(),
10516            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
10517        );
10518        let temp = c_name(temp_name.as_bytes(), path)?;
10519        let fd = unsafe {
10520            libc::openat(
10521                directory.as_raw_fd(),
10522                temp.as_ptr(),
10523                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10524                0o600,
10525            )
10526        };
10527        if fd < 0 {
10528            return Err(std::io::Error::last_os_error().into());
10529        }
10530        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10531        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
10532            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10533            return Err(error.into());
10534        }
10535        drop(file);
10536        let renamed = unsafe {
10537            libc::renameat(
10538                directory.as_raw_fd(),
10539                temp.as_ptr(),
10540                directory.as_raw_fd(),
10541                leaf_name.as_ptr(),
10542            )
10543        };
10544        if renamed != 0 {
10545            let error = std::io::Error::last_os_error();
10546            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10547            return Err(error.into());
10548        }
10549        directory.sync_all()?;
10550    }
10551    root.sync_all()?;
10552    Ok(())
10553}
10554
10555#[cfg(unix)]
10556fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
10557    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10558
10559    let path = &entry.path;
10560    let components: Vec<&str> = path.split('/').collect();
10561    let (leaf, parents) = components
10562        .split_last()
10563        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10564    let mut directory = root.try_clone()?;
10565    for component in parents {
10566        let name = c_name(component.as_bytes(), path)?;
10567        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10568        if made != 0 {
10569            let error = std::io::Error::last_os_error();
10570            if error.raw_os_error() != Some(libc::EEXIST) {
10571                return Err(error.into());
10572            }
10573        }
10574        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10575    }
10576    let leaf_name = c_name(leaf.as_bytes(), path)?;
10577    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
10578    if unsafe {
10579        libc::fstatat(
10580            directory.as_raw_fd(),
10581            leaf_name.as_ptr(),
10582            &mut existing,
10583            libc::AT_SYMLINK_NOFOLLOW,
10584        )
10585    } == 0
10586        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
10587    {
10588        return Err(LinkError::UnsafePath { path: path.clone() });
10589    }
10590    let nonce = SystemTime::now()
10591        .duration_since(UNIX_EPOCH)
10592        .unwrap_or_default()
10593        .as_nanos();
10594    let temp_name = format!(
10595        ".dbmd-pull-{}-{nonce}-{}",
10596        std::process::id(),
10597        content_sha256(path.as_bytes())
10598    );
10599    let temp = c_name(temp_name.as_bytes(), path)?;
10600    let fd = unsafe {
10601        libc::openat(
10602            directory.as_raw_fd(),
10603            temp.as_ptr(),
10604            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10605            0o600,
10606        )
10607    };
10608    if fd < 0 {
10609        return Err(std::io::Error::last_os_error().into());
10610    }
10611    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
10612    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
10613    let mut digest = Sha256::new();
10614    let mut total = 0_u64;
10615    let mut buffer = [0_u8; 64 * 1024];
10616    let copied = (|| -> std::io::Result<()> {
10617        loop {
10618            let read = input.read(&mut buffer)?;
10619            if read == 0 {
10620                break;
10621            }
10622            total = total.saturating_add(read as u64);
10623            if total > entry.bytes {
10624                return Err(std::io::Error::new(
10625                    std::io::ErrorKind::InvalidData,
10626                    "staged sync source grew beyond its verified length",
10627                ));
10628            }
10629            digest.update(&buffer[..read]);
10630            output.write_all(&buffer[..read])?;
10631        }
10632        Ok(())
10633    })();
10634    if let Err(error) = copied {
10635        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10636        return Err(error.into());
10637    }
10638    drop(output);
10639    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
10640        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10641        return Err(invalid_feed(
10642            "private staged sync source failed final integrity verification",
10643        ));
10644    }
10645    if unsafe {
10646        libc::renameat(
10647            directory.as_raw_fd(),
10648            temp.as_ptr(),
10649            directory.as_raw_fd(),
10650            leaf_name.as_ptr(),
10651        )
10652    } != 0
10653    {
10654        let error = std::io::Error::last_os_error();
10655        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10656        return Err(error.into());
10657    }
10658    Ok(())
10659}
10660
10661#[cfg(unix)]
10662fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
10663    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10664
10665    let path = &entry.path;
10666    let components: Vec<&str> = path.split('/').collect();
10667    let (leaf, parents) = components
10668        .split_last()
10669        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10670    let mut directory = root.try_clone()?;
10671    for component in parents {
10672        directory = open_dir_at(
10673            directory.as_raw_fd(),
10674            &c_name(component.as_bytes(), path)?,
10675            path,
10676        )?;
10677    }
10678    let leaf = c_name(leaf.as_bytes(), path)?;
10679    let fd = unsafe {
10680        libc::openat(
10681            directory.as_raw_fd(),
10682            leaf.as_ptr(),
10683            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10684        )
10685    };
10686    if fd < 0 {
10687        return Err(std::io::Error::last_os_error().into());
10688    }
10689    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10690    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
10691        return Err(invalid_feed(
10692            "private pull stage changed before its durability barrier",
10693        ));
10694    }
10695    file.sync_all()?;
10696    Ok(())
10697}
10698
10699#[cfg(unix)]
10700fn run_pull_source_workers(
10701    root: &std::fs::File,
10702    entries: &[V2StagedFile],
10703    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
10704) -> LinkResult<()> {
10705    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10706
10707    let next = AtomicUsize::new(0);
10708    let failed = AtomicBool::new(false);
10709    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
10710    let mut first_error = None;
10711    std::thread::scope(|scope| {
10712        let (sender, receiver) = std::sync::mpsc::channel();
10713        for _ in 0..worker_count {
10714            let sender = sender.clone();
10715            let next = &next;
10716            let failed = &failed;
10717            scope.spawn(move || {
10718                while !failed.load(Ordering::Acquire) {
10719                    let index = next.fetch_add(1, Ordering::Relaxed);
10720                    let Some(entry) = entries.get(index) else {
10721                        break;
10722                    };
10723                    let result = operation(root, entry);
10724                    if result.is_err() {
10725                        failed.store(true, Ordering::Release);
10726                    }
10727                    if sender.send(result).is_err() {
10728                        break;
10729                    }
10730                }
10731            });
10732        }
10733        drop(sender);
10734        for result in receiver {
10735            if let Err(error) = result {
10736                if first_error.is_none() {
10737                    first_error = Some(error);
10738                }
10739            }
10740        }
10741    });
10742    if let Some(error) = first_error {
10743        return Err(error);
10744    }
10745    if next.load(Ordering::Relaxed) < entries.len() {
10746        return Err(invalid_feed(
10747            "a bounded pull worker stopped before reporting every file",
10748        ));
10749    }
10750    Ok(())
10751}
10752
10753#[cfg(unix)]
10754fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
10755    use std::os::fd::AsRawFd as _;
10756
10757    for name in directory_entry_names(root)? {
10758        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
10759            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
10760            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
10761            sync_pull_directory_tree(&child, &child_display)?;
10762        }
10763    }
10764    root.sync_all()?;
10765    Ok(())
10766}
10767
10768#[cfg(unix)]
10769fn write_pull_sources_beneath_dir(
10770    root: &std::fs::File,
10771    entries: &[V2StagedFile],
10772) -> LinkResult<()> {
10773    // The stage is still private and unreachable as the checkout here. Write
10774    // and verify every leaf first so the filesystem can coalesce dirty data,
10775    // then re-open/re-hash/fsync leaves through the held root capability. Only
10776    // after every file barrier succeeds do we flush each directory once. This
10777    // preserves the same crash durability as file-fsync + directory-fsync per
10778    // path without serializing 50,000 redundant directory barriers.
10779    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
10780    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
10781    sync_pull_directory_tree(root, "v2 pull stage")
10782}
10783
10784#[cfg(unix)]
10785fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
10786    use std::os::fd::AsRawFd as _;
10787    for path in paths {
10788        if !safe_store_rel_path(path) {
10789            return Err(LinkError::UnsafePath { path: path.clone() });
10790        }
10791        let components = path.split('/').collect::<Vec<_>>();
10792        let Some((leaf, parents)) = components.split_last() else {
10793            return Err(LinkError::UnsafePath { path: path.clone() });
10794        };
10795        let mut directory = root.try_clone()?;
10796        let mut missing = false;
10797        for component in parents {
10798            let name = c_name(component.as_bytes(), path)?;
10799            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
10800                None => {
10801                    missing = true;
10802                    break;
10803                }
10804                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
10805                Some(true) => {
10806                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10807                }
10808            }
10809        }
10810        if missing {
10811            continue;
10812        }
10813        let leaf = c_name(leaf.as_bytes(), path)?;
10814        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
10815            None => {}
10816            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
10817            Some(false) => {
10818                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
10819                    return Err(std::io::Error::last_os_error().into());
10820                }
10821                directory.sync_all()?;
10822            }
10823        }
10824    }
10825    Ok(())
10826}
10827
10828#[cfg(unix)]
10829fn install_pulled_delta(
10830    dest: &Path,
10831    entries: &[(String, Vec<u8>)],
10832    deleted: &[String],
10833    rebuild_indexes: bool,
10834) -> LinkResult<()> {
10835    use ring::rand::SecureRandom as _;
10836    use std::os::fd::AsRawFd as _;
10837    use std::os::unix::ffi::OsStrExt as _;
10838
10839    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10840    let name = dest
10841        .file_name()
10842        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10843        .ok_or_else(|| LinkError::UnsafePath {
10844            path: dest.display().to_string(),
10845        })?;
10846    let parent_dir = open_or_create_dir_nofollow(parent)?;
10847    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10848    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10849        None => false,
10850        Some(true) => true,
10851        Some(false) => {
10852            return Err(LinkError::UnsafePath {
10853                path: dest.display().to_string(),
10854            });
10855        }
10856    };
10857
10858    let mut nonce = [0_u8; 16];
10859    ring::rand::SystemRandom::new()
10860        .fill(&mut nonce)
10861        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10862    let stage_label = format!(
10863        ".{}.dbmd-pull-stage-{}",
10864        name.to_string_lossy(),
10865        URL_SAFE_NO_PAD.encode(nonce)
10866    );
10867    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10868    let stage_dir = create_dir_exclusive_at(
10869        parent_dir.as_raw_fd(),
10870        &stage_name,
10871        &dest.display().to_string(),
10872    )?;
10873
10874    let prepared = (|| -> LinkResult<()> {
10875        if dest_exists {
10876            let live = open_dir_at(
10877                parent_dir.as_raw_fd(),
10878                &dest_name,
10879                &dest.display().to_string(),
10880            )?;
10881            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10882        }
10883        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10884        write_pull_entries_beneath_dir(&stage_dir, entries)?;
10885        if rebuild_indexes {
10886            let stage_store =
10887                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10888                    .map_err(|error| LinkError::InvalidPack {
10889                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10890                    })?;
10891            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10892                LinkError::InvalidPack {
10893                    message: format!("could not materialize v2 local catalogs: {error}"),
10894                }
10895            })?;
10896        }
10897        stage_dir.sync_all()?;
10898        Ok(())
10899    })();
10900    if let Err(error) = prepared {
10901        let _ = remove_tree_at(
10902            parent_dir.as_raw_fd(),
10903            &stage_name,
10904            &dest.display().to_string(),
10905        );
10906        return Err(error);
10907    }
10908
10909    if let Err(error) =
10910        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10911    {
10912        let _ = remove_tree_at(
10913            parent_dir.as_raw_fd(),
10914            &stage_name,
10915            &dest.display().to_string(),
10916        );
10917        return Err(error);
10918    }
10919    parent_dir.sync_all()?;
10920    if dest_exists {
10921        // Commit already happened atomically. Cleanup is best-effort so a
10922        // failure cannot be reported as a failed pull after the live tree
10923        // changed; a crash may leave only this private old-tree sibling.
10924        let _ = remove_tree_at(
10925            parent_dir.as_raw_fd(),
10926            &stage_name,
10927            &dest.display().to_string(),
10928        );
10929        let _ = parent_dir.sync_all();
10930    }
10931    Ok(())
10932}
10933
10934#[cfg(unix)]
10935fn install_pulled_delta_sources(
10936    dest: &Path,
10937    entries: &[V2StagedFile],
10938    deleted: &[String],
10939    rebuild_indexes: bool,
10940    _previous: Option<&V2SyncBaseline>,
10941    _next: &V2VerifiedHead,
10942) -> LinkResult<()> {
10943    use ring::rand::SecureRandom as _;
10944    use std::os::fd::AsRawFd as _;
10945    use std::os::unix::ffi::OsStrExt as _;
10946
10947    // An established checkout is updated only at the changed coordinates
10948    // through the durable pull journal shared with Windows. Initial clones
10949    // still use a private complete stage and one atomic directory install.
10950    if let Ok(store) = Store::open_strict(dest) {
10951        return install_established_v2_delta(
10952            store,
10953            entries,
10954            deleted,
10955            rebuild_indexes,
10956            _previous,
10957            _next,
10958        );
10959    }
10960
10961    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10962    let name = dest
10963        .file_name()
10964        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10965        .ok_or_else(|| LinkError::UnsafePath {
10966            path: dest.display().to_string(),
10967        })?;
10968    let parent_dir = open_or_create_dir_nofollow(parent)?;
10969    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10970    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10971        None => false,
10972        Some(true) => true,
10973        Some(false) => {
10974            return Err(LinkError::UnsafePath {
10975                path: dest.display().to_string(),
10976            })
10977        }
10978    };
10979    let mut nonce = [0_u8; 16];
10980    ring::rand::SystemRandom::new()
10981        .fill(&mut nonce)
10982        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10983    let stage_label = format!(
10984        ".{}.dbmd-pull-stage-{}",
10985        name.to_string_lossy(),
10986        URL_SAFE_NO_PAD.encode(nonce)
10987    );
10988    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10989    let stage_dir = create_dir_exclusive_at(
10990        parent_dir.as_raw_fd(),
10991        &stage_name,
10992        &dest.display().to_string(),
10993    )?;
10994    let prepared = (|| -> LinkResult<()> {
10995        if dest_exists {
10996            let live = open_dir_at(
10997                parent_dir.as_raw_fd(),
10998                &dest_name,
10999                &dest.display().to_string(),
11000            )?;
11001            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
11002        }
11003        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
11004        write_pull_sources_beneath_dir(&stage_dir, entries)?;
11005        if rebuild_indexes {
11006            let stage_store =
11007                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
11008                    .map_err(|error| LinkError::InvalidPack {
11009                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
11010                    })?;
11011            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
11012                LinkError::InvalidPack {
11013                    message: format!("could not materialize v2 local catalogs: {error}"),
11014                }
11015            })?;
11016        }
11017        stage_dir.sync_all()?;
11018        Ok(())
11019    })();
11020    if let Err(error) = prepared {
11021        let _ = remove_tree_at(
11022            parent_dir.as_raw_fd(),
11023            &stage_name,
11024            &dest.display().to_string(),
11025        );
11026        return Err(error);
11027    }
11028    if let Err(error) =
11029        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
11030    {
11031        let _ = remove_tree_at(
11032            parent_dir.as_raw_fd(),
11033            &stage_name,
11034            &dest.display().to_string(),
11035        );
11036        return Err(error);
11037    }
11038    parent_dir.sync_all()?;
11039    if dest_exists {
11040        let _ = remove_tree_at(
11041            parent_dir.as_raw_fd(),
11042            &stage_name,
11043            &dest.display().to_string(),
11044        );
11045        let _ = parent_dir.sync_all();
11046    }
11047    Ok(())
11048}
11049
11050#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11051struct V2PullCoordinate {
11052    head_seq: Option<u64>,
11053    commit_hash: Option<String>,
11054    view_kind: Option<String>,
11055    view_revision: Option<String>,
11056}
11057
11058#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11059struct V2PullFileCoordinate {
11060    sha256: String,
11061    bytes: u64,
11062}
11063
11064#[derive(Debug, Clone, Deserialize, Serialize)]
11065struct V2PullJournalEntry {
11066    path: String,
11067    old: Option<V2PullFileCoordinate>,
11068    new: Option<V2PullFileCoordinate>,
11069    backup: Option<String>,
11070}
11071
11072#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11073#[serde(rename_all = "snake_case")]
11074enum V2PullPhase {
11075    Preparing,
11076    Ready,
11077}
11078
11079#[derive(Debug, Clone, Deserialize, Serialize)]
11080struct V2PullJournal {
11081    v: u8,
11082    phase: V2PullPhase,
11083    brain: String,
11084    previous: V2PullCoordinate,
11085    next: V2PullCoordinate,
11086    backup_dir: String,
11087    entries: Vec<V2PullJournalEntry>,
11088}
11089
11090const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
11091
11092fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
11093    V2PullCoordinate {
11094        head_seq: baseline.and_then(|value| value.head_seq),
11095        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
11096        view_kind: baseline.and_then(|value| value.view_kind.clone()),
11097        view_revision: baseline.and_then(|value| value.view_revision.clone()),
11098    }
11099}
11100
11101fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
11102    V2PullCoordinate {
11103        head_seq: head.pointer.as_ref().map(|value| value.seq),
11104        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
11105        view_kind: Some(head.view_kind.clone()),
11106        view_revision: Some(head.view_revision.clone()),
11107    }
11108}
11109
11110fn v2_pull_file_coordinate(
11111    store: &Store,
11112    path: &str,
11113    limit: u64,
11114) -> LinkResult<Option<V2PullFileCoordinate>> {
11115    let file = match store.open_regular(Path::new(path)) {
11116        Ok(file) => file,
11117        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11118        Err(error) => return Err(error.into()),
11119    };
11120    let bytes = file.metadata()?.len();
11121    if bytes > limit || bytes > MAX_PULL_TRANSACTION_BYTES {
11122        return Err(invalid_feed(
11123            "pull transaction file exceeds its declared bound",
11124        ));
11125    }
11126    Ok(Some(V2PullFileCoordinate {
11127        sha256: content_sha256_reader(file)?,
11128        bytes,
11129    }))
11130}
11131
11132fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
11133    let mut bytes = serde_json::to_vec_pretty(journal)
11134        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
11135    bytes.push(b'\n');
11136    Ok(bytes)
11137}
11138
11139fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
11140    let backup_prefix = ".dbmd/pull-backup-";
11141    let suffix = journal
11142        .backup_dir
11143        .strip_prefix(backup_prefix)
11144        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
11145    let mut paths = std::collections::BTreeSet::new();
11146    if journal.v != 1
11147        || !crate::ulid::is_ulid(&journal.brain)
11148        || !crate::ulid::is_ulid(suffix)
11149        || journal.entries.is_empty()
11150        || journal.entries.len() > MAX_PUSH_FILES + 4
11151        || journal.previous == journal.next
11152    {
11153        return Err(invalid_feed("v2 pull journal failed validation"));
11154    }
11155    for (index, entry) in journal.entries.iter().enumerate() {
11156        if !safe_store_rel_path(&entry.path)
11157            || entry.path == V2_PULL_JOURNAL
11158            || entry.path.starts_with(backup_prefix)
11159        {
11160            return Err(invalid_feed(format!(
11161                "v2 pull journal has an unsafe entry path: {}",
11162                entry.path
11163            )));
11164        }
11165        if !paths.insert(entry.path.clone()) {
11166            return Err(invalid_feed(format!(
11167                "v2 pull journal repeats entry path: {}",
11168                entry.path
11169            )));
11170        }
11171        if entry.old.is_none() && entry.new.is_none() {
11172            return Err(invalid_feed(format!(
11173                "v2 pull journal entry has no coordinate: {}",
11174                entry.path
11175            )));
11176        }
11177        if entry
11178            .old
11179            .iter()
11180            .chain(entry.new.iter())
11181            .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_PULL_TRANSACTION_BYTES)
11182        {
11183            return Err(invalid_feed(format!(
11184                "v2 pull journal entry has invalid content metadata: {}",
11185                entry.path
11186            )));
11187        }
11188        if entry.backup.as_deref()
11189            != entry
11190                .old
11191                .as_ref()
11192                .map(|_| format!("{index:08x}"))
11193                .as_deref()
11194        {
11195            return Err(invalid_feed(format!(
11196                "v2 pull journal entry has an invalid backup coordinate: {}",
11197                entry.path
11198            )));
11199        }
11200    }
11201    Ok(())
11202}
11203
11204fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
11205    #[cfg(unix)]
11206    {
11207        use std::os::unix::fs::PermissionsExt as _;
11208        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
11209            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
11210                return Err(invalid_feed(
11211                    "v2 pull journal is accessible to group/other; set mode 0600",
11212                ));
11213            }
11214            Ok(_) => {}
11215            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11216            Err(error) => return Err(error.into()),
11217        }
11218    }
11219    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
11220        Ok(bytes) => bytes,
11221        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11222        Err(error) => return Err(error.into()),
11223    };
11224    let journal: V2PullJournal =
11225        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
11226    validate_v2_pull_journal(&journal)?;
11227    Ok(Some(journal))
11228}
11229
11230fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
11231    // The journal is the only recovery authority. Remove it first only after
11232    // rollback or baseline commit is durable; an orphan private backup is safe
11233    // and can be pruned, while a journal without its backups is not recoverable.
11234    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
11235        Ok(()) => {}
11236        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
11237        Err(error) => return Err(error.into()),
11238    }
11239    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
11240        Ok(()) => Ok(()),
11241        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
11242        Err(error) => Err(error.into()),
11243    }
11244}
11245
11246fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
11247    let names = match store.directory_names(Path::new(".dbmd")) {
11248        Ok(names) => names,
11249        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
11250        Err(error) => return Err(error.into()),
11251    };
11252    for name in names {
11253        let Some(name) = name.to_str() else {
11254            continue;
11255        };
11256        let Some(suffix) = name.strip_prefix("pull-backup-") else {
11257            continue;
11258        };
11259        if crate::ulid::is_ulid(suffix) {
11260            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
11261        }
11262    }
11263    Ok(())
11264}
11265
11266fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
11267    // Validate every live coordinate and every backup before the first restore.
11268    for entry in &journal.entries {
11269        let limit = entry
11270            .old
11271            .as_ref()
11272            .into_iter()
11273            .chain(entry.new.iter())
11274            .map(|value| value.bytes)
11275            .max()
11276            .unwrap_or(0);
11277        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
11278        if current != entry.old && current != entry.new {
11279            return Err(LinkError::InvalidPack {
11280                message: format!(
11281                    "cannot recover interrupted pull because `{}` changed afterward",
11282                    entry.path
11283                ),
11284            });
11285        }
11286        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
11287            let path = Path::new(&journal.backup_dir).join(backup);
11288            let file = store.open_regular(&path)?;
11289            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
11290                return Err(invalid_feed("v2 pull recovery backup failed verification"));
11291            }
11292        }
11293    }
11294    for entry in journal.entries.iter().rev() {
11295        match (&entry.old, &entry.backup) {
11296            (Some(old), Some(backup)) => {
11297                let bytes =
11298                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
11299                store.write_atomic(Path::new(&entry.path), &bytes)?;
11300            }
11301            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
11302                store.remove_file(Path::new(&entry.path))?;
11303            }
11304            (None, None) => {}
11305            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
11306        }
11307    }
11308    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
11309        message: format!("could not rebuild catalogs after pull recovery: {error}"),
11310    })?;
11311    cleanup_v2_pull_journal(store, journal)
11312}
11313
11314fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
11315    let Ok(store) = Store::open_strict(dest) else {
11316        return Ok(());
11317    };
11318    if let Some(journal) = load_v2_pull_journal(&store)? {
11319        if journal.brain != brain {
11320            return Err(invalid_feed("v2 pull journal belongs to another brain"));
11321        }
11322        if journal.phase == V2PullPhase::Preparing {
11323            cleanup_v2_pull_journal(&store, &journal)?;
11324        } else {
11325            let baseline = load_v2_baseline(cfg, brain, dest)?;
11326            let current = v2_pull_baseline_coordinate(baseline.as_ref());
11327            if current == journal.next {
11328                cleanup_v2_pull_journal(&store, &journal)?;
11329            } else {
11330                if current != journal.previous {
11331                    return Err(invalid_feed(
11332                        "cannot recover interrupted pull because its baseline changed afterward",
11333                    ));
11334                }
11335                rollback_v2_pull(&store, &journal)?;
11336            }
11337        }
11338    }
11339    // Cleanup removes the journal first after the state is durably old or new.
11340    // A hard kill in the tiny interval before deleting its backup directory can
11341    // therefore leave only an inert, private orphan. The per-brain operation
11342    // lock is held by the caller, so no live pull can own one here.
11343    prune_orphan_v2_pull_backups(&store)
11344}
11345
11346fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
11347    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
11348        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
11349    })?;
11350    if let Some(journal) = load_v2_pull_journal(&store)? {
11351        cleanup_v2_pull_journal(&store, &journal)?;
11352    }
11353    Ok(())
11354}
11355
11356#[cfg(windows)]
11357fn install_windows_initial_sources(
11358    dest: &Path,
11359    entries: &[V2StagedFile],
11360    rebuild_indexes: bool,
11361) -> LinkResult<()> {
11362    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
11363    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
11364        path: dest.display().to_string(),
11365    })?;
11366    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
11367    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
11368        return Err(LinkError::UnsafePath {
11369            path: dest.display().to_string(),
11370        });
11371    }
11372    let stage_name = format!(
11373        ".{}.dbmd-pull-stage-{}",
11374        name.to_string_lossy(),
11375        crate::ulid::mint()
11376    );
11377    let stage_path = parent.join(&stage_name);
11378    let stage_capability =
11379        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
11380    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
11381    let prepared = (|| -> LinkResult<()> {
11382        for entry in entries {
11383            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
11384            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
11385                return Err(invalid_feed(
11386                    "private staged sync source failed final integrity verification",
11387                ));
11388            }
11389            stage.write_atomic(Path::new(&entry.path), &bytes)?;
11390        }
11391        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
11392            .map_err(|error| LinkError::InvalidPack {
11393                message: format!("v2 staging tree is not a valid db.md store: {error}"),
11394            })?;
11395        if rebuild_indexes {
11396            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
11397                message: format!("could not materialize v2 local catalogs: {error}"),
11398            })?;
11399        }
11400        Ok(())
11401    })();
11402    if let Err(error) = prepared {
11403        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
11404        return Err(error);
11405    }
11406    crate::fsx::rename_directory_beneath(
11407        &parent_capability,
11408        Path::new(&stage_name),
11409        Path::new(name),
11410    )?;
11411    Ok(())
11412}
11413
11414fn install_established_v2_delta(
11415    store: Store,
11416    entries: &[V2StagedFile],
11417    deleted: &[String],
11418    rebuild_indexes: bool,
11419    previous: Option<&V2SyncBaseline>,
11420    next: &V2VerifiedHead,
11421) -> LinkResult<()> {
11422    if load_v2_pull_journal(&store)?.is_some() {
11423        return Err(invalid_feed(
11424            "an interrupted pull must be recovered before installing",
11425        ));
11426    }
11427    let mut sources = std::collections::BTreeMap::new();
11428    for entry in entries {
11429        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
11430            return Err(invalid_feed("pull mutation repeats a path"));
11431        }
11432    }
11433    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
11434    paths.extend(deleted.iter().cloned());
11435    paths.sort();
11436    paths.dedup();
11437    if paths.is_empty() {
11438        return Ok(());
11439    }
11440    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
11441    let mut journal = V2PullJournal {
11442        v: 1,
11443        phase: V2PullPhase::Preparing,
11444        brain: next.brain_id.clone(),
11445        previous: v2_pull_baseline_coordinate(previous),
11446        next: v2_pull_head_coordinate(next),
11447        backup_dir: backup_dir.clone(),
11448        entries: Vec::with_capacity(paths.len()),
11449    };
11450    for path in &paths {
11451        let old = v2_pull_file_coordinate(&store, path, MAX_PULL_TRANSACTION_BYTES)?;
11452        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
11453            sha256: entry.sha256.clone(),
11454            bytes: entry.bytes,
11455        });
11456        if old == new {
11457            continue;
11458        }
11459        let index = journal.entries.len();
11460        journal.entries.push(V2PullJournalEntry {
11461            path: path.clone(),
11462            backup: old.as_ref().map(|_| format!("{index:08x}")),
11463            old,
11464            new,
11465        });
11466    }
11467    if journal.entries.is_empty() {
11468        return Ok(());
11469    }
11470    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
11471        entry
11472            .old
11473            .as_ref()
11474            .map_or(Some(total), |old| total.checked_add(old.bytes))
11475    });
11476    if backup_bytes.is_none_or(|bytes| bytes > MAX_PULL_TRANSACTION_BYTES) {
11477        return Err(LinkError::InvalidPack {
11478            message: "pull recovery preimages exceed the bounded transaction limit".to_string(),
11479        });
11480    }
11481    validate_v2_pull_journal(&journal)?;
11482    store.write_private_atomic_new(
11483        Path::new(V2_PULL_JOURNAL),
11484        &v2_pull_journal_bytes(&journal)?,
11485    )?;
11486    let prepared = (|| -> LinkResult<()> {
11487        store.create_private_dir_all(Path::new(&backup_dir))?;
11488        for entry in &journal.entries {
11489            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
11490                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
11491                if content_sha256(&bytes) != old.sha256 {
11492                    return Err(invalid_feed("live pull source changed during backup"));
11493                }
11494                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
11495            }
11496        }
11497        journal.phase = V2PullPhase::Ready;
11498        store.write_private_atomic(
11499            Path::new(V2_PULL_JOURNAL),
11500            &v2_pull_journal_bytes(&journal)?,
11501        )?;
11502        Ok(())
11503    })();
11504    if let Err(error) = prepared {
11505        let cleanup = cleanup_v2_pull_journal(&store, &journal);
11506        return match cleanup {
11507            Ok(()) => Err(error),
11508            Err(cleanup) => Err(LinkError::InvalidPack {
11509                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
11510            }),
11511        };
11512    }
11513    let installed = (|| -> LinkResult<()> {
11514        for entry in &journal.entries {
11515            if v2_pull_file_coordinate(&store, &entry.path, MAX_PULL_TRANSACTION_BYTES)?
11516                != entry.old
11517            {
11518                return Err(LinkError::InvalidPack {
11519                    message: format!("local path `{}` changed during pull", entry.path),
11520                });
11521            }
11522            if let Some(source) = sources.get(&entry.path) {
11523                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
11524                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
11525                    return Err(invalid_feed(
11526                        "private staged sync source failed final integrity verification",
11527                    ));
11528                }
11529                store.write_atomic(Path::new(&entry.path), &bytes)?;
11530            } else if entry.old.is_some() {
11531                store.remove_file(Path::new(&entry.path))?;
11532            }
11533        }
11534        if rebuild_indexes {
11535            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
11536                message: format!("could not materialize v2 local catalogs: {error}"),
11537            })?;
11538        }
11539        Ok(())
11540    })();
11541    if let Err(error) = installed {
11542        return match rollback_v2_pull(&store, &journal) {
11543            Ok(()) => Err(error),
11544            Err(rollback) => Err(LinkError::InvalidPack {
11545                message: format!("{error}; durable pull rollback also failed: {rollback}"),
11546            }),
11547        };
11548    }
11549    Ok(())
11550}
11551
11552#[cfg(windows)]
11553fn install_pulled_delta_sources(
11554    dest: &Path,
11555    entries: &[V2StagedFile],
11556    deleted: &[String],
11557    rebuild_indexes: bool,
11558    previous: Option<&V2SyncBaseline>,
11559    next: &V2VerifiedHead,
11560) -> LinkResult<()> {
11561    match Store::open_strict(dest) {
11562        Ok(store) => {
11563            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
11564        }
11565        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
11566    }
11567}
11568
11569#[cfg(not(any(unix, windows)))]
11570fn install_pulled_delta_sources(
11571    _dest: &Path,
11572    _entries: &[V2StagedFile],
11573    _deleted: &[String],
11574    _rebuild_indexes: bool,
11575    _previous: Option<&V2SyncBaseline>,
11576    _next: &V2VerifiedHead,
11577) -> LinkResult<()> {
11578    Err(LinkError::UnsupportedPlatform {
11579        operation: "atomic v2 pull install",
11580    })
11581}
11582
11583#[cfg(unix)]
11584fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
11585    install_pulled_delta(dest, entries, &[], false)
11586}
11587
11588#[cfg(not(windows))]
11589fn is_safe_slug(slug: &str) -> bool {
11590    !slug.is_empty()
11591        && slug.len() <= 63
11592        && !slug.starts_with('-')
11593        && !slug.ends_with('-')
11594        && slug
11595            .bytes()
11596            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
11597}
11598
11599fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
11600    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
11601}
11602
11603fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
11604    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
11605}
11606
11607fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
11608    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
11609}
11610
11611fn preflight_zip_central_directory(
11612    bytes: &[u8],
11613    offset: usize,
11614    size: usize,
11615    count: u64,
11616) -> LinkResult<()> {
11617    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
11618    let end = offset
11619        .checked_add(size)
11620        .filter(|end| *end <= bytes.len())
11621        .ok_or_else(|| LinkError::InvalidPack {
11622            message: "ZIP central directory is out of bounds".to_string(),
11623        })?;
11624    let mut cursor = offset;
11625    for _ in 0..count {
11626        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
11627            return Err(LinkError::InvalidPack {
11628                message: "ZIP central directory entry count is inconsistent".to_string(),
11629            });
11630        }
11631        if le_u16(bytes, cursor + 34) != Some(0) {
11632            return Err(LinkError::InvalidPack {
11633                message: "multi-disk ZIP archives are not supported".to_string(),
11634            });
11635        }
11636        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
11637            total.checked_add(le_u16(bytes, cursor + at)? as usize)
11638        });
11639        cursor = cursor
11640            .checked_add(46)
11641            .and_then(|fixed| fixed.checked_add(variable?))
11642            .filter(|cursor| *cursor <= end)
11643            .ok_or_else(|| LinkError::InvalidPack {
11644                message: "ZIP central directory entry is truncated".to_string(),
11645            })?;
11646    }
11647    if cursor != end {
11648        return Err(LinkError::InvalidPack {
11649            message: "ZIP central directory size is inconsistent".to_string(),
11650        });
11651    }
11652    Ok(())
11653}
11654
11655/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
11656/// metadata object per central-directory entry. Supports ordinary EOCD and the
11657/// Zip64 locator/record emitted for >65,535-entry archives.
11658fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
11659    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
11660    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
11661    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
11662    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
11663    let eocd = bytes[search_start..]
11664        .windows(4)
11665        .rposition(|window| window == EOCD_SIG)
11666        .map(|offset| search_start + offset)
11667        .ok_or_else(|| LinkError::InvalidPack {
11668            message: "ZIP has no end-of-central-directory record".to_string(),
11669        })?;
11670    let invalid_end = || LinkError::InvalidPack {
11671        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
11672    };
11673    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
11674    if eocd
11675        .checked_add(22)
11676        .and_then(|end| end.checked_add(comment_len))
11677        != Some(bytes.len())
11678    {
11679        // Do not fall back to an earlier signature. ZipArchive does, and a
11680        // fake low-count EOCD appended after a real Zip64 entry-count bomb
11681        // would otherwise bypass this allocation preflight.
11682        return Err(invalid_end());
11683    }
11684    let disk = le_u16(bytes, eocd + 4);
11685    let central_disk = le_u16(bytes, eocd + 6);
11686    if disk != Some(0) || central_disk != Some(0) {
11687        return Err(LinkError::InvalidPack {
11688            message: "multi-disk ZIP archives are not supported".to_string(),
11689        });
11690    }
11691    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
11692    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
11693    if entries_on_disk != ordinary {
11694        return Err(LinkError::InvalidPack {
11695            message: "multi-disk ZIP archives are not supported".to_string(),
11696        });
11697    }
11698    let zip64_locator = eocd
11699        .checked_sub(20)
11700        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
11701    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
11702        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
11703        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
11704        if central_offset
11705            .checked_add(central_size)
11706            .filter(|end| *end == eocd)
11707            .is_none()
11708        {
11709            return Err(invalid_end());
11710        }
11711        (ordinary as u64, central_offset, central_size)
11712    } else {
11713        let Some(locator) = zip64_locator else {
11714            return Err(invalid_end());
11715        };
11716        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
11717            return Err(LinkError::InvalidPack {
11718                message: "multi-disk ZIP64 archives are not supported".to_string(),
11719            });
11720        }
11721        let record = le_u64(bytes, locator + 8)
11722            .and_then(|offset| usize::try_from(offset).ok())
11723            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
11724            .ok_or_else(|| LinkError::InvalidPack {
11725                message: "ZIP64 archive has an invalid end record".to_string(),
11726            })?;
11727        let record_size = le_u64(bytes, record + 4)
11728            .and_then(|size| usize::try_from(size).ok())
11729            .filter(|size| *size >= 44)
11730            .ok_or_else(invalid_end)?;
11731        if record
11732            .checked_add(12)
11733            .and_then(|end| end.checked_add(record_size))
11734            != Some(locator)
11735            || le_u32(bytes, record + 16) != Some(0)
11736            || le_u32(bytes, record + 20) != Some(0)
11737        {
11738            return Err(invalid_end());
11739        }
11740        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
11741        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
11742        let central_size = le_u64(bytes, record + 40)
11743            .and_then(|size| usize::try_from(size).ok())
11744            .ok_or_else(invalid_end)?;
11745        let central_offset = le_u64(bytes, record + 48)
11746            .and_then(|offset| usize::try_from(offset).ok())
11747            .ok_or_else(invalid_end)?;
11748        if zip64_on_disk != zip64_total
11749            || central_offset
11750                .checked_add(central_size)
11751                .filter(|end| *end == record)
11752                .is_none()
11753        {
11754            return Err(invalid_end());
11755        }
11756        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
11757        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
11758        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
11759            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
11760        {
11761            return Err(invalid_end());
11762        }
11763        (zip64_total, central_offset, central_size)
11764    };
11765    if count == 0 || count > max_entries as u64 {
11766        return Err(LinkError::InvalidPack {
11767            message: format!("invalid file count {count}"),
11768        });
11769    }
11770    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
11771    Ok(())
11772}
11773
11774fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
11775    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
11776    let mut archive =
11777        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
11778            message: format!("ZIP parse failed: {err}"),
11779        })?;
11780    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
11781        return Err(LinkError::InvalidPack {
11782            message: format!("invalid file count {}", archive.len()),
11783        });
11784    }
11785    let mut total = 0u64;
11786    let mut seen = std::collections::HashSet::new();
11787    let mut entries = Vec::with_capacity(archive.len());
11788    for index in 0..archive.len() {
11789        let mut file = archive
11790            .by_index(index)
11791            .map_err(|err| LinkError::InvalidPack {
11792                message: format!("ZIP entry failed: {err}"),
11793            })?;
11794        if file.is_dir() {
11795            continue;
11796        }
11797        let path = file.name().to_string();
11798        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
11799            return Err(LinkError::UnsafePath { path });
11800        }
11801        if file
11802            .unix_mode()
11803            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
11804        {
11805            return Err(LinkError::InvalidPack {
11806                message: format!("non-file entry `{path}`"),
11807            });
11808        }
11809        if !seen.insert(path.clone()) {
11810            return Err(LinkError::InvalidPack {
11811                message: format!("duplicate path `{path}`"),
11812            });
11813        }
11814        let remaining = MAX_STORE_BYTES.saturating_sub(total);
11815        if file.size() > remaining {
11816            return Err(LinkError::InvalidPack {
11817                message: "expanded content exceeds the 512 MB limit".to_string(),
11818            });
11819        }
11820        let mut content = Vec::new();
11821        (&mut file)
11822            .take(remaining + 1)
11823            .read_to_end(&mut content)
11824            .map_err(|err| LinkError::InvalidPack {
11825                message: format!("could not decompress `{path}`: {err}"),
11826            })?;
11827        if content.len() as u64 > remaining {
11828            return Err(LinkError::InvalidPack {
11829                message: "expanded content exceeds the 512 MB limit".to_string(),
11830            });
11831        }
11832        if content.len() as u64 != file.size() {
11833            return Err(LinkError::InvalidPack {
11834                message: format!("length mismatch for `{path}`"),
11835            });
11836        }
11837        total += content.len() as u64;
11838        entries.push((path, content));
11839    }
11840    if entries.is_empty() {
11841        return Err(LinkError::InvalidPack {
11842            message: "pack contains no files".to_string(),
11843        });
11844    }
11845    Ok(entries)
11846}
11847
11848fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
11849    let mut expected = std::collections::BTreeMap::new();
11850    for file in signed {
11851        if !safe_store_rel_path(&file.path) {
11852            return Err(LinkError::UnsafePath {
11853                path: file.path.clone(),
11854            });
11855        }
11856        if !is_sha256(&file.sha256)
11857            || expected
11858                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11859                .is_some()
11860        {
11861            return Err(invalid_feed(
11862                "signed snapshot manifest contains an invalid or duplicate file",
11863            ));
11864        }
11865    }
11866    if expected.len() != entries.len() {
11867        return Err(invalid_feed(
11868            "downloaded pack file set differs from the signed snapshot manifest",
11869        ));
11870    }
11871    for (path, bytes) in entries {
11872        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
11873            return Err(invalid_feed(format!(
11874                "downloaded pack contains unsigned path `{path}`"
11875            )));
11876        };
11877        if *declared_bytes != bytes.len() as u64
11878            || *sha256 != format!("{:x}", Sha256::digest(bytes))
11879        {
11880            return Err(invalid_feed(format!(
11881                "downloaded file `{path}` differs from its signed manifest"
11882            )));
11883        }
11884    }
11885    Ok(())
11886}
11887
11888/// Collect the files a push sends: the store's owned source text — `DB.md`,
11889/// `assets.jsonl` when present, the canonical curator timeline (`log.md` plus
11890/// rotated `log/*.md` archives), and every content `.md` under `records/` and
11891/// `sources/`. Derived `index.*` catalogs remain local because the receiver
11892/// rebuilds them. Returns `(store-relative path, content)` pairs, path-sorted.
11893pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11894    require_hardened_filesystem("sync push")?;
11895    preflight_push_ownership(store)?;
11896    let mut out: Vec<(String, String)> = Vec::new();
11897    let mut total = 0u64;
11898
11899    let mut read_text = |rel: &str| -> LinkResult<String> {
11900        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11901        total = total
11902            .checked_add(bytes.len() as u64)
11903            .ok_or_else(|| LinkError::PushTooLarge {
11904                detail: "uncompressed byte count overflow".to_string(),
11905            })?;
11906        if total > MAX_STORE_BYTES {
11907            return Err(LinkError::PushTooLarge {
11908                detail: format!("{total} uncompressed bytes"),
11909            });
11910        }
11911        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11912            path: rel.to_string(),
11913        })
11914    };
11915
11916    out.push(("DB.md".to_string(), read_text("DB.md")?));
11917    if store
11918        .regular_file_exists(Path::new("assets.jsonl"))
11919        .unwrap_or(false)
11920    {
11921        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11922    }
11923    if store
11924        .regular_file_exists(Path::new("log.md"))
11925        .unwrap_or(false)
11926    {
11927        out.push(("log.md".to_string(), read_text("log.md")?));
11928    }
11929    if store.directory_exists(Path::new("log"))? {
11930        for rel in store.walk_regular_files(Path::new("log"))? {
11931            let rel_str = rel.to_string_lossy().replace('\\', "/");
11932            if rel.extension().and_then(std::ffi::OsStr::to_str) != Some("md") {
11933                continue;
11934            }
11935            if !safe_store_rel_path(&rel_str) {
11936                return Err(LinkError::UnsafePath { path: rel_str });
11937            }
11938            let content = read_text(&rel_str)?;
11939            out.push((rel_str, content));
11940        }
11941    }
11942
11943    for rel in store.walk()? {
11944        let rel_str = rel.to_string_lossy().replace('\\', "/");
11945        if !safe_store_rel_path(&rel_str) {
11946            // A locally-legal name outside the hub's portable charset cannot
11947            // travel this wire; refusing beats silently dropping it.
11948            return Err(LinkError::UnsafePath { path: rel_str });
11949        }
11950        let content = read_text(&rel_str)?;
11951        out.push((rel_str, content));
11952    }
11953
11954    out.sort_by(|a, b| a.0.cmp(&b.0));
11955    Ok(out)
11956}
11957
11958/// Refuse to build a destructive whole-store snapshot from an ambiguous local
11959/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
11960/// silently omitted them could delete the hosted copies of those paths.
11961fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11962    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11963        return Err(LinkError::from(std::io::Error::new(
11964            std::io::ErrorKind::PermissionDenied,
11965            format!("cannot push: nested db.md store at {}", nested.display()),
11966        )));
11967    }
11968
11969    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11970        return Err(LinkError::from(std::io::Error::new(
11971            std::io::ErrorKind::PermissionDenied,
11972            format!(
11973                "cannot push: {} is a symlink outside the store ownership model",
11974                symlink.display()
11975            ),
11976        )));
11977    }
11978    Ok(())
11979}
11980
11981/// Push `files` to `brain` as a whole-store snapshot — the hub's push
11982/// semantics: the hosted copy becomes exactly this set (pull first if the
11983/// hosted side may have records the local copy lacks). Client-side caps
11984/// mirror the hub's JSON-path limits so an oversized push fails before the
11985/// upload.
11986pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11987    require_safe_ref(brain)?;
11988    let remote = verified_remote_head(cfg, brain, false)?;
11989    if files.len() > MAX_PUSH_FILES {
11990        return Err(LinkError::PushTooLarge {
11991            detail: format!("{} files", files.len()),
11992        });
11993    }
11994    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11995    if raw_total > MAX_STORE_BYTES {
11996        return Err(LinkError::PushTooLarge {
11997            detail: format!("{raw_total} uncompressed bytes"),
11998        });
11999    }
12000
12001    // Self-custody (a brain key is configured): the JSON fast path is
12002    // hub-signed by construction, so every push goes through the pack flow
12003    // with a locally signed entry — the hub verifies and can never sign.
12004    if cfg.brain_key.is_none() {
12005        let body = json!({
12006            "files": files
12007                .iter()
12008                .map(|(p, c)| json!({ "path": p, "content": c }))
12009                .collect::<Vec<_>>(),
12010        });
12011        if body.to_string().len() <= MAX_PUSH_BYTES {
12012            let path = format!("/api/hub/brains/{brain}/push");
12013            let pushed = ensure_ok(
12014                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12015                "sync push",
12016            )?;
12017            return Ok(pushed);
12018        }
12019    }
12020
12021    let pack = build_store_pack(files)?;
12022    if pack.len() as u64 > MAX_PACK_BYTES {
12023        return Err(LinkError::PushTooLarge {
12024            detail: format!("{} pack bytes", pack.len()),
12025        });
12026    }
12027    let sha256 = format!("{:x}", Sha256::digest(&pack));
12028    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
12029    if let Some(key) = &cfg.brain_key {
12030        if !remote.head.verified {
12031            return Err(invalid_feed(
12032                "self-custody push requires a fully verified, unscoped feed head",
12033            ));
12034        }
12035        let identity = remote
12036            .identity
12037            .as_ref()
12038            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
12039        let current_multikey = format!("ed25519:{}", identity.fingerprint);
12040        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
12041            return Err(invalid_feed(
12042                "configured brain key is not the verified current brain identity",
12043            ));
12044        }
12045        // The verified state pins seq + prev; a concurrent writer surfaces as
12046        // the hub's 422 on commit (re-run to retry against the new head).
12047        let next_seq = remote
12048            .head
12049            .seq
12050            .checked_add(1)
12051            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
12052        let mut manifest: Vec<WireFeedFile> = files
12053            .iter()
12054            .map(|(path, content)| WireFeedFile {
12055                path: path.clone(),
12056                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
12057                bytes: content.len() as u64,
12058            })
12059            .collect();
12060        manifest.sort_by(|a, b| a.path.cmp(&b.path));
12061        let ts = crate::now()
12062            .with_timezone(&chrono::Utc)
12063            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
12064            .to_string();
12065        let entry = self_custody_entry(
12066            key,
12067            next_seq,
12068            ts,
12069            &sha256,
12070            &manifest,
12071            remote.head.feed_hash.as_deref(),
12072        )?;
12073        meta["entry"] = Value::String(entry);
12074    }
12075    let presigned = ensure_ok(
12076        request(
12077            cfg,
12078            "POST",
12079            &format!("/api/hub/brains/{brain}/packs/presign"),
12080            Some(&meta),
12081            Auth::Required,
12082        )?,
12083        "prepare pack upload",
12084    )?;
12085    let url = presigned
12086        .get("url")
12087        .and_then(Value::as_str)
12088        .ok_or_else(|| LinkError::InvalidPack {
12089            message: "the hub returned no upload URL".to_string(),
12090        })?;
12091    put_presigned(
12092        cfg,
12093        url,
12094        presigned.get("headers").unwrap_or(&Value::Null),
12095        &pack,
12096    )?;
12097    let committed = ensure_ok(
12098        request(
12099            cfg,
12100            "POST",
12101            &format!("/api/hub/brains/{brain}/packs/commit"),
12102            Some(&meta),
12103            Auth::Required,
12104        )?,
12105        "commit pack",
12106    )?;
12107    Ok(committed)
12108}
12109
12110fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
12111    const LOCAL_HEADER: u32 = 0x0403_4b50;
12112    const CENTRAL_HEADER: u32 = 0x0201_4b50;
12113    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
12114    const VERSION_20: u16 = 20;
12115    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
12116    const UTF8_FLAG: u16 = 1 << 11;
12117    const STORED: u16 = 0;
12118    const DOS_TIME_MIDNIGHT: u16 = 0;
12119    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
12120    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
12121
12122    struct CentralEntry<'a> {
12123        name: &'a [u8],
12124        crc32: u32,
12125        size: u32,
12126        local_offset: u32,
12127    }
12128
12129    fn push_u16(out: &mut Vec<u8>, value: u16) {
12130        out.extend_from_slice(&value.to_le_bytes());
12131    }
12132
12133    fn push_u32(out: &mut Vec<u8>, value: u32) {
12134        out.extend_from_slice(&value.to_le_bytes());
12135    }
12136
12137    if files.is_empty() {
12138        return Err(LinkError::InvalidPack {
12139            message: "cannot create an empty snapshot pack".to_string(),
12140        });
12141    }
12142    if files.len() > u16::MAX as usize {
12143        return Err(LinkError::PushTooLarge {
12144            detail: format!(
12145                "{} files (canonical ZIP32 packs cap at {})",
12146                files.len(),
12147                u16::MAX
12148            ),
12149        });
12150    }
12151
12152    let mut sorted: Vec<_> = files.iter().collect();
12153    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
12154    let mut previous: Option<&str> = None;
12155    for (path, content) in &sorted {
12156        if !safe_store_rel_path(path) {
12157            return Err(LinkError::UnsafePath {
12158                path: (*path).clone(),
12159            });
12160        }
12161        if previous == Some(path.as_str()) {
12162            return Err(LinkError::InvalidPack {
12163                message: format!("duplicate path `{path}`"),
12164            });
12165        }
12166        previous = Some(path.as_str());
12167        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
12168            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
12169        })?;
12170    }
12171
12172    let mut out = Vec::new();
12173    let mut central = Vec::with_capacity(sorted.len());
12174    for (path, content) in sorted {
12175        let name = path.as_bytes();
12176        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
12177            message: format!("ZIP entry name is too long: `{path}`"),
12178        })?;
12179        let bytes = content.as_bytes();
12180        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
12181            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
12182        })?;
12183        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
12184            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
12185        })?;
12186        let crc32 = crc32fast::hash(bytes);
12187
12188        // Canonical local header: sizes and CRC are known up front. Bit 3 is
12189        // deliberately clear, so there is no trailing data descriptor.
12190        push_u32(&mut out, LOCAL_HEADER);
12191        push_u16(&mut out, VERSION_20);
12192        push_u16(&mut out, UTF8_FLAG);
12193        push_u16(&mut out, STORED);
12194        push_u16(&mut out, DOS_TIME_MIDNIGHT);
12195        push_u16(&mut out, DOS_DATE_1980_01_01);
12196        push_u32(&mut out, crc32);
12197        push_u32(&mut out, size);
12198        push_u32(&mut out, size);
12199        push_u16(&mut out, name_len);
12200        push_u16(&mut out, 0); // no local extra data
12201        out.extend_from_slice(name);
12202        out.extend_from_slice(bytes);
12203
12204        central.push(CentralEntry {
12205            name,
12206            crc32,
12207            size,
12208            local_offset,
12209        });
12210    }
12211
12212    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
12213        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
12214    })?;
12215    for entry in &central {
12216        push_u32(&mut out, CENTRAL_HEADER);
12217        push_u16(&mut out, MADE_BY_UNIX_20);
12218        push_u16(&mut out, VERSION_20);
12219        push_u16(&mut out, UTF8_FLAG);
12220        push_u16(&mut out, STORED);
12221        push_u16(&mut out, DOS_TIME_MIDNIGHT);
12222        push_u16(&mut out, DOS_DATE_1980_01_01);
12223        push_u32(&mut out, entry.crc32);
12224        push_u32(&mut out, entry.size);
12225        push_u32(&mut out, entry.size);
12226        push_u16(&mut out, entry.name.len() as u16);
12227        push_u16(&mut out, 0); // no central extra data
12228        push_u16(&mut out, 0); // no file comment
12229        push_u16(&mut out, 0); // disk number
12230        push_u16(&mut out, 0); // internal attributes
12231        push_u32(&mut out, UNIX_REGULAR_0600);
12232        push_u32(&mut out, entry.local_offset);
12233        out.extend_from_slice(entry.name);
12234    }
12235    let central_size = u32::try_from(out.len())
12236        .ok()
12237        .and_then(|end| end.checked_sub(central_offset))
12238        .ok_or_else(|| LinkError::PushTooLarge {
12239            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
12240        })?;
12241    let entry_count = central.len() as u16;
12242
12243    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
12244    push_u16(&mut out, 0); // this disk
12245    push_u16(&mut out, 0); // central directory disk
12246    push_u16(&mut out, entry_count);
12247    push_u16(&mut out, entry_count);
12248    push_u32(&mut out, central_size);
12249    push_u32(&mut out, central_offset);
12250    push_u16(&mut out, 0); // no archive comment
12251
12252    if out.len() > u32::MAX as usize {
12253        return Err(LinkError::PushTooLarge {
12254            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
12255        });
12256    }
12257    Ok(out)
12258}
12259
12260// ─────────────────────────────────────────────────────────────────────────────
12261// grant — issue / list / revoke capabilities (owner-side)
12262// ─────────────────────────────────────────────────────────────────────────────
12263
12264/// The two capabilities a v0 hub enforces.
12265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12266pub enum Capability {
12267    /// Read the granted slice.
12268    Read,
12269    /// Read and push (whole-store; a path-scoped grant is read-only).
12270    Write,
12271}
12272
12273impl Capability {
12274    /// The wire form.
12275    pub fn as_str(self) -> &'static str {
12276        match self {
12277            Capability::Read => "read",
12278            Capability::Write => "write",
12279        }
12280    }
12281}
12282
12283/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
12284/// by email in v0 (the protocol's near-term simplification; key-named
12285/// grantees arrive with the signing layer). `scope` is a store-path prefix
12286/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
12287/// revoked.
12288pub fn grant_issue(
12289    cfg: &HubConfig,
12290    brain: &str,
12291    grantee: &str,
12292    can: Capability,
12293    scope: Option<&str>,
12294    until: Option<&str>,
12295) -> LinkResult<Value> {
12296    require_safe_ref(brain)?;
12297    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
12298    // multikey holder (link.md §6 cross-party keys — no hub account; the
12299    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
12300    // hub principal named by email.
12301    let is_key_grantee = URL_SAFE_NO_PAD
12302        .decode(grantee)
12303        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
12304        .unwrap_or(false);
12305    if let Some(head) = v2_verified_head(cfg, brain)? {
12306        if is_key_grantee {
12307            let scope = scope.unwrap_or("");
12308            let preset = match can {
12309                Capability::Read => "viewer",
12310                Capability::Write => "editor",
12311            };
12312            let entropy = format!(
12313                "{}\0{}\0{}\0{}\0{}\0{}\0{}",
12314                normalized_origin(&cfg.hub)?,
12315                head.brain_id,
12316                head.control_revision,
12317                grantee,
12318                preset,
12319                scope,
12320                until.unwrap_or("")
12321            );
12322            let mut body = json!({
12323                "context": "external",
12324                "expected_control_revision": head.control_revision,
12325                "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
12326                "preset": preset,
12327                "principal_kind": "key",
12328                "public_key": grantee,
12329                "scope": scope,
12330                "scope_kind": "prefix",
12331            });
12332            if let Some(value) = until {
12333                body["expires_at"] = json!(value);
12334            }
12335            let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
12336            let response = ensure_ok(
12337                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12338                "v2 grant issue",
12339            )?;
12340            let expected_fingerprint = identity_fingerprint(grantee)?;
12341            if response.get("v").and_then(Value::as_u64) != Some(2)
12342                || response
12343                    .get("id")
12344                    .and_then(Value::as_str)
12345                    .is_none_or(|id| !crate::ulid::is_ulid(id))
12346                || response.get("principal_kind").and_then(Value::as_str) != Some("key")
12347                || response.get("principal_id").and_then(Value::as_str)
12348                    != Some(expected_fingerprint.as_str())
12349                || response
12350                    .get("control_revision")
12351                    .and_then(Value::as_str)
12352                    .is_none_or(|value| !is_sha256(value))
12353            {
12354                return Err(invalid_feed(
12355                    "v2 grant issue response is not authority-bound",
12356                ));
12357            }
12358            return Ok(response);
12359        }
12360        // Account invitations remain a product workflow. The compatibility
12361        // endpoint resolves email/account identity, then commits a native v2
12362        // grant with the same control-plane recovery boundary. The v2 head
12363        // above is already the authenticated authority checkpoint; never
12364        // fall through to the retired v1 feed verifier for this request.
12365        let mut body = json!({ "email": grantee, "capability": can.as_str() });
12366        if let Some(value) = scope {
12367            body["scopePrefix"] = json!(value);
12368        }
12369        if let Some(value) = until {
12370            body["expiresAt"] = json!(value);
12371        }
12372        let path = format!("/api/hub/brains/{}/grants", head.brain_id);
12373        return ensure_ok(
12374            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12375            "account grant issue",
12376        );
12377    }
12378    let _ = verified_remote_head(cfg, brain, false)?;
12379    let mut body = if is_key_grantee {
12380        json!({ "keySpki": grantee, "capability": can.as_str() })
12381    } else {
12382        json!({ "email": grantee, "capability": can.as_str() })
12383    };
12384    if let Some(s) = scope {
12385        body["scopePrefix"] = json!(s);
12386    }
12387    if let Some(u) = until {
12388        body["expiresAt"] = json!(u);
12389    }
12390    let path = format!("/api/hub/brains/{brain}/grants");
12391    ensure_ok(
12392        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12393        "grant issue",
12394    )
12395}
12396
12397/// List the active grants (and pending invites) on `brain`. Owner-side.
12398pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
12399    require_safe_ref(brain)?;
12400    if let Some(head) = v2_verified_head(cfg, brain)? {
12401        let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
12402        let response = ensure_ok(
12403            request(cfg, "GET", &path, None, Auth::Required)?,
12404            "v2 grant list",
12405        )?;
12406        if response.get("v").and_then(Value::as_u64) != Some(2)
12407            || response.get("control_revision").and_then(Value::as_str)
12408                != Some(head.control_revision.as_str())
12409            || !response.get("grants").is_some_and(Value::is_array)
12410        {
12411            return Err(invalid_feed(
12412                "v2 grant list is not bound to the verified authority",
12413            ));
12414        }
12415        return Ok(response);
12416    }
12417    let _ = verified_remote_head(cfg, brain, false)?;
12418    let path = format!("/api/hub/brains/{brain}/grants");
12419    ensure_ok(
12420        request(cfg, "GET", &path, None, Auth::Required)?,
12421        "grant list",
12422    )
12423}
12424
12425/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
12426/// is soft on the hub (the audit trail survives).
12427pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
12428    require_safe_ref(brain)?;
12429    require_safe_grant_id(grant_id)?;
12430    if let Some(head) = v2_verified_head(cfg, brain)? {
12431        let entropy = format!(
12432            "{}\0{}\0{}\0{}",
12433            normalized_origin(&cfg.hub)?,
12434            head.brain_id,
12435            head.control_revision,
12436            grant_id
12437        );
12438        let body = json!({
12439            "expected_control_revision": head.control_revision,
12440            "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
12441        });
12442        let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
12443        let response = ensure_ok(
12444            request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12445            "v2 grant revoke",
12446        )?;
12447        if response.get("v").and_then(Value::as_u64) != Some(2)
12448            || response.get("id").and_then(Value::as_str) != Some(grant_id)
12449            || response.get("revoked").and_then(Value::as_bool) != Some(true)
12450            || response
12451                .get("control_revision")
12452                .and_then(Value::as_str)
12453                .is_none_or(|value| !is_sha256(value))
12454        {
12455            return Err(invalid_feed(
12456                "v2 grant revocation response is not authority-bound",
12457            ));
12458        }
12459        return Ok(response);
12460    }
12461    let _ = verified_remote_head(cfg, brain, false)?;
12462    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
12463    ensure_ok(
12464        request(cfg, "DELETE", &path, None, Auth::Required)?,
12465        "grant revoke",
12466    )
12467}
12468
12469// ─────────────────────────────────────────────────────────────────────────────
12470// permissioned v2 proposals — inspect, accept, reject
12471// ─────────────────────────────────────────────────────────────────────────────
12472
12473#[derive(Debug)]
12474struct VerifiedV2Proposal {
12475    value: Value,
12476    changes: Value,
12477    blobs: Vec<(String, u64, String)>,
12478}
12479
12480fn require_proposal_id(id: &str) -> LinkResult<()> {
12481    if crate::ulid::is_ulid(id) {
12482        Ok(())
12483    } else {
12484        Err(invalid_feed("proposal id is not a lowercase ULID"))
12485    }
12486}
12487
12488fn verified_v2_proposal(
12489    cfg: &HubConfig,
12490    head: &V2VerifiedHead,
12491    proposal_id: &str,
12492) -> LinkResult<VerifiedV2Proposal> {
12493    require_proposal_id(proposal_id)?;
12494    if head.view_kind != "full" {
12495        return Err(invalid_feed(
12496            "proposal review requires a full readable view",
12497        ));
12498    }
12499    let path = format!(
12500        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12501        head.brain_id
12502    );
12503    let value = ensure_ok(
12504        request_capped(
12505            cfg,
12506            "GET",
12507            &path,
12508            None,
12509            Auth::Required,
12510            MAX_FEED_RESPONSE_BYTES,
12511        )?,
12512        "v2 proposal",
12513    )?;
12514    verify_v2_proposal_value(head, proposal_id, value)
12515}
12516
12517fn verify_v2_proposal_value(
12518    head: &V2VerifiedHead,
12519    proposal_id: &str,
12520    value: Value,
12521) -> LinkResult<VerifiedV2Proposal> {
12522    if value.get("v").and_then(Value::as_u64) != Some(2) {
12523        return Err(invalid_feed("proposal response has an invalid version"));
12524    }
12525    let proposal = value
12526        .get("proposal")
12527        .and_then(Value::as_object)
12528        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
12529    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
12530        return Err(invalid_feed("proposal response changed its id"));
12531    }
12532    let payload_hash = proposal
12533        .get("payload_sha256")
12534        .and_then(Value::as_str)
12535        .filter(|hash| is_sha256(hash))
12536        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
12537    let clear_hash = proposal
12538        .get("clear_sha256")
12539        .and_then(Value::as_str)
12540        .filter(|hash| is_sha256(hash))
12541        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
12542    let submission_hash = proposal
12543        .get("submission_claim_sha256")
12544        .and_then(Value::as_str)
12545        .filter(|hash| is_sha256(hash))
12546        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
12547    let submission = STANDARD
12548        .decode(
12549            proposal
12550                .get("submission_claim_base64")
12551                .and_then(Value::as_str)
12552                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
12553        )
12554        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
12555    let submission_value: Value = serde_json::from_slice(&submission)
12556        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
12557    if crate::linkmd_v2::canonical_bytes(&submission_value)
12558        .map_err(|error| invalid_feed(error.to_string()))?
12559        != submission
12560        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
12561            .map_err(|error| invalid_feed(error.to_string()))?
12562            != submission_hash
12563    {
12564        return Err(invalid_feed(
12565            "proposal submission claim is not canonical or addressed",
12566        ));
12567    }
12568    let envelope = submission_value
12569        .as_object()
12570        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
12571    let claim = envelope
12572        .get("claim")
12573        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
12574    let claim_object = claim
12575        .as_object()
12576        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
12577    let actor_root = claim_object
12578        .get("actor_root")
12579        .and_then(Value::as_object)
12580        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
12581    let public_key = envelope
12582        .get("public_key")
12583        .and_then(Value::as_str)
12584        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
12585    let fingerprint = envelope
12586        .get("fingerprint")
12587        .and_then(Value::as_str)
12588        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
12589    let signature = envelope
12590        .get("sig")
12591        .and_then(Value::as_str)
12592        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
12593    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
12594        .map_err(|error| invalid_feed(error.to_string()))?;
12595    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
12596    let signer = format!("{fingerprint}:{public_key}");
12597    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
12598    let grants = actor_root.get("grants").and_then(Value::as_array);
12599    let grants_are_canonical = grants.is_some_and(|items| {
12600        let mut prior: Option<&str> = None;
12601        items.iter().all(|item| {
12602            let Some(grant) = item.as_str() else {
12603                return false;
12604            };
12605            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
12606                return false;
12607            }
12608            prior = Some(grant);
12609            true
12610        })
12611    });
12612    let optional_actor_field = |name: &str| {
12613        actor_root.get(name).is_some_and(|value| {
12614            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
12615        })
12616    };
12617    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
12618    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
12619        || format!("{:x}", Sha256::digest(&der)) != fingerprint
12620        || head
12621            .trust
12622            .hub_signer
12623            .as_ref()
12624            .is_some_and(|known| known != &signer)
12625        || !matches!(
12626            actor_class,
12627            Some(
12628                "user"
12629                    | "owned_agent"
12630                    | "foreign_key"
12631                    | "curation"
12632                    | "inbox"
12633                    | "restore"
12634                    | "migration"
12635                    | "operator_recovery"
12636            )
12637        )
12638        || actor_root
12639            .get("principal")
12640            .and_then(Value::as_str)
12641            .is_none_or(|value| value.is_empty())
12642        || actor_root
12643            .get("credential")
12644            .and_then(Value::as_str)
12645            .is_none_or(|value| value.is_empty())
12646        || !optional_actor_field("organization")
12647        || !optional_actor_field("role")
12648        || !grants_are_canonical
12649        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
12650        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12651        || !claim_object
12652            .get("mutation_id")
12653            .and_then(Value::as_str)
12654            .is_some_and(|value| {
12655                !value.is_empty()
12656                    && value.len() <= 128
12657                    && value.chars().enumerate().all(|(index, char)| {
12658                        char.is_ascii_alphanumeric()
12659                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
12660                    })
12661            })
12662        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
12663        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
12664        || !claim_object
12665            .get("control_revision")
12666            .and_then(Value::as_str)
12667            .is_some_and(is_sha256)
12668        || submitted_at.is_none_or(|value| {
12669            chrono::DateTime::parse_from_rfc3339(value).is_err()
12670                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
12671        })
12672        || !proposal
12673            .get("state")
12674            .and_then(Value::as_str)
12675            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
12676        || proposal
12677            .get("expires_at")
12678            .and_then(Value::as_str)
12679            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
12680        || proposal
12681            .get("proposer")
12682            .and_then(Value::as_object)
12683            .and_then(|value| value.get("class"))
12684            .and_then(Value::as_str)
12685            != actor_class
12686    {
12687        return Err(invalid_feed(
12688            "proposal submission claim does not bind the verified proposal",
12689        ));
12690    }
12691    let changes_b64 = proposal
12692        .get("changes_base64")
12693        .and_then(Value::as_str)
12694        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
12695    let changes_bytes = STANDARD
12696        .decode(changes_b64)
12697        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
12698    let changes: Value = serde_json::from_slice(&changes_bytes)
12699        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
12700    if crate::linkmd_v2::canonical_bytes(&changes)
12701        .map_err(|error| invalid_feed(error.to_string()))?
12702        != changes_bytes
12703        || changes.get("v").and_then(Value::as_u64) != Some(2)
12704        || !changes.get("operations").is_some_and(Value::is_array)
12705    {
12706        return Err(invalid_feed("proposal changeset is not canonical v2"));
12707    }
12708    let blob_values = proposal
12709        .get("blobs")
12710        .and_then(Value::as_array)
12711        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
12712    let mut blobs = Vec::with_capacity(blob_values.len());
12713    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
12714    let mut prior_hash: Option<String> = None;
12715    for item in blob_values {
12716        let hash = item
12717            .get("sha256")
12718            .and_then(Value::as_str)
12719            .filter(|hash| is_sha256(hash))
12720            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
12721        let bytes = item
12722            .get("bytes")
12723            .and_then(Value::as_u64)
12724            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
12725            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
12726        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
12727            return Err(invalid_feed(
12728                "proposal blob declarations are not unique and sorted",
12729            ));
12730        }
12731        prior_hash = Some(hash.to_string());
12732        let endpoint = item
12733            .get("endpoint")
12734            .and_then(Value::as_str)
12735            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
12736        let expected_endpoint = format!(
12737            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
12738            head.brain_id
12739        );
12740        if endpoint != expected_endpoint {
12741            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
12742        }
12743        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
12744        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
12745    }
12746    let descriptor = json!({
12747        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
12748        "blobs": descriptor_blobs,
12749        "changes_base64": changes_b64,
12750        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
12751        "v": 2,
12752    });
12753    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
12754        .map_err(|error| invalid_feed(error.to_string()))?;
12755    if content_sha256(&descriptor_bytes) != clear_hash {
12756        return Err(invalid_feed(
12757            "proposal clear payload differs from its signed submission claim",
12758        ));
12759    }
12760    Ok(VerifiedV2Proposal {
12761        value,
12762        changes,
12763        blobs,
12764    })
12765}
12766
12767pub fn proposal_list(
12768    cfg: &HubConfig,
12769    brain: &str,
12770    state: &str,
12771    after: Option<&str>,
12772    limit: usize,
12773) -> LinkResult<Value> {
12774    require_safe_ref(brain)?;
12775    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
12776        return Err(invalid_feed("proposal state is invalid"));
12777    }
12778    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
12779        return Err(invalid_feed("proposal cursor is invalid"));
12780    }
12781    let head = v2_verified_head(cfg, brain)?
12782        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12783    let path = format!(
12784        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
12785        head.brain_id,
12786        limit.clamp(1, 100),
12787        after.map_or_else(String::new, |value| format!("&after={value}"))
12788    );
12789    ensure_ok(
12790        request_capped(
12791            cfg,
12792            "GET",
12793            &path,
12794            None,
12795            Auth::Required,
12796            MAX_FEED_RESPONSE_BYTES,
12797        )?,
12798        "v2 proposal list",
12799    )
12800}
12801
12802pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
12803    require_safe_ref(brain)?;
12804    let head = v2_verified_head(cfg, brain)?
12805        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12806    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
12807}
12808
12809pub fn proposal_reject(
12810    cfg: &HubConfig,
12811    brain: &str,
12812    proposal_id: &str,
12813    mutation_id: &str,
12814    reason: &str,
12815) -> LinkResult<Value> {
12816    require_safe_ref(brain)?;
12817    require_proposal_id(proposal_id)?;
12818    let head = v2_verified_head(cfg, brain)?
12819        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12820    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
12821    let body = json!({
12822        "mutation_id": mutation_id,
12823        "control_revision": head.control_revision,
12824        "reason": reason,
12825    });
12826    let path = format!(
12827        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12828        head.brain_id
12829    );
12830    ensure_ok(
12831        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12832        "v2 proposal rejection",
12833    )
12834}
12835
12836pub fn proposal_accept_exact(
12837    cfg: &HubConfig,
12838    brain: &str,
12839    proposal_id: &str,
12840    mutation_id: &str,
12841    reason: &str,
12842) -> LinkResult<Value> {
12843    require_safe_ref(brain)?;
12844    require_proposal_id(proposal_id)?;
12845    let head = v2_verified_head(cfg, brain)?
12846        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12847    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
12848    let operations = proposal
12849        .changes
12850        .get("operations")
12851        .and_then(Value::as_array)
12852        .cloned()
12853        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
12854    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
12855        return Err(invalid_feed("proposal operation count is invalid"));
12856    }
12857    let mut downloaded = std::collections::BTreeMap::new();
12858    for (hash, bytes, endpoint) in &proposal.blobs {
12859        let body = ensure_raw_ok(
12860            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
12861            "v2 proposal blob",
12862        )?;
12863        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
12864            return Err(invalid_feed("proposal blob does not match its declaration"));
12865        }
12866        downloaded.insert(hash.clone(), body);
12867    }
12868    let remote = files_for_v2_view(
12869        &head,
12870        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
12871    );
12872    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
12873    let mut expected_candidate = remote.clone();
12874    let mut expected_candidate_assets = remote_assets;
12875    for operation in &operations {
12876        let op = operation
12877            .get("op")
12878            .and_then(Value::as_str)
12879            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
12880        match op {
12881            "put" | "put_asset_content" | "restore" => {
12882                let path = operation
12883                    .get("path")
12884                    .and_then(Value::as_str)
12885                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
12886                crate::linkmd_v2::normalize_path(path)
12887                    .map_err(|error| invalid_feed(error.to_string()))?;
12888                let hash = operation
12889                    .get("blob")
12890                    .and_then(Value::as_str)
12891                    .filter(|hash| is_sha256(hash))
12892                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12893                let bytes = operation
12894                    .get("bytes")
12895                    .and_then(Value::as_u64)
12896                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12897                expected_candidate.insert(
12898                    path.to_string(),
12899                    V2BaselineFile {
12900                        sha256: hash.to_string(),
12901                        bytes,
12902                        proof: None,
12903                    },
12904                );
12905            }
12906            "delete" | "withdraw_from_hosting" => {
12907                let path = operation
12908                    .get("path")
12909                    .and_then(Value::as_str)
12910                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12911                crate::linkmd_v2::normalize_path(path)
12912                    .map_err(|error| invalid_feed(error.to_string()))?;
12913                expected_candidate.remove(path);
12914            }
12915            "rename" => {
12916                let from = operation
12917                    .get("from")
12918                    .and_then(Value::as_str)
12919                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12920                let to = operation
12921                    .get("to")
12922                    .and_then(Value::as_str)
12923                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12924                crate::linkmd_v2::normalize_path(from)
12925                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
12926                    .map_err(|error| invalid_feed(error.to_string()))?;
12927                let hash = operation
12928                    .get("blob")
12929                    .and_then(Value::as_str)
12930                    .filter(|hash| is_sha256(hash))
12931                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12932                let bytes = operation
12933                    .get("bytes")
12934                    .and_then(Value::as_u64)
12935                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12936                expected_candidate.remove(from);
12937                expected_candidate.insert(
12938                    to.to_string(),
12939                    V2BaselineFile {
12940                        sha256: hash.to_string(),
12941                        bytes,
12942                        proof: None,
12943                    },
12944                );
12945            }
12946            "asset_delete" => {
12947                let path = operation
12948                    .get("path")
12949                    .and_then(Value::as_str)
12950                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12951                expected_candidate_assets.remove(path);
12952            }
12953            "asset_withdraw" => {
12954                let path = operation
12955                    .get("path")
12956                    .and_then(Value::as_str)
12957                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12958                if !expected_candidate_assets.contains_key(path) {
12959                    return Err(invalid_feed("proposal withdraws an unknown asset"));
12960                }
12961                let Some(asset) = operation.get("asset").and_then(Value::as_object) else {
12962                    // Pre-supersession proposals carried no replacement leaf.
12963                    // Preserve their exact historical meaning while all new
12964                    // pushes include the explicit withheld value.
12965                    let prior = expected_candidate_assets
12966                        .get_mut(path)
12967                        .expect("presence checked above");
12968                    prior.disposition = "withheld".to_string();
12969                    prior.leaf_hash.clear();
12970                    continue;
12971                };
12972                let blob_sha256 = asset
12973                    .get("blob_sha256")
12974                    .and_then(Value::as_str)
12975                    .filter(|hash| is_sha256(hash))
12976                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12977                let bytes = asset
12978                    .get("bytes")
12979                    .and_then(Value::as_u64)
12980                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12981                let media_type = asset
12982                    .get("media_type")
12983                    .and_then(Value::as_str)
12984                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12985                let wrappers = asset
12986                    .get("wrappers")
12987                    .and_then(Value::as_array)
12988                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12989                    .iter()
12990                    .map(|wrapper| {
12991                        wrapper
12992                            .as_str()
12993                            .map(str::to_string)
12994                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12995                    })
12996                    .collect::<LinkResult<Vec<_>>>()?;
12997                let required = asset
12998                    .get("required")
12999                    .and_then(Value::as_bool)
13000                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
13001                if asset.get("disposition").and_then(Value::as_str) != Some("withheld") {
13002                    return Err(invalid_feed("proposal asset withdrawal is not withheld"));
13003                }
13004                expected_candidate_assets.insert(
13005                    path.to_string(),
13006                    V2BaselineAsset {
13007                        blob_sha256: blob_sha256.to_string(),
13008                        bytes,
13009                        media_type: media_type.to_string(),
13010                        wrappers,
13011                        required,
13012                        disposition: "withheld".to_string(),
13013                        leaf_hash: String::new(),
13014                    },
13015                );
13016            }
13017            "asset_put" | "asset_resume" => {
13018                let path = operation
13019                    .get("path")
13020                    .and_then(Value::as_str)
13021                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
13022                let asset = operation
13023                    .get("asset")
13024                    .and_then(Value::as_object)
13025                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
13026                let blob_sha256 = asset
13027                    .get("blob_sha256")
13028                    .and_then(Value::as_str)
13029                    .filter(|hash| is_sha256(hash))
13030                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
13031                let bytes = asset
13032                    .get("bytes")
13033                    .and_then(Value::as_u64)
13034                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
13035                let media_type = asset
13036                    .get("media_type")
13037                    .and_then(Value::as_str)
13038                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
13039                let wrappers = asset
13040                    .get("wrappers")
13041                    .and_then(Value::as_array)
13042                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
13043                    .iter()
13044                    .map(|wrapper| {
13045                        wrapper
13046                            .as_str()
13047                            .map(str::to_string)
13048                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
13049                    })
13050                    .collect::<LinkResult<Vec<_>>>()?;
13051                let required = asset
13052                    .get("required")
13053                    .and_then(Value::as_bool)
13054                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
13055                let disposition = asset
13056                    .get("disposition")
13057                    .and_then(Value::as_str)
13058                    .filter(|value| matches!(*value, "hosted" | "withheld"))
13059                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
13060                expected_candidate_assets.insert(
13061                    path.to_string(),
13062                    V2BaselineAsset {
13063                        blob_sha256: blob_sha256.to_string(),
13064                        bytes,
13065                        media_type: media_type.to_string(),
13066                        wrappers,
13067                        required,
13068                        disposition: disposition.to_string(),
13069                        leaf_hash: String::new(),
13070                    },
13071                );
13072            }
13073            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
13074        }
13075    }
13076    let base = head.pointer.as_ref().map(|pointer| {
13077        json!({
13078            "seq": pointer.seq,
13079            "commit_hash": pointer.commit_hash,
13080            "content_root": pointer.content_root,
13081            "asset_root": pointer.asset_root,
13082        })
13083    });
13084    let mut body = json!({
13085        "mutation_id": mutation_id,
13086        "base": base,
13087        "rebase": "strict",
13088        "reason": reason,
13089        "operations": operations,
13090        "blobs": downloaded
13091            .iter()
13092            .map(|(sha256, bytes)| json!({
13093                "sha256": sha256,
13094                "bytes": bytes.len(),
13095                "content_base64": STANDARD.encode(bytes),
13096            }))
13097            .collect::<Vec<_>>(),
13098        "proposal_id": proposal_id,
13099        "proposal_mode": "exact",
13100    });
13101    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
13102        total
13103            .checked_add(bytes.len())
13104            .ok_or_else(|| LinkError::PushTooLarge {
13105                detail: "proposal changed-byte total overflow".to_string(),
13106            })
13107    })?;
13108    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
13109        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
13110        for operation in &operations {
13111            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
13112                return Err(invalid_feed("proposal upload operation has no kind"));
13113            };
13114            let hash = match kind {
13115                "put" | "put_asset_content" | "restore" | "rename" => {
13116                    operation.get("blob").and_then(Value::as_str)
13117                }
13118                "asset_put" | "asset_resume" => operation
13119                    .get("asset")
13120                    .and_then(|asset| asset.get("blob_sha256"))
13121                    .and_then(Value::as_str),
13122                _ => None,
13123            };
13124            let Some(hash) = hash else { continue };
13125            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
13126            if kind == "rename" {
13127                for field in ["from", "to"] {
13128                    coordinates.insert(
13129                        operation
13130                            .get(field)
13131                            .and_then(Value::as_str)
13132                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
13133                            .to_string(),
13134                    );
13135                }
13136            } else {
13137                let path = operation
13138                    .get("path")
13139                    .and_then(Value::as_str)
13140                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
13141                coordinates.insert(if kind.starts_with("asset_") {
13142                    format!("assets/{path}")
13143                } else {
13144                    path.to_string()
13145                });
13146            }
13147        }
13148        let declarations = downloaded
13149            .iter()
13150            .map(|(sha256, bytes)| {
13151                json!({
13152                    "sha256": sha256,
13153                    "bytes": bytes.len(),
13154                    "coordinates": coordinates_by_hash
13155                        .get(sha256)
13156                        .into_iter()
13157                        .flatten()
13158                        .collect::<Vec<_>>(),
13159                })
13160            })
13161            .collect::<Vec<_>>();
13162        let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
13163        for batch in batch_upload_declarations(declarations) {
13164            let reserved = reserve_upload_window(
13165                cfg,
13166                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
13167                &json!({ "blobs": batch }),
13168                "prepare proposal blob transport",
13169            )?;
13170            let reserved_items = reserved
13171                .get("uploads")
13172                .and_then(Value::as_array)
13173                .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
13174            items.extend(reserved_items.iter().cloned());
13175        }
13176        if items.len() != downloaded.len() {
13177            return Err(invalid_feed("proposal upload reservation changed the set"));
13178        }
13179        let mut references = Vec::with_capacity(items.len());
13180        for item in items {
13181            let hash = item
13182                .get("sha256")
13183                .and_then(Value::as_str)
13184                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
13185            let bytes = downloaded
13186                .get(hash)
13187                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
13188            let reservation_id = item
13189                .get("reservation_id")
13190                .and_then(Value::as_str)
13191                .filter(|id| crate::ulid::is_ulid(id))
13192                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
13193            let expected_coordinates = coordinates_by_hash
13194                .get(hash)
13195                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
13196            let returned_coordinates = item
13197                .get("coordinates")
13198                .and_then(Value::as_array)
13199                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
13200            if returned_coordinates.len() != expected_coordinates.len()
13201                || returned_coordinates
13202                    .iter()
13203                    .zip(expected_coordinates)
13204                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
13205            {
13206                return Err(invalid_feed(
13207                    "proposal upload reservation changed its coordinates",
13208                ));
13209            }
13210            match item.get("status").and_then(Value::as_str) {
13211                Some("upload") => put_presigned(
13212                    cfg,
13213                    item.get("url")
13214                        .and_then(Value::as_str)
13215                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
13216                    item.get("headers").unwrap_or(&Value::Null),
13217                    bytes,
13218                )?,
13219                Some("already_present") => {}
13220                _ => return Err(invalid_feed("proposal upload status is invalid")),
13221            }
13222            references.push(json!({
13223                "sha256": hash,
13224                "bytes": bytes.len(),
13225                "reservation_id": reservation_id,
13226            }));
13227        }
13228        body["blobs"] = Value::Array(references);
13229    }
13230    // A proposal can be queued through the staged lane, so acceptance has to be
13231    // able to restate it. Capping only this side would make a large proposal
13232    // permanently unacceptable.
13233    stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
13234    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
13235    let mut result = ensure_ok(
13236        request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
13237        "exact proposal acceptance",
13238    )?;
13239    let mut candidate_hub_signer = None;
13240    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
13241        let request_id = result
13242            .get("request_id")
13243            .and_then(Value::as_str)
13244            .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
13245            .to_string();
13246        let challenge = result
13247            .get("signing_challenge")
13248            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
13249        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
13250            cfg,
13251            &head,
13252            &expected_candidate,
13253            &expected_candidate_assets,
13254            mutation_id,
13255            &v2_signed_request_view(&body, &operations),
13256            challenge,
13257        )?;
13258        body["signing_challenge_id"] = Value::String(challenge_id);
13259        body["signature_base64url"] = Value::String(signature);
13260        candidate_hub_signer = Some(actor_signer);
13261        result = ensure_ok(
13262            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
13263            "signed exact proposal acceptance",
13264        )?;
13265    }
13266    let refreshed = v2_verified_head(cfg, brain)?
13267        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
13268    if candidate_hub_signer
13269        .as_ref()
13270        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
13271        || refreshed
13272            .pointer
13273            .as_ref()
13274            .map(|pointer| pointer.commit_hash.as_str())
13275            != result.get("commit_hash").and_then(Value::as_str)
13276        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
13277        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
13278    {
13279        return Err(LinkError::RemoteAdvancedDuringSync);
13280    }
13281    accept_v2_head(cfg, &refreshed)?;
13282    Ok(result)
13283}
13284
13285// ─────────────────────────────────────────────────────────────────────────────
13286// propose — write without trust: evidence into the owner's inbox
13287// ─────────────────────────────────────────────────────────────────────────────
13288
13289/// Submit `body` to the published site `handle`, addressed to its app page
13290/// `app` (a page that declares the `write-inbox` capability). Deliberately
13291/// unauthenticated — this is the cross-party door; the submission lands as
13292/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
13293/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
13294/// receipt.
13295pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
13296    require_valid_handle(handle)?;
13297    if body.len() as u64 > MAX_PROPOSE_BYTES {
13298        return Err(LinkError::ProposeTooLarge {
13299            bytes: body.len() as u64,
13300        });
13301    }
13302    let payload = json!({ "app": app, "body": body });
13303    // A ULID-shaped target is a bare brain address (link.md §7.4's
13304    // generalization): the brain inbox door, open on public brains, where a
13305    // configured credential earns a bigger actor-class budget. Anything else
13306    // is a published-site handle: that door is unauthenticated by design.
13307    let (path, auth) = if crate::ulid::is_ulid(handle) {
13308        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
13309    } else {
13310        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
13311    };
13312    ensure_ok(
13313        request(cfg, "POST", &path, Some(&payload), auth)?,
13314        "propose",
13315    )
13316}
13317
13318// ─────────────────────────────────────────────────────────────────────────────
13319// subscribe — follow feed-head movement
13320// ─────────────────────────────────────────────────────────────────────────────
13321
13322/// One observation of a brain's feed head.
13323#[derive(Debug, serde::Serialize)]
13324pub struct Head {
13325    /// The brain id.
13326    pub brain: String,
13327    /// The hub's durable feed cursor — advances on every accepted write.
13328    pub seq: u64,
13329    /// The hub's `updatedAt` for the brain, when present.
13330    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
13331    pub updated_at: Option<String>,
13332    /// SHA-256 of the exact signed head entry.
13333    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
13334    pub feed_hash: Option<String>,
13335    /// Whether the head entry's content hash, identity, and Ed25519 signature
13336    /// were verified locally. Path-scoped grants get head movement only.
13337    pub verified: bool,
13338}
13339
13340struct BoundedVecVisitor<T, const MAX: usize> {
13341    label: &'static str,
13342    marker: std::marker::PhantomData<T>,
13343}
13344
13345impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
13346where
13347    T: Deserialize<'de>,
13348{
13349    type Value = Vec<T>;
13350
13351    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13352        write!(formatter, "at most {MAX} {}", self.label)
13353    }
13354
13355    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
13356    where
13357        A: serde::de::SeqAccess<'de>,
13358    {
13359        if sequence.size_hint().is_some_and(|size| size > MAX) {
13360            return Err(serde::de::Error::custom(format!(
13361                "{} exceeds the {MAX}-item limit",
13362                self.label
13363            )));
13364        }
13365        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
13366        while let Some(value) = sequence.next_element()? {
13367            if values.len() == MAX {
13368                return Err(serde::de::Error::custom(format!(
13369                    "{} exceeds the {MAX}-item limit",
13370                    self.label
13371                )));
13372            }
13373            values.push(value);
13374        }
13375        Ok(values)
13376    }
13377}
13378
13379fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
13380    deserializer: D,
13381    label: &'static str,
13382) -> Result<Vec<T>, D::Error>
13383where
13384    D: serde::Deserializer<'de>,
13385    T: Deserialize<'de>,
13386{
13387    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
13388        label,
13389        marker: std::marker::PhantomData,
13390    })
13391}
13392
13393fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
13394where
13395    D: serde::Deserializer<'de>,
13396{
13397    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
13398}
13399
13400fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
13401where
13402    D: serde::Deserializer<'de>,
13403{
13404    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
13405}
13406
13407fn deserialize_previous_identities<'de, D>(
13408    deserializer: D,
13409) -> Result<Vec<PreviousIdentity>, D::Error>
13410where
13411    D: serde::Deserializer<'de>,
13412{
13413    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
13414        deserializer,
13415        "previous identities",
13416    )
13417}
13418
13419fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
13420where
13421    D: serde::Deserializer<'de>,
13422{
13423    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
13424        deserializer,
13425        "rotation statements",
13426    )
13427}
13428
13429fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
13430where
13431    D: serde::Deserializer<'de>,
13432{
13433    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
13434}
13435
13436#[derive(Debug, Clone, Deserialize, Serialize)]
13437struct FeedFile {
13438    path: String,
13439    sha256: String,
13440    bytes: u64,
13441}
13442
13443#[cfg(test)]
13444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13445enum V1DisclosureError {
13446    DuplicateFile,
13447    DuplicateRemoved,
13448    PushManifestMismatch,
13449    EditMissingChange,
13450    EditFalseFile,
13451    RemovedMismatch,
13452}
13453
13454/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
13455/// the exact previous and resulting pack manifests. `edit` accepts the
13456/// historical superset form while still requiring every actual changed path.
13457#[cfg(test)]
13458fn verify_v1_manifest_disclosure(
13459    kind: &str,
13460    previous: &[FeedFile],
13461    resulting: &[FeedFile],
13462    files: &[FeedFile],
13463    removed: &[String],
13464) -> Result<(), V1DisclosureError> {
13465    fn as_map(
13466        files: &[FeedFile],
13467    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
13468        let mut result = std::collections::BTreeMap::new();
13469        for file in files {
13470            if result
13471                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
13472                .is_some()
13473            {
13474                return Err(V1DisclosureError::DuplicateFile);
13475            }
13476        }
13477        Ok(result)
13478    }
13479    let previous = as_map(previous)?;
13480    let resulting = as_map(resulting)?;
13481    let disclosed = as_map(files)?;
13482    let removed_set: std::collections::BTreeSet<&str> =
13483        removed.iter().map(String::as_str).collect();
13484    if removed_set.len() != removed.len() {
13485        return Err(V1DisclosureError::DuplicateRemoved);
13486    }
13487    let expected_removed: std::collections::BTreeSet<&str> = previous
13488        .keys()
13489        .copied()
13490        .filter(|path| !resulting.contains_key(path))
13491        .collect();
13492    if removed_set != expected_removed {
13493        return Err(V1DisclosureError::RemovedMismatch);
13494    }
13495    if kind == "push" {
13496        return if disclosed == resulting {
13497            Ok(())
13498        } else {
13499            Err(V1DisclosureError::PushManifestMismatch)
13500        };
13501    }
13502    if kind != "edit" {
13503        return Err(V1DisclosureError::EditFalseFile);
13504    }
13505    if disclosed
13506        .iter()
13507        .any(|(path, value)| resulting.get(path) != Some(value))
13508    {
13509        return Err(V1DisclosureError::EditFalseFile);
13510    }
13511    for (path, value) in &resulting {
13512        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
13513            return Err(V1DisclosureError::EditMissingChange);
13514        }
13515    }
13516    Ok(())
13517}
13518
13519#[derive(Debug, Clone, Deserialize, Serialize)]
13520struct FeedEntry {
13521    v: u8,
13522    seq: u64,
13523    ts: String,
13524    brain: String,
13525    public_key: String,
13526    kind: String,
13527    op: String,
13528    pack_sha256: String,
13529    #[serde(deserialize_with = "deserialize_feed_files")]
13530    files: Vec<FeedFile>,
13531    #[serde(deserialize_with = "deserialize_removed_paths")]
13532    removed: Vec<String>,
13533    prev_entry_hash: Option<String>,
13534    sig: String,
13535}
13536
13537#[derive(Serialize)]
13538struct UnsignedFeedEntry<'a> {
13539    v: u8,
13540    seq: u64,
13541    ts: &'a str,
13542    brain: &'a str,
13543    public_key: &'a str,
13544    kind: &'a str,
13545    op: &'a str,
13546    pack_sha256: &'a str,
13547    files: &'a [FeedFile],
13548    removed: &'a [String],
13549    prev_entry_hash: &'a Option<String>,
13550}
13551
13552#[derive(Debug, Clone, Deserialize, Serialize)]
13553struct FeedItem {
13554    hash: String,
13555    entry: FeedEntry,
13556}
13557
13558#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13559struct FeedIdentity {
13560    fingerprint: String,
13561    #[serde(rename = "publicKeySpki")]
13562    public_key_spki: String,
13563    /// Rotation history (link.md §9.1): identities this brain previously
13564    /// signed as. Entries verify against current OR previous — rotation
13565    /// never invalidates history.
13566    #[serde(default, deserialize_with = "deserialize_previous_identities")]
13567    previous: Vec<PreviousIdentity>,
13568    /// Exact normative rotation statements, oldest first. A list of previous
13569    /// public keys without these old-key signatures is not a trust chain.
13570    #[serde(default, deserialize_with = "deserialize_rotations")]
13571    rotations: Vec<String>,
13572}
13573
13574#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13575struct PreviousIdentity {
13576    fingerprint: String,
13577    #[serde(rename = "publicKeySpki")]
13578    public_key_spki: String,
13579}
13580
13581#[derive(Debug, Deserialize)]
13582struct FeedResponse {
13583    #[serde(rename = "headSeq")]
13584    head_seq: u64,
13585    #[serde(rename = "feedHash")]
13586    feed_hash: Option<String>,
13587    identity: Option<FeedIdentity>,
13588    #[serde(deserialize_with = "deserialize_feed_items")]
13589    entries: Vec<FeedItem>,
13590    #[serde(rename = "scopeLimited")]
13591    scope_limited: bool,
13592}
13593
13594#[derive(Debug, Deserialize, Serialize)]
13595#[serde(deny_unknown_fields)]
13596struct RotationStatement {
13597    v: u8,
13598    op: String,
13599    brain: String,
13600    public_key: String,
13601    new_brain: String,
13602    new_public_key: String,
13603    prior_head_seq: u64,
13604    prior_feed_hash: Option<String>,
13605    ts: String,
13606    sig: String,
13607}
13608
13609#[derive(Debug, Clone, Deserialize, Serialize)]
13610struct TrustState {
13611    v: u8,
13612    origin: String,
13613    /// The exact caller-visible ref whose resolution this checkpoint pins.
13614    /// Slugs/handles are mutable names at the hub; once observed, they may not
13615    /// silently resolve to a different canonical brain.
13616    #[serde(default)]
13617    requested: String,
13618    /// Canonical hub brain id returned for `requested`.
13619    brain: String,
13620    /// Federation home origin when this ref was learned from a registry.
13621    /// Once observed, the registry cannot silently relocate the same handle.
13622    #[serde(default, skip_serializing_if = "Option::is_none")]
13623    home: Option<String>,
13624    anchor: String,
13625    current: String,
13626    #[serde(rename = "headSeq")]
13627    head_seq: u64,
13628    #[serde(rename = "feedHash")]
13629    feed_hash: Option<String>,
13630    /// Exact accepted old-key-signed rotation statements, oldest first. v2
13631    /// checkpoints require this vector to be an immutable prefix of every
13632    /// subsequently served identity chain.
13633    #[serde(default)]
13634    rotations: Vec<String>,
13635    /// Hub pointer-signing identity pinned on first v2 observation. This is
13636    /// separate from the brain key, which signs commits.
13637    #[serde(default, skip_serializing_if = "Option::is_none")]
13638    hub_signer: Option<String>,
13639    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
13640    /// are recognized by their pinned hub signer during one-way migration.
13641    #[serde(default, skip_serializing_if = "Option::is_none")]
13642    protocol_profile: Option<String>,
13643}
13644
13645fn accepted_as_v2(state: &TrustState) -> bool {
13646    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
13647}
13648
13649fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
13650    let directory = open_trust_dir(cfg)?;
13651    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
13652        return Ok(true);
13653    }
13654    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
13655        return Ok(false);
13656    };
13657    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
13658}
13659
13660#[derive(Debug, Clone, Deserialize, Serialize)]
13661struct AliasBinding {
13662    v: u8,
13663    origin: String,
13664    requested: String,
13665    brain: String,
13666    #[serde(default, skip_serializing_if = "Option::is_none")]
13667    home: Option<String>,
13668}
13669
13670struct VerifiedRemote {
13671    head: Head,
13672    identity: Option<FeedIdentity>,
13673    head_entry: Option<FeedItem>,
13674    /// Present when the caller requested and verified the complete chain.
13675    entries: Vec<FeedItem>,
13676    anchor: Option<String>,
13677}
13678
13679fn invalid_feed(message: impl Into<String>) -> LinkError {
13680    LinkError::InvalidFeed {
13681        message: message.into(),
13682    }
13683}
13684
13685fn is_sha256(value: &str) -> bool {
13686    value.len() == 64
13687        && value
13688            .bytes()
13689            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
13690}
13691
13692fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
13693    let der = URL_SAFE_NO_PAD
13694        .decode(public_key_spki)
13695        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
13696    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
13697        return Err(invalid_feed(
13698            "identity public key is not a valid Ed25519 SPKI",
13699        ));
13700    }
13701    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
13702}
13703
13704/// Verify the hub's current identity and every old-key-signed rotation that
13705/// leads from the original TOFU anchor to it. `previous` alone is metadata,
13706/// never authority.
13707fn verify_identity_chain(
13708    identity: &FeedIdentity,
13709    pinned: Option<&TrustState>,
13710) -> LinkResult<String> {
13711    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
13712        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
13713    {
13714        return Err(invalid_feed(
13715            "identity rotation history exceeds the client cap",
13716        ));
13717    }
13718    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
13719        return Err(invalid_feed(
13720            "current identity fingerprint does not match its public key",
13721        ));
13722    }
13723    for previous in &identity.previous {
13724        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
13725            return Err(invalid_feed(
13726                "previous identity fingerprint does not match its public key",
13727            ));
13728        }
13729    }
13730    if identity.rotations.len() != identity.previous.len() {
13731        return Err(invalid_feed(
13732            "identity history is missing an old-key-signed rotation statement",
13733        ));
13734    }
13735
13736    // The HTTP identity lists prior identities newest first. Rotation
13737    // statements are chronological, so verification walks the reversed list
13738    // and ends at the current identity.
13739    let mut chain: Vec<(&str, &str)> = identity
13740        .previous
13741        .iter()
13742        .rev()
13743        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
13744        .collect();
13745    chain.push((&identity.fingerprint, &identity.public_key_spki));
13746
13747    for (index, raw) in identity.rotations.iter().enumerate() {
13748        let statement: RotationStatement = serde_json::from_str(raw)
13749            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
13750        let (old_fingerprint, old_spki) = chain[index];
13751        let (new_fingerprint, new_spki) = chain[index + 1];
13752        if statement.v != 1
13753            || statement.op != "rotate"
13754            || statement.brain != format!("ed25519:{old_fingerprint}")
13755            || statement.public_key != old_spki
13756            || statement.new_brain != format!("ed25519:{new_fingerprint}")
13757            || statement.new_public_key != new_spki
13758            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
13759            || (statement.prior_head_seq > 0
13760                && statement
13761                    .prior_feed_hash
13762                    .as_deref()
13763                    .is_none_or(|hash| !is_sha256(hash)))
13764        {
13765            return Err(invalid_feed(
13766                "rotation statement does not connect adjacent identities",
13767            ));
13768        }
13769        let unsigned = serde_json::to_string(&UnsignedRotation {
13770            v: statement.v,
13771            op: &statement.op,
13772            brain: &statement.brain,
13773            public_key: &statement.public_key,
13774            new_brain: &statement.new_brain,
13775            new_public_key: &statement.new_public_key,
13776            prior_head_seq: statement.prior_head_seq,
13777            prior_feed_hash: statement.prior_feed_hash.as_deref(),
13778            ts: statement.ts.clone(),
13779        })
13780        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
13781        let exact = format!(
13782            "{},\"sig\":\"{}\"}}",
13783            &unsigned[..unsigned.len() - 1],
13784            statement.sig
13785        );
13786        if exact != *raw {
13787            return Err(invalid_feed(
13788                "rotation statement is not in normative serialization",
13789            ));
13790        }
13791        let der = URL_SAFE_NO_PAD
13792            .decode(old_spki)
13793            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
13794        let signature = URL_SAFE_NO_PAD
13795            .decode(&statement.sig)
13796            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
13797        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
13798            .verify(unsigned.as_bytes(), &signature)
13799            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
13800        if index > 0 {
13801            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
13802                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
13803            if statement.prior_head_seq < prior.prior_head_seq {
13804                return Err(invalid_feed("rotation feed boundaries move backward"));
13805            }
13806        }
13807    }
13808
13809    let anchor = format!("ed25519:{}", chain[0].0);
13810    let current = format!("ed25519:{}", identity.fingerprint);
13811    if let Some(pin) = pinned {
13812        if pin.anchor != anchor {
13813            return Err(invalid_feed(
13814                "served identity chain does not descend from the pinned anchor",
13815            ));
13816        }
13817        if !chain
13818            .iter()
13819            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
13820        {
13821            return Err(invalid_feed(
13822                "served identity chain forked away from the last pinned identity",
13823            ));
13824        }
13825        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
13826            return Err(invalid_feed("served identity discarded its rotation chain"));
13827        }
13828        if pin.v >= 2
13829            && (identity.rotations.len() < pin.rotations.len()
13830                || identity.rotations[..pin.rotations.len()] != pin.rotations)
13831        {
13832            return Err(invalid_feed(
13833                "served identity rewrote the locally accepted rotation history",
13834            ));
13835        }
13836    }
13837    Ok(anchor)
13838}
13839
13840fn verify_rotation_feed_boundaries(
13841    identity: &FeedIdentity,
13842    pinned: Option<&TrustState>,
13843    observed: &[FeedItem],
13844    advertised_seq: u64,
13845) -> LinkResult<()> {
13846    let mut chain: Vec<String> = identity
13847        .previous
13848        .iter()
13849        .rev()
13850        .map(|previous| format!("ed25519:{}", previous.fingerprint))
13851        .collect();
13852    chain.push(format!("ed25519:{}", identity.fingerprint));
13853    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
13854
13855    for (index, raw) in identity.rotations.iter().enumerate() {
13856        let rotation: RotationStatement = serde_json::from_str(raw)
13857            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13858        if rotation.prior_head_seq > advertised_seq {
13859            return Err(invalid_feed(
13860                "rotation claims a feed boundary beyond the advertised head",
13861            ));
13862        }
13863        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
13864            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
13865                return Err(invalid_feed(
13866                    "newly disclosed rotation predates the local feed checkpoint",
13867                ));
13868            }
13869        }
13870        let actual = if rotation.prior_head_seq == 0 {
13871            None
13872        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
13873            pinned.and_then(|pin| pin.feed_hash.as_deref())
13874        } else {
13875            observed
13876                .iter()
13877                .find(|item| item.entry.seq == rotation.prior_head_seq)
13878                .map(|item| item.hash.as_str())
13879        };
13880        if let Some(actual) = actual {
13881            if rotation.prior_feed_hash.as_deref() != Some(actual) {
13882                return Err(invalid_feed(
13883                    "rotation statement does not commit the verified feed boundary",
13884                ));
13885            }
13886        } else if rotation.prior_head_seq == 0 {
13887            // The empty-feed boundary is represented by (0, null); there is
13888            // no entry hash to find in `observed`.
13889        } else if pinned.is_some_and(|pin| {
13890            pinned_index.is_some_and(|pin_index| index >= pin_index)
13891                || rotation.prior_head_seq >= pin.head_seq
13892        }) {
13893            return Err(invalid_feed(
13894                "rotation feed boundary was not present in the verified chain",
13895            ));
13896        }
13897    }
13898    Ok(())
13899}
13900
13901/// Once a client has checkpointed identity K, identities retired before K may
13902/// verify old history but can never regain authority over a later sequence.
13903/// This is also the safe migration rule for legacy v1 checkpoints that did not
13904/// persist the exact accepted rotation statements.
13905fn reject_retired_signer_after_checkpoint(
13906    identity: &FeedIdentity,
13907    pinned: Option<&TrustState>,
13908    item: &FeedItem,
13909) -> LinkResult<()> {
13910    let Some(pin) = pinned else {
13911        return Ok(());
13912    };
13913    if item.entry.seq <= pin.head_seq {
13914        return Ok(());
13915    }
13916    let mut chain: Vec<String> = identity
13917        .previous
13918        .iter()
13919        .rev()
13920        .map(|previous| format!("ed25519:{}", previous.fingerprint))
13921        .collect();
13922    chain.push(format!("ed25519:{}", identity.fingerprint));
13923    let pinned_index = chain
13924        .iter()
13925        .position(|key| key == &pin.current)
13926        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
13927    let signer_index = chain
13928        .iter()
13929        .position(|key| key == &item.entry.brain)
13930        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
13931    if signer_index < pinned_index {
13932        return Err(invalid_feed(
13933            "a retired identity attempted to sign after the local checkpoint",
13934        ));
13935    }
13936    Ok(())
13937}
13938
13939fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
13940    let origin = normalized_origin(&cfg.hub)?;
13941    let key = format!(
13942        "{:x}",
13943        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
13944    );
13945    Ok(format!("{key}.json"))
13946}
13947
13948fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13949    let origin = normalized_origin(&cfg.hub)?;
13950    let key = format!(
13951        "{:x}",
13952        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13953    );
13954    Ok(format!("alias-{key}.json"))
13955}
13956
13957#[cfg(any(unix, windows))]
13958struct TrustLock {
13959    _file: std::fs::File,
13960}
13961
13962#[cfg(unix)]
13963fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13964    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13965
13966    let lock_string = format!(".{state_name}.lock");
13967    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13968    let fd = unsafe {
13969        libc::openat(
13970            directory.as_raw_fd(),
13971            lock_name.as_ptr(),
13972            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13973            0o600,
13974        )
13975    };
13976    if fd < 0 {
13977        return Err(std::io::Error::last_os_error().into());
13978    }
13979    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13980    if !file.metadata()?.is_file() {
13981        return Err(LinkError::UnsafePath { path: lock_string });
13982    }
13983    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13984        return Err(std::io::Error::last_os_error().into());
13985    }
13986    Ok(TrustLock { _file: file })
13987}
13988
13989#[cfg(windows)]
13990fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13991    let lock_name = format!(".{state_name}.lock");
13992    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13993    Ok(TrustLock { _file: file })
13994}
13995
13996#[cfg(any(unix, windows))]
13997fn lock_trust_many(
13998    cfg: &HubConfig,
13999    directory: &std::fs::File,
14000    refs: &[&str],
14001) -> LinkResult<Vec<TrustLock>> {
14002    let mut names = refs
14003        .iter()
14004        .map(|reference| trust_file_name(cfg, reference))
14005        .collect::<LinkResult<Vec<_>>>()?;
14006    names.sort();
14007    names.dedup();
14008    names
14009        .iter()
14010        .map(|name| lock_trust_name(directory, name))
14011        .collect()
14012}
14013
14014#[cfg(not(any(unix, windows)))]
14015fn lock_trust_many(
14016    _cfg: &HubConfig,
14017    _directory: &TrustDirectory,
14018    _refs: &[&str],
14019) -> LinkResult<Vec<()>> {
14020    Err(LinkError::UnsupportedPlatform {
14021        operation: "verified link.md state",
14022    })
14023}
14024
14025#[cfg(any(unix, windows))]
14026type TrustDirectory = std::fs::File;
14027
14028#[cfg(not(any(unix, windows)))]
14029struct TrustDirectory;
14030
14031#[cfg(unix)]
14032fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
14033    use std::os::fd::AsRawFd as _;
14034
14035    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
14036    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
14037        return Err(std::io::Error::last_os_error().into());
14038    }
14039    directory.sync_all()?;
14040    Ok(directory)
14041}
14042
14043#[cfg(windows)]
14044fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
14045    let marker = cfg.state_dir.join("trust").join(".directory");
14046    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
14047    Ok(crate::fsx::open_directory_nofollow(
14048        marker.parent().expect("trust marker has a parent"),
14049    )?)
14050}
14051
14052#[cfg(not(any(unix, windows)))]
14053fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
14054    Err(LinkError::UnsupportedPlatform {
14055        operation: "verified link.md state",
14056    })
14057}
14058
14059#[cfg(unix)]
14060fn load_trust_in(
14061    cfg: &HubConfig,
14062    directory: &TrustDirectory,
14063    requested: &str,
14064) -> LinkResult<Option<TrustState>> {
14065    use std::os::fd::{AsRawFd as _, FromRawFd as _};
14066
14067    let name_string = trust_file_name(cfg, requested)?;
14068    let name = c_name(name_string.as_bytes(), &name_string)?;
14069    let fd = unsafe {
14070        libc::openat(
14071            directory.as_raw_fd(),
14072            name.as_ptr(),
14073            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14074        )
14075    };
14076    if fd < 0 {
14077        let error = std::io::Error::last_os_error();
14078        if error.kind() == std::io::ErrorKind::NotFound {
14079            return Ok(None);
14080        }
14081        return Err(LinkError::UnsafePath { path: name_string });
14082    }
14083    let file = unsafe { std::fs::File::from_raw_fd(fd) };
14084    if !file.metadata()?.is_file() {
14085        return Err(LinkError::UnsafePath { path: name_string });
14086    }
14087    let mut bytes = Vec::new();
14088    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
14089    if bytes.len() > 1024 * 1024 {
14090        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
14091    }
14092    let mut state: TrustState = serde_json::from_slice(&bytes)
14093        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
14094    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
14095        return Err(invalid_feed(
14096            "local identity/feed checkpoint does not match this hub and brain",
14097        ));
14098    }
14099    if state.v == 1 {
14100        // Legacy files were keyed by canonical brain id. They can migrate only
14101        // when the caller used that exact id; old slug lookups had no durable
14102        // alias binding and therefore cannot be guessed safely.
14103        if state.brain != requested {
14104            return Err(invalid_feed(
14105                "legacy checkpoint is not bound to the requested brain id",
14106            ));
14107        }
14108        state.requested = requested.to_string();
14109    } else if state.requested != requested {
14110        return Err(invalid_feed(
14111            "local identity/feed checkpoint is bound to a different requested ref",
14112        ));
14113    }
14114    Ok(Some(state))
14115}
14116
14117#[cfg(windows)]
14118fn load_trust_in(
14119    cfg: &HubConfig,
14120    directory: &TrustDirectory,
14121    requested: &str,
14122) -> LinkResult<Option<TrustState>> {
14123    let name = trust_file_name(cfg, requested)?;
14124    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
14125    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
14126        Ok(bytes) => bytes,
14127        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
14128        Err(_) => return Err(LinkError::UnsafePath { path: name }),
14129    };
14130    let mut state: TrustState = serde_json::from_slice(&bytes)
14131        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
14132    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
14133        return Err(invalid_feed(
14134            "local identity/feed checkpoint does not match this hub and brain",
14135        ));
14136    }
14137    if state.v == 1 {
14138        if state.brain != requested {
14139            return Err(invalid_feed(
14140                "legacy checkpoint is not bound to the requested brain id",
14141            ));
14142        }
14143        state.requested = requested.to_string();
14144    } else if state.requested != requested {
14145        return Err(invalid_feed(
14146            "local identity/feed checkpoint is bound to a different requested ref",
14147        ));
14148    }
14149    Ok(Some(state))
14150}
14151
14152#[cfg(not(any(unix, windows)))]
14153fn load_trust_in(
14154    _cfg: &HubConfig,
14155    _directory: &TrustDirectory,
14156    _brain: &str,
14157) -> LinkResult<Option<TrustState>> {
14158    Err(LinkError::UnsupportedPlatform {
14159        operation: "verified link.md state",
14160    })
14161}
14162
14163#[cfg(all(test, any(unix, windows)))]
14164fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
14165    let directory = open_trust_dir(cfg)?;
14166    load_trust_in(cfg, &directory, requested)
14167}
14168
14169#[cfg(unix)]
14170fn save_trust_in(
14171    cfg: &HubConfig,
14172    directory: &TrustDirectory,
14173    state: &TrustState,
14174) -> LinkResult<()> {
14175    use std::os::fd::{AsRawFd as _, FromRawFd as _};
14176
14177    let name_string = trust_file_name(cfg, &state.requested)?;
14178    let name = c_name(name_string.as_bytes(), &name_string)?;
14179    let mut bytes = serde_json::to_vec(state)
14180        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
14181    bytes.push(b'\n');
14182
14183    let nonce = std::time::SystemTime::now()
14184        .duration_since(std::time::UNIX_EPOCH)
14185        .unwrap_or_default()
14186        .as_nanos();
14187    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
14188    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
14189    let fd = unsafe {
14190        libc::openat(
14191            directory.as_raw_fd(),
14192            temp.as_ptr(),
14193            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14194            0o600,
14195        )
14196    };
14197    if fd < 0 {
14198        return Err(std::io::Error::last_os_error().into());
14199    }
14200    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
14201    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
14202        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14203        return Err(error.into());
14204    }
14205    drop(file);
14206    if unsafe {
14207        libc::renameat(
14208            directory.as_raw_fd(),
14209            temp.as_ptr(),
14210            directory.as_raw_fd(),
14211            name.as_ptr(),
14212        )
14213    } != 0
14214    {
14215        let error = std::io::Error::last_os_error();
14216        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14217        return Err(error.into());
14218    }
14219    directory.sync_all()?;
14220    Ok(())
14221}
14222
14223#[cfg(windows)]
14224fn save_trust_in(
14225    cfg: &HubConfig,
14226    directory: &TrustDirectory,
14227    state: &TrustState,
14228) -> LinkResult<()> {
14229    let name = trust_file_name(cfg, &state.requested)?;
14230    let mut bytes = serde_json::to_vec(state)
14231        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
14232    bytes.push(b'\n');
14233    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
14234    Ok(())
14235}
14236
14237#[cfg(not(any(unix, windows)))]
14238fn save_trust_in(
14239    _cfg: &HubConfig,
14240    _directory: &TrustDirectory,
14241    _state: &TrustState,
14242) -> LinkResult<()> {
14243    Err(LinkError::UnsupportedPlatform {
14244        operation: "verified link.md state",
14245    })
14246}
14247
14248#[cfg(unix)]
14249fn load_alias_in(
14250    cfg: &HubConfig,
14251    directory: &TrustDirectory,
14252    requested: &str,
14253) -> LinkResult<Option<AliasBinding>> {
14254    use std::os::fd::{AsRawFd as _, FromRawFd as _};
14255
14256    let name_string = alias_file_name(cfg, requested)?;
14257    let name = c_name(name_string.as_bytes(), &name_string)?;
14258    let fd = unsafe {
14259        libc::openat(
14260            directory.as_raw_fd(),
14261            name.as_ptr(),
14262            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14263        )
14264    };
14265    if fd < 0 {
14266        let error = std::io::Error::last_os_error();
14267        if error.kind() == std::io::ErrorKind::NotFound {
14268            return Ok(None);
14269        }
14270        return Err(LinkError::UnsafePath { path: name_string });
14271    }
14272    let file = unsafe { std::fs::File::from_raw_fd(fd) };
14273    if !file.metadata()?.is_file() {
14274        return Err(LinkError::UnsafePath { path: name_string });
14275    }
14276    let mut bytes = Vec::new();
14277    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
14278    if bytes.len() > 64 * 1024 {
14279        return Err(invalid_feed("local alias binding is oversized"));
14280    }
14281    let alias: AliasBinding = serde_json::from_slice(&bytes)
14282        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
14283    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
14284    {
14285        return Err(invalid_feed(
14286            "local alias binding does not match this hub and requested ref",
14287        ));
14288    }
14289    Ok(Some(alias))
14290}
14291
14292#[cfg(windows)]
14293fn load_alias_in(
14294    cfg: &HubConfig,
14295    directory: &TrustDirectory,
14296    requested: &str,
14297) -> LinkResult<Option<AliasBinding>> {
14298    let name = alias_file_name(cfg, requested)?;
14299    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
14300    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
14301        Ok(bytes) => bytes,
14302        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
14303        Err(_) => return Err(LinkError::UnsafePath { path: name }),
14304    };
14305    let alias: AliasBinding = serde_json::from_slice(&bytes)
14306        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
14307    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
14308    {
14309        return Err(invalid_feed(
14310            "local alias binding does not match this hub and requested ref",
14311        ));
14312    }
14313    Ok(Some(alias))
14314}
14315
14316#[cfg(not(any(unix, windows)))]
14317fn load_alias_in(
14318    _cfg: &HubConfig,
14319    _directory: &TrustDirectory,
14320    _requested: &str,
14321) -> LinkResult<Option<AliasBinding>> {
14322    Err(LinkError::UnsupportedPlatform {
14323        operation: "verified link.md state",
14324    })
14325}
14326
14327#[cfg(unix)]
14328fn save_alias_in(
14329    cfg: &HubConfig,
14330    directory: &TrustDirectory,
14331    alias: &AliasBinding,
14332) -> LinkResult<()> {
14333    use std::os::fd::{AsRawFd as _, FromRawFd as _};
14334
14335    let name_string = alias_file_name(cfg, &alias.requested)?;
14336    let name = c_name(name_string.as_bytes(), &name_string)?;
14337    let mut bytes = serde_json::to_vec(alias)
14338        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
14339    bytes.push(b'\n');
14340    let nonce = std::time::SystemTime::now()
14341        .duration_since(std::time::UNIX_EPOCH)
14342        .unwrap_or_default()
14343        .as_nanos();
14344    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
14345    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
14346    let fd = unsafe {
14347        libc::openat(
14348            directory.as_raw_fd(),
14349            temp.as_ptr(),
14350            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14351            0o600,
14352        )
14353    };
14354    if fd < 0 {
14355        return Err(std::io::Error::last_os_error().into());
14356    }
14357    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
14358    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
14359        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14360        return Err(error.into());
14361    }
14362    drop(file);
14363    if unsafe {
14364        libc::renameat(
14365            directory.as_raw_fd(),
14366            temp.as_ptr(),
14367            directory.as_raw_fd(),
14368            name.as_ptr(),
14369        )
14370    } != 0
14371    {
14372        let error = std::io::Error::last_os_error();
14373        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14374        return Err(error.into());
14375    }
14376    directory.sync_all()?;
14377    Ok(())
14378}
14379
14380#[cfg(windows)]
14381fn save_alias_in(
14382    cfg: &HubConfig,
14383    directory: &TrustDirectory,
14384    alias: &AliasBinding,
14385) -> LinkResult<()> {
14386    let name = alias_file_name(cfg, &alias.requested)?;
14387    let mut bytes = serde_json::to_vec(alias)
14388        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
14389    bytes.push(b'\n');
14390    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
14391    Ok(())
14392}
14393
14394#[cfg(not(any(unix, windows)))]
14395fn save_alias_in(
14396    _cfg: &HubConfig,
14397    _directory: &TrustDirectory,
14398    _alias: &AliasBinding,
14399) -> LinkResult<()> {
14400    Err(LinkError::UnsupportedPlatform {
14401        operation: "verified link.md state",
14402    })
14403}
14404
14405/// Load the canonical checkpoint shared by every spelling of one brain and
14406/// the separate alias binding. This also performs the one-way migration from
14407/// pre-v3 checkpoints that stored a full trust state under the alias itself.
14408/// Callers hold both alias and canonical locks before entering.
14409fn load_canonical_pin(
14410    cfg: &HubConfig,
14411    directory: &TrustDirectory,
14412    requested: &str,
14413    resolved_brain: &str,
14414) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
14415    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
14416    if requested == resolved_brain {
14417        return Ok((canonical, None));
14418    }
14419
14420    let mut alias = load_alias_in(cfg, directory, requested)?;
14421    if let Some(binding) = &alias {
14422        if binding.brain != resolved_brain {
14423            return Err(LinkError::AliasRebindRequired {
14424                alias: requested.to_string(),
14425                from: binding.brain.clone(),
14426                to: resolved_brain.to_string(),
14427            });
14428        }
14429        return Ok((canonical, alias));
14430    }
14431
14432    // A v2 build stored the complete checkpoint under the requested slug.
14433    // Promote it to the canonical ULID key before creating the lightweight
14434    // alias binding. Never silently merge two independently advanced states.
14435    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
14436        if legacy.brain != resolved_brain {
14437            return Err(invalid_feed(
14438                "legacy alias checkpoint names a different canonical brain",
14439            ));
14440        }
14441        if let Some(existing) = &canonical {
14442            if existing.brain != legacy.brain
14443                || existing.anchor != legacy.anchor
14444                || existing.current != legacy.current
14445                || existing.head_seq != legacy.head_seq
14446                || existing.feed_hash != legacy.feed_hash
14447                || existing.rotations != legacy.rotations
14448            {
14449                return Err(invalid_feed(
14450                    "legacy alias checkpoint conflicts with the canonical checkpoint",
14451                ));
14452            }
14453        } else {
14454            let mut promoted = legacy.clone();
14455            promoted.requested = resolved_brain.to_string();
14456            promoted.home = None;
14457            save_trust_in(cfg, directory, &promoted)?;
14458            canonical = Some(promoted);
14459        }
14460        alias = Some(AliasBinding {
14461            v: 1,
14462            origin: normalized_origin(&cfg.hub)?,
14463            requested: requested.to_string(),
14464            brain: resolved_brain.to_string(),
14465            home: legacy.home,
14466        });
14467        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
14468    }
14469    Ok((canonical, alias))
14470}
14471
14472/// Explicitly replace one mutable alias binding after the operator reviews the
14473/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
14474/// or rewritten, so old history remains pinned and the new brain establishes
14475/// its own independently verified trust chain.
14476pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
14477    require_hardened_filesystem("verified alias rebind")?;
14478    require_safe_ref(alias)?;
14479    require_safe_ref(from)?;
14480    require_safe_ref(to)?;
14481    if crate::ulid::is_ulid(alias)
14482        || !crate::ulid::is_ulid(from)
14483        || !crate::ulid::is_ulid(to)
14484        || from == to
14485    {
14486        return Err(LinkError::InvalidPack {
14487            message:
14488                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
14489                    .to_string(),
14490        });
14491    }
14492
14493    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
14494        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
14495    })?;
14496    accept_v2_head(cfg, &verified)?;
14497
14498    let alias_response = ensure_ok(
14499        request(
14500            cfg,
14501            "GET",
14502            &format!("/api/hub/brains/{alias}/v2/head"),
14503            None,
14504            Auth::Required,
14505        )?,
14506        "resolve alias for explicit rebind",
14507    )?;
14508    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
14509        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
14510    if resolved.v != 2 || resolved.brain_id != to {
14511        return Err(LinkError::RemoteAdvancedDuringSync);
14512    }
14513
14514    let directory = open_trust_dir(cfg)?;
14515    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
14516    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
14517        message: "the requested alias has no existing local binding to replace".to_string(),
14518    })?;
14519    if binding.brain != from {
14520        return Err(LinkError::AliasRebindRequired {
14521            alias: alias.to_string(),
14522            from: binding.brain,
14523            to: to.to_string(),
14524        });
14525    }
14526    save_alias_in(
14527        cfg,
14528        &directory,
14529        &AliasBinding {
14530            v: 1,
14531            origin: normalized_origin(&cfg.hub)?,
14532            requested: alias.to_string(),
14533            brain: to.to_string(),
14534            home: binding.home,
14535        },
14536    )?;
14537    Ok(json!({
14538        "v": 2,
14539        "alias": alias,
14540        "from": from,
14541        "to": to,
14542        "outcome": "alias_rebound",
14543    }))
14544}
14545
14546fn save_canonical_pin_and_alias(
14547    cfg: &HubConfig,
14548    directory: &TrustDirectory,
14549    requested: &str,
14550    resolved_brain: &str,
14551    mut state: TrustState,
14552    existing_alias: Option<&AliasBinding>,
14553) -> LinkResult<()> {
14554    state.requested = resolved_brain.to_string();
14555    state.brain = resolved_brain.to_string();
14556    state.home = None;
14557    save_trust_in(cfg, directory, &state)?;
14558    if requested != resolved_brain {
14559        save_alias_in(
14560            cfg,
14561            directory,
14562            &AliasBinding {
14563                v: 1,
14564                origin: normalized_origin(&cfg.hub)?,
14565                requested: requested.to_string(),
14566                brain: resolved_brain.to_string(),
14567                home: existing_alias.and_then(|alias| alias.home.clone()),
14568            },
14569        )?;
14570    }
14571    Ok(())
14572}
14573
14574fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
14575    const ED25519_SPKI_PREFIX: &[u8] = &[
14576        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
14577    ];
14578    let entry = &item.entry;
14579    let public_der = URL_SAFE_NO_PAD
14580        .decode(&entry.public_key)
14581        .map_err(|_| invalid_feed("public key is not base64url"))?;
14582    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
14583        || !public_der.starts_with(ED25519_SPKI_PREFIX)
14584    {
14585        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
14586    }
14587    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
14588    if entry.brain != format!("ed25519:{fingerprint}") {
14589        return Err(invalid_feed(
14590            "brain fingerprint does not match its public key",
14591        ));
14592    }
14593    // Verify the complete rotation authority before using any previous key.
14594    let _ = verify_identity_chain(identity, None)?;
14595    let mut chain: Vec<(&str, &str)> = identity
14596        .previous
14597        .iter()
14598        .rev()
14599        .map(|previous| {
14600            (
14601                previous.fingerprint.as_str(),
14602                previous.public_key_spki.as_str(),
14603            )
14604        })
14605        .collect();
14606    chain.push((&identity.fingerprint, &identity.public_key_spki));
14607    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
14608        *known_fingerprint == fingerprint && *spki == entry.public_key
14609    });
14610    let Some(signer_index) = signer_index else {
14611        return Err(invalid_feed(
14612            "entry signer is not this brain's identity (current or rotated-from)",
14613        ));
14614    };
14615    let lower_boundary = if signer_index == 0 {
14616        None
14617    } else {
14618        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
14619            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
14620        Some(prior.prior_head_seq)
14621    };
14622    let upper_boundary = if signer_index == identity.rotations.len() {
14623        None
14624    } else {
14625        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
14626            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
14627        Some(next.prior_head_seq)
14628    };
14629    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
14630        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
14631    {
14632        return Err(invalid_feed(
14633            "entry signer is outside its authenticated rotation epoch",
14634        ));
14635    }
14636    let unsigned = UnsignedFeedEntry {
14637        v: entry.v,
14638        seq: entry.seq,
14639        ts: &entry.ts,
14640        brain: &entry.brain,
14641        public_key: &entry.public_key,
14642        kind: &entry.kind,
14643        op: &entry.op,
14644        pack_sha256: &entry.pack_sha256,
14645        files: &entry.files,
14646        removed: &entry.removed,
14647        prev_entry_hash: &entry.prev_entry_hash,
14648    };
14649    let message =
14650        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
14651    let signature = URL_SAFE_NO_PAD
14652        .decode(&entry.sig)
14653        .map_err(|_| invalid_feed("signature is not base64url"))?;
14654    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
14655        .verify(&message, &signature)
14656        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
14657
14658    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
14659    exact.push(b'\n');
14660    let actual_hash = format!("{:x}", Sha256::digest(&exact));
14661    if actual_hash != item.hash {
14662        return Err(invalid_feed("entry SHA-256 does not match"));
14663    }
14664    Ok(())
14665}
14666
14667// ─────────────────────────────────────────────────────────────────────────────
14668// key rotation — link.md §9.1: the new key, signed by the old one
14669// ─────────────────────────────────────────────────────────────────────────────
14670
14671/// The unsigned rotation statement in its normative field order.
14672#[derive(Serialize)]
14673struct UnsignedRotation<'a> {
14674    v: u8,
14675    op: &'a str,
14676    brain: &'a str,
14677    public_key: &'a str,
14678    new_brain: &'a str,
14679    new_public_key: &'a str,
14680    prior_head_seq: u64,
14681    prior_feed_hash: Option<&'a str>,
14682    ts: String,
14683}
14684
14685/// Durable intent for an in-flight key rotation. The hub's recovery contract
14686/// identifies an ambiguous retry by the exact statement bytes, so the
14687/// statement cannot be reconstructed from the key and feed boundary later:
14688/// its timestamp and signature would differ.
14689#[derive(Debug, Deserialize, Serialize)]
14690#[serde(deny_unknown_fields)]
14691struct RotationJournal {
14692    v: u8,
14693    origin: String,
14694    brain: String,
14695    old_brain: String,
14696    new_brain: String,
14697    prior_head_seq: u64,
14698    prior_feed_hash: Option<String>,
14699    statement: String,
14700}
14701
14702fn rotation_journal_path(key_path: &Path) -> PathBuf {
14703    let mut path = key_path.as_os_str().to_os_string();
14704    path.push(".rotation.json");
14705    PathBuf::from(path)
14706}
14707
14708fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
14709    #[cfg(unix)]
14710    let file = {
14711        use std::os::fd::{AsRawFd as _, FromRawFd as _};
14712        use std::os::unix::ffi::OsStrExt as _;
14713        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
14714            .map_err(|error| {
14715                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
14716            })?;
14717        let leaf_name = path
14718            .file_name()
14719            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
14720        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
14721        let fd = unsafe {
14722            libc::openat(
14723                parent.as_raw_fd(),
14724                leaf.as_ptr(),
14725                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14726            )
14727        };
14728        if fd < 0 {
14729            return Err(bad_agent_key(
14730                "the rotation journal must be an existing regular file without symlink ancestors",
14731            ));
14732        }
14733        unsafe { std::fs::File::from_raw_fd(fd) }
14734    };
14735    #[cfg(not(unix))]
14736    let file = std::fs::File::open(path)
14737        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
14738    let metadata = file
14739        .metadata()
14740        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
14741    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
14742        return Err(bad_agent_key(
14743            "the rotation journal must be a bounded regular file",
14744        ));
14745    }
14746    #[cfg(unix)]
14747    {
14748        use std::os::unix::fs::PermissionsExt as _;
14749        if metadata.permissions().mode() & 0o077 != 0 {
14750            return Err(bad_agent_key(
14751                "the rotation journal is accessible to group/other; set mode 0600",
14752            ));
14753        }
14754    }
14755    serde_json::from_reader(file)
14756        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
14757}
14758
14759fn remove_rotation_journal(path: &Path) {
14760    #[cfg(unix)]
14761    {
14762        use std::os::fd::AsRawFd as _;
14763        use std::os::unix::ffi::OsStrExt as _;
14764        let Ok(parent) =
14765            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
14766        else {
14767            return;
14768        };
14769        let Some(leaf_name) = path.file_name() else {
14770            return;
14771        };
14772        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
14773            return;
14774        };
14775        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
14776            let _ = parent.sync_all();
14777        }
14778    }
14779    #[cfg(not(unix))]
14780    {
14781        let _ = std::fs::remove_file(path);
14782    }
14783}
14784
14785fn validate_rotation_journal(
14786    journal: &RotationJournal,
14787    cfg: &HubConfig,
14788    canonical_brain: &str,
14789    old_key: &AgentSigningKey,
14790    new_key: &AgentSigningKey,
14791    head: &Head,
14792) -> LinkResult<()> {
14793    if journal.v != 1
14794        || journal.origin != normalized_origin(&cfg.hub)?
14795        || journal.brain != canonical_brain
14796        || journal.old_brain != old_key.multikey
14797        || journal.new_brain != new_key.multikey
14798        || journal.prior_head_seq != head.seq
14799        || journal.prior_feed_hash != head.feed_hash
14800    {
14801        return Err(invalid_feed(
14802            "rotation journal does not match the verified key and feed boundary",
14803        ));
14804    }
14805    let statement: RotationStatement = serde_json::from_str(&journal.statement)
14806        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
14807    if statement.prior_head_seq != journal.prior_head_seq
14808        || statement.prior_feed_hash != journal.prior_feed_hash
14809        || statement.brain != old_key.multikey
14810        || statement.public_key != old_key.public_key_spki
14811        || statement.new_brain != new_key.multikey
14812        || statement.new_public_key != new_key.public_key_spki
14813    {
14814        return Err(invalid_feed(
14815            "rotation journal statement does not match its durable intent",
14816        ));
14817    }
14818    let identity = FeedIdentity {
14819        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
14820        public_key_spki: new_key.public_key_spki.clone(),
14821        previous: vec![PreviousIdentity {
14822            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
14823            public_key_spki: old_key.public_key_spki.clone(),
14824        }],
14825        rotations: vec![journal.statement.clone()],
14826    };
14827    verify_identity_chain(&identity, None)?;
14828    Ok(())
14829}
14830
14831/// What `dbmd key rotate` returns.
14832#[derive(Debug, Serialize)]
14833pub struct RotationReport {
14834    /// The brain id rotated.
14835    pub brain: String,
14836    /// The NEW identity the hub now serves.
14837    pub multikey: String,
14838    /// Where the new PKCS#8 secret landed (0600).
14839    #[serde(rename = "keyFile")]
14840    pub key_file: String,
14841    /// Prior identities (newest first) the feed still verifies against.
14842    pub previous: Vec<String>,
14843}
14844
14845/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
14846/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
14847/// OLD key in normative serialization — and send it to the hub. The new secret
14848/// is durably created at 0600 before the POST; an existing output is reused for
14849/// idempotent retry/reconciliation. The old key is left untouched.
14850pub fn rotate_brain_key(
14851    cfg: &HubConfig,
14852    brain: &str,
14853    old_key: &AgentSigningKey,
14854    out: &Path,
14855) -> LinkResult<RotationReport> {
14856    require_hardened_filesystem("key rotation")?;
14857    require_safe_ref(brain)?;
14858    // The new private key must be durable *before* the hub can accept its
14859    // public half. An existing file is the retry/reconciliation path after an
14860    // ambiguous network failure: reuse it, never generate another identity.
14861    let new_key = if out.exists() {
14862        load_signing_key(out)?
14863    } else {
14864        let rng = ring::rand::SystemRandom::new();
14865        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
14866            .map_err(|_| bad_agent_key("key generation failed"))?;
14867        let pair = agent_keypair(pkcs8.as_ref())?;
14868        let (public_key_spki, multikey) = public_identity_for(&pair);
14869        write_secret_new(
14870            out,
14871            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
14872        )?;
14873        AgentSigningKey {
14874            pkcs8: pkcs8.as_ref().to_vec(),
14875            multikey,
14876            public_key_spki,
14877        }
14878    };
14879    let new_spki = new_key.public_key_spki.clone();
14880    let new_multikey = new_key.multikey.clone();
14881    let journal_path = rotation_journal_path(out);
14882    let before_v2 = v2_verified_head(cfg, brain)?;
14883    let (canonical_brain, served_identity, observed_head, v2_profile) =
14884        if let Some(head) = before_v2 {
14885            let observed = Head {
14886                brain: head.brain_id.clone(),
14887                seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14888                updated_at: head
14889                    .pointer
14890                    .as_ref()
14891                    .map(|pointer| pointer.signed_at.clone()),
14892                feed_hash: head
14893                    .pointer
14894                    .as_ref()
14895                    .map(|pointer| pointer.feed_hash.clone()),
14896                verified: true,
14897            };
14898            let identity = v2_identity(&head.identity);
14899            let canonical = head.brain_id.clone();
14900            accept_v2_head(cfg, &head)?;
14901            (canonical, identity, observed, true)
14902        } else {
14903            let remote = verified_remote_head(cfg, brain, false)?;
14904            let identity = remote
14905                .identity
14906                .clone()
14907                .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
14908            (remote.head.brain.clone(), identity, remote.head, false)
14909        };
14910    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
14911    let already_rotated = served_multikey == new_multikey;
14912    // A completed invocation removes the journal only after the server has
14913    // acknowledged its recovery boundary. If the verified identity advanced
14914    // but the journal remains, the prior response was ambiguous: replay the
14915    // exact signed statement so a v2 hub can repair/confirm its checkpoint.
14916    if already_rotated && !journal_path.exists() {
14917        remove_rotation_journal(&journal_path);
14918        return Ok(RotationReport {
14919            brain: brain.to_string(),
14920            multikey: new_multikey,
14921            key_file: out.display().to_string(),
14922            previous: served_identity
14923                .previous
14924                .iter()
14925                .map(|identity| format!("ed25519:{}", identity.fingerprint))
14926                .collect(),
14927        });
14928    }
14929    if !already_rotated && served_multikey != old_key.multikey {
14930        return Err(invalid_feed(
14931            "the supplied old key is not the brain's verified current identity",
14932        ));
14933    }
14934
14935    let journal = if journal_path.exists() {
14936        read_rotation_journal(&journal_path)?
14937    } else {
14938        let ts = crate::now()
14939            .with_timezone(&chrono::Utc)
14940            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14941            .to_string();
14942        let unsigned = serde_json::to_string(&UnsignedRotation {
14943            v: 1,
14944            op: "rotate",
14945            brain: &old_key.multikey,
14946            public_key: &old_key.public_key_spki,
14947            new_brain: &new_multikey,
14948            new_public_key: &new_spki,
14949            prior_head_seq: observed_head.seq,
14950            prior_feed_hash: observed_head.feed_hash.as_deref(),
14951            ts,
14952        })
14953        .expect("serialize rotation");
14954        let old_pair = agent_keypair(&old_key.pkcs8)?;
14955        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14956        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14957        let journal = RotationJournal {
14958            v: 1,
14959            origin: normalized_origin(&cfg.hub)?,
14960            brain: canonical_brain.clone(),
14961            old_brain: old_key.multikey.clone(),
14962            new_brain: new_multikey.clone(),
14963            prior_head_seq: observed_head.seq,
14964            prior_feed_hash: observed_head.feed_hash.clone(),
14965            statement,
14966        };
14967        let mut exact = serde_json::to_vec(&journal)
14968            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14969        exact.push(b'\n');
14970        if write_secret_new(&journal_path, &exact).is_err() {
14971            // A concurrent retry may have won the O_EXCL race. Only an exact,
14972            // fully validated journal is allowed to recover that race.
14973            read_rotation_journal(&journal_path)?
14974        } else {
14975            journal
14976        }
14977    };
14978    validate_rotation_journal(
14979        &journal,
14980        cfg,
14981        &canonical_brain,
14982        old_key,
14983        &new_key,
14984        &observed_head,
14985    )?;
14986
14987    let body = json!({ "statement": journal.statement });
14988    let path = format!("/api/hub/brains/{brain}/rotate");
14989    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14990    let attempted_failure = match attempted {
14991        Ok(response) if (200..300).contains(&response.status) => None,
14992        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14993        Err(error) => Some(error),
14994    };
14995
14996    // A 2xx body is not authority, and a failed response may have followed a
14997    // committed mutation. In both cases success means the normal verifier sees
14998    // an append-only rotation chain ending at the durable new key.
14999    let identity = if v2_profile {
15000        match v2_verified_head(cfg, brain) {
15001            Ok(Some(after)) => {
15002                let identity = v2_identity(&after.identity);
15003                accept_v2_head(cfg, &after)?;
15004                identity
15005            }
15006            Ok(None) => {
15007                return Err(attempted_failure.unwrap_or_else(|| {
15008                    invalid_feed("rotated v2 brain no longer serves a v2 head")
15009                }));
15010            }
15011            Err(error) => return Err(attempted_failure.unwrap_or(error)),
15012        }
15013    } else {
15014        match verified_remote_head(cfg, brain, false) {
15015            Ok(after) => after
15016                .identity
15017                .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
15018            Err(error) => return Err(attempted_failure.unwrap_or(error)),
15019        }
15020    };
15021    if format!("ed25519:{}", identity.fingerprint) != new_multikey
15022        || identity.public_key_spki != new_spki
15023    {
15024        return Err(attempted_failure.unwrap_or_else(|| {
15025            invalid_feed("hub acknowledged rotation without committing the verified new identity")
15026        }));
15027    }
15028    if v2_profile {
15029        if let Some(error) = attempted_failure {
15030            // Seeing the new public identity is insufficient: v2 rotation is
15031            // successful only after the endpoint has forced and acknowledged
15032            // the recovery checkpoint that contains it. Keep the journal for
15033            // an exact retry after any non-2xx or transport ambiguity.
15034            return Err(error);
15035        }
15036    }
15037    let previous = identity
15038        .previous
15039        .iter()
15040        .map(|prior| format!("ed25519:{}", prior.fingerprint))
15041        .collect();
15042    remove_rotation_journal(&journal_path);
15043
15044    Ok(RotationReport {
15045        brain: brain.to_string(),
15046        multikey: new_multikey,
15047        key_file: out.display().to_string(),
15048        previous,
15049    })
15050}
15051
15052// ─────────────────────────────────────────────────────────────────────────────
15053// mirror — verified replication: the whole feed + files, re-servable
15054// ─────────────────────────────────────────────────────────────────────────────
15055
15056/// What `dbmd mirror` materialized.
15057#[derive(Debug, Serialize)]
15058pub struct MirrorReport {
15059    /// The brain id.
15060    pub brain: String,
15061    /// The mirrored feed head.
15062    #[serde(rename = "headSeq")]
15063    pub head_seq: u64,
15064    /// The head entry hash (the feed's advertised converged state).
15065    #[serde(rename = "feedHash")]
15066    pub feed_hash: Option<String>,
15067    /// Signed feed entries verified and stored.
15068    pub entries: u64,
15069    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
15070    pub pinned: String,
15071    /// Store files materialized by the pull.
15072    pub files: usize,
15073}
15074
15075/// The mirror state directory, relative to the mirror root.
15076pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
15077
15078/// Fully re-verified mirror material suitable for a read-only re-server.
15079#[derive(Debug)]
15080pub struct VerifiedMirrorMaterial {
15081    pub brain: String,
15082    pub head_seq: u64,
15083    pub feed_hash: Option<String>,
15084    pub identity: serde_json::Value,
15085    /// Sequence, exact normative entry JSON without newline, and entry hash.
15086    pub entries: Vec<(u64, String, String)>,
15087    pub pack_sha256: Option<String>,
15088}
15089
15090#[derive(Deserialize)]
15091#[serde(deny_unknown_fields)]
15092struct StoredMirrorHead {
15093    brain: String,
15094    #[serde(rename = "headSeq")]
15095    head_seq: u64,
15096    #[serde(rename = "feedHash")]
15097    feed_hash: Option<String>,
15098}
15099
15100/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
15101/// exact snapshot pack before `dbmd serve` exposes any bytes.
15102pub fn verify_mirror_material(
15103    head_bytes: &[u8],
15104    identity_bytes: &[u8],
15105    feed_bytes: &[Vec<u8>],
15106    snapshot_pack: Option<&[u8]>,
15107    expected_anchor: &str,
15108) -> LinkResult<VerifiedMirrorMaterial> {
15109    let snapshot_hash = snapshot_pack
15110        .filter(|pack| !pack.is_empty())
15111        .map(content_sha256);
15112    verify_mirror_material_with_pack_hash(
15113        head_bytes,
15114        identity_bytes,
15115        feed_bytes,
15116        snapshot_hash.as_deref(),
15117        expected_anchor,
15118    )
15119}
15120
15121/// Re-verify mirror metadata against a snapshot digest computed from a held
15122/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
15123/// large pack without ever buffering the pack in process memory.
15124pub fn verify_mirror_material_with_pack_hash(
15125    head_bytes: &[u8],
15126    identity_bytes: &[u8],
15127    feed_bytes: &[Vec<u8>],
15128    snapshot_pack_sha256: Option<&str>,
15129    expected_anchor: &str,
15130) -> LinkResult<VerifiedMirrorMaterial> {
15131    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
15132        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
15133    require_safe_ref(&head.brain)?;
15134    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
15135        return Err(invalid_feed(
15136            "stored mirror feed count does not match its bounded head sequence",
15137        ));
15138    }
15139    let aggregate = feed_bytes
15140        .iter()
15141        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
15142        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
15143    if aggregate > MAX_FEED_REPLAY_BYTES {
15144        return Err(invalid_feed(
15145            "stored mirror feed metadata exceeds the aggregate limit",
15146        ));
15147    }
15148    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
15149        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
15150    let anchor = verify_identity_chain(&identity, None)?;
15151    if anchor != expected_anchor {
15152        return Err(invalid_feed(
15153            "stored mirror identity does not descend from the explicitly trusted anchor",
15154        ));
15155    }
15156
15157    let mut entries = Vec::with_capacity(feed_bytes.len());
15158    let mut items = Vec::with_capacity(feed_bytes.len());
15159    let mut previous_hash = None;
15160    let mut pack_sha256 = None;
15161    for (index, bytes) in feed_bytes.iter().enumerate() {
15162        let exact = bytes
15163            .strip_suffix(b"\n")
15164            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
15165        if exact.ends_with(b"\n") {
15166            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
15167        }
15168        let entry: FeedEntry = serde_json::from_slice(exact)
15169            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
15170        let expected_seq = index as u64 + 1;
15171        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
15172            return Err(invalid_feed(
15173                "stored mirror feed is not contiguous and hash-chained",
15174            ));
15175        }
15176        let canonical = serde_json::to_vec(&entry)
15177            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
15178        if canonical != exact {
15179            return Err(invalid_feed(
15180                "stored feed entry is not in normative serialization",
15181            ));
15182        }
15183        let hash = content_sha256(bytes);
15184        let item = FeedItem {
15185            hash: hash.clone(),
15186            entry,
15187        };
15188        verify_feed_item(&item, &identity)?;
15189        previous_hash = Some(hash.clone());
15190        if expected_seq == head.head_seq {
15191            pack_sha256 = Some(item.entry.pack_sha256.clone());
15192        }
15193        entries.push((
15194            expected_seq,
15195            std::str::from_utf8(exact)
15196                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
15197                .to_string(),
15198            hash,
15199        ));
15200        items.push(item);
15201    }
15202    if previous_hash != head.feed_hash {
15203        return Err(invalid_feed(
15204            "stored mirror feed does not converge on its advertised head",
15205        ));
15206    }
15207    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
15208    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
15209        (0, None, None) => {}
15210        (_, Some(actual), Some(expected)) if actual == expected => {}
15211        _ => {
15212            return Err(LinkError::InvalidPack {
15213                message: "stored snapshot pack does not match the signed head digest".to_string(),
15214            });
15215        }
15216    }
15217    let identity_value = serde_json::to_value(&identity)
15218        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
15219    Ok(VerifiedMirrorMaterial {
15220        brain: head.brain,
15221        head_seq: head.head_seq,
15222        feed_hash: head.feed_hash,
15223        identity: identity_value,
15224        entries,
15225        pack_sha256,
15226    })
15227}
15228
15229/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
15230/// entry hash every consumer recomputes (SPEC §5.3).
15231pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
15232    format!(
15233        "{:x}",
15234        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
15235    )
15236}
15237
15238/// SHA-256 hex for content a signed manifest names. Exposed for the thin
15239/// `dbmd serve` adapter; cryptographic verification remains centralized here.
15240pub fn content_sha256(bytes: &[u8]) -> String {
15241    format!("{:x}", Sha256::digest(bytes))
15242}
15243
15244/// SHA-256 a stream with a fixed-size working buffer.
15245pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
15246    let mut digest = Sha256::new();
15247    let mut buffer = [0u8; 64 * 1024];
15248    loop {
15249        let read = reader.read(&mut buffer)?;
15250        if read == 0 {
15251            break;
15252        }
15253        digest.update(&buffer[..read]);
15254    }
15255    Ok(format!("{:x}", digest.finalize()))
15256}
15257
15258/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
15259/// chain, not just the head): every entry's signature, hash, sequence
15260/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
15261/// checked in a sibling staging directory. Only then is the old mirror swapped
15262/// out through an atomic directory exchange. Every stage, install, and cleanup
15263/// operation is relative to one held no-follow parent-directory capability, so
15264/// renaming an ancestor cannot redirect any write or deletion.
15265#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
15266pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
15267    require_hardened_filesystem("mirror")?;
15268    require_safe_ref(brain)?;
15269    #[cfg(windows)]
15270    {
15271        let _ = (cfg, dest);
15272        return Err(LinkError::UnsupportedPlatform {
15273            operation: "atomic whole-mirror replacement on Windows",
15274        });
15275    }
15276    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
15277    let name = dest
15278        .file_name()
15279        .and_then(|name| name.to_str())
15280        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
15281        .ok_or_else(|| LinkError::UnsafePath {
15282            path: dest.display().to_string(),
15283        })?;
15284    #[cfg(unix)]
15285    let parent_dir = open_or_create_dir_nofollow(parent)?;
15286    #[cfg(unix)]
15287    use std::os::fd::AsRawFd as _;
15288    #[cfg(unix)]
15289    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
15290    #[cfg(unix)]
15291    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
15292        None => false,
15293        Some(true) => true,
15294        Some(false) => {
15295            return Err(LinkError::UnsafePath {
15296                path: dest.display().to_string(),
15297            });
15298        }
15299    };
15300
15301    // A fixed backup was used by pre-hardening builds. Never interpret or
15302    // delete an attacker-planted entry at that name; require manual recovery.
15303    #[cfg(unix)]
15304    let legacy_backup_name = c_name(
15305        format!(".{name}.dbmd-backup").as_bytes(),
15306        &dest.display().to_string(),
15307    )?;
15308    #[cfg(unix)]
15309    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
15310        return Err(LinkError::UnsafePath {
15311            path: parent
15312                .join(format!(".{name}.dbmd-backup"))
15313                .display()
15314                .to_string(),
15315        });
15316    }
15317
15318    let nonce = std::time::SystemTime::now()
15319        .duration_since(std::time::UNIX_EPOCH)
15320        .unwrap_or_default()
15321        .as_nanos();
15322    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
15323    #[cfg(unix)]
15324    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
15325    #[cfg(unix)]
15326    let stage_dir = create_dir_exclusive_at(
15327        parent_dir.as_raw_fd(),
15328        &stage_name,
15329        &dest.display().to_string(),
15330    )?;
15331
15332    let assembled = (|| -> LinkResult<MirrorReport> {
15333        let remote = verified_remote_head(cfg, brain, true)?;
15334        let brain_id = remote.head.brain.clone();
15335        let identity = remote
15336            .identity
15337            .as_ref()
15338            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
15339        let anchor = remote
15340            .anchor
15341            .clone()
15342            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
15343        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
15344        let snapshot_entries = parse_store_pack(pack.clone())?;
15345        let snapshot_count = snapshot_entries.len();
15346        let mut staged_entries = snapshot_entries;
15347        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
15348        for item in &remote.entries {
15349            let mut exact = serde_json::to_vec(&item.entry)
15350                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
15351            exact.push(b'\n');
15352            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
15353                return Err(invalid_feed(
15354                    "serialized mirror entry differs from its verified hash",
15355                ));
15356            }
15357            staged_entries.push((
15358                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
15359                exact,
15360            ));
15361        }
15362        let mut identity_bytes = serde_json::to_vec(identity)
15363            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
15364        identity_bytes.push(b'\n');
15365        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
15366        let mut head_bytes = serde_json::to_vec(&json!({
15367            "brain": brain_id,
15368            "headSeq": remote.head.seq,
15369            "feedHash": remote.head.feed_hash,
15370        }))
15371        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
15372        head_bytes.push(b'\n');
15373        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
15374        staged_entries.push((
15375            CONFIG_REL_PATH.to_string(),
15376            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
15377        ));
15378        #[cfg(unix)]
15379        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
15380
15381        Ok(MirrorReport {
15382            brain: brain_id,
15383            head_seq: remote.head.seq,
15384            feed_hash: remote.head.feed_hash,
15385            entries: remote.entries.len() as u64,
15386            pinned: anchor,
15387            files: snapshot_count,
15388        })
15389    })();
15390
15391    let report = match assembled {
15392        Ok(report) => report,
15393        Err(error) => {
15394            #[cfg(unix)]
15395            let _ = remove_tree_at(
15396                parent_dir.as_raw_fd(),
15397                &stage_name,
15398                &dest.display().to_string(),
15399            );
15400            return Err(error);
15401        }
15402    };
15403
15404    #[cfg(unix)]
15405    if let Err(error) =
15406        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
15407    {
15408        let _ = remove_tree_at(
15409            parent_dir.as_raw_fd(),
15410            &stage_name,
15411            &dest.display().to_string(),
15412        );
15413        return Err(error);
15414    }
15415    // An exchange leaves the old mirror at the unique stage name. Cleanup is
15416    // capability-relative and never follows symlinks in hostile old content.
15417    #[cfg(unix)]
15418    if dest_exists {
15419        remove_tree_at(
15420            parent_dir.as_raw_fd(),
15421            &stage_name,
15422            &dest.display().to_string(),
15423        )?;
15424    }
15425    #[cfg(unix)]
15426    parent_dir.sync_all()?;
15427    Ok(report)
15428}
15429
15430fn verified_remote_head(
15431    cfg: &HubConfig,
15432    brain: &str,
15433    require_full_chain: bool,
15434) -> LinkResult<VerifiedRemote> {
15435    require_hardened_filesystem("verified link.md state")?;
15436    require_safe_ref(brain)?;
15437    // Refuse an unsafe state root before sending credentials or consulting an
15438    // untrusted card, and retain this exact directory inode for the complete
15439    // checkpoint transaction.
15440    let trust_directory = open_trust_dir(cfg)?;
15441    let path = format!("/api/hub/brains/{brain}");
15442    let body = ensure_ok(
15443        request(cfg, "GET", &path, None, Auth::Required)?,
15444        "subscribe",
15445    )?;
15446    let resolved_brain = body
15447        .get("id")
15448        .and_then(Value::as_str)
15449        .filter(|id| crate::ulid::is_ulid(id))
15450        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
15451        .to_string();
15452    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
15453        return Err(invalid_feed(
15454            "brain card id differs from the explicitly requested brain id",
15455        ));
15456    }
15457    // The card supplies the canonical ULID. Lock alias + canonical keys in
15458    // deterministic filename order, then hold both through verify + save.
15459    // Concurrent aliases for the same brain therefore converge on one
15460    // checkpoint instead of establishing independent TOFU universes.
15461    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
15462    let (pinned, alias_binding) =
15463        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
15464    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
15465    let advertised_hash = body
15466        .get("feedHash")
15467        .and_then(Value::as_str)
15468        .map(str::to_string);
15469    let updated_at = body
15470        .get("updatedAt")
15471        .and_then(Value::as_str)
15472        .map(str::to_string);
15473    if let Some(pin) = &pinned {
15474        if seq < pin.head_seq {
15475            return Err(invalid_feed(format!(
15476                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
15477                pin.head_seq
15478            )));
15479        }
15480        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
15481            return Err(invalid_feed(
15482                "feed equivocation: the checkpoint sequence now has a different hash",
15483            ));
15484        }
15485    }
15486    if seq == 0 {
15487        if advertised_hash.is_some() {
15488            return Err(invalid_feed("an empty feed advertised a head hash"));
15489        }
15490        let identity: FeedIdentity = serde_json::from_value(
15491            body.get("identity")
15492                .cloned()
15493                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
15494        )
15495        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
15496        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
15497        // A valid old-key-signed rotation is still inconsistent if it commits
15498        // to history that the same card now claims never existed. Run the
15499        // identical feed-boundary proof used by non-empty heads before this
15500        // identity can become a durable TOFU checkpoint.
15501        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
15502        save_canonical_pin_and_alias(
15503            cfg,
15504            &trust_directory,
15505            brain,
15506            &resolved_brain,
15507            TrustState {
15508                v: 2,
15509                origin: normalized_origin(&cfg.hub)?,
15510                requested: resolved_brain.clone(),
15511                brain: resolved_brain.clone(),
15512                home: None,
15513                anchor: anchor.clone(),
15514                current: format!("ed25519:{}", identity.fingerprint),
15515                head_seq: 0,
15516                feed_hash: None,
15517                rotations: identity.rotations.clone(),
15518                hub_signer: None,
15519                protocol_profile: None,
15520            },
15521            alias_binding.as_ref(),
15522        )?;
15523        return Ok(VerifiedRemote {
15524            head: Head {
15525                brain: resolved_brain,
15526                seq,
15527                updated_at,
15528                feed_hash: None,
15529                verified: true,
15530            },
15531            identity: Some(identity),
15532            head_entry: None,
15533            entries: Vec::new(),
15534            anchor: Some(anchor),
15535        });
15536    }
15537    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
15538        return Err(invalid_feed(
15539            "non-empty feed did not advertise a valid SHA-256 head",
15540        ));
15541    }
15542
15543    // On first contact the signed head itself is the TOFU checkpoint. A full
15544    // history replay cannot add authority before an anchor exists; mirrors
15545    // still request the complete chain because they promise an archival copy.
15546    let replay_head_only = !require_full_chain
15547        && pinned
15548            .as_ref()
15549            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
15550    let mut after = if replay_head_only {
15551        seq - 1
15552    } else if require_full_chain || pinned.is_none() {
15553        0
15554    } else {
15555        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
15556    };
15557    let mut expected_seq = after + 1;
15558    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
15559        None
15560    } else {
15561        pinned
15562            .as_ref()
15563            .and_then(|checkpoint| checkpoint.feed_hash.clone())
15564    };
15565    let mut identity: Option<FeedIdentity> = None;
15566    let mut anchor: Option<String> = None;
15567    let mut head_entry: Option<FeedItem> = None;
15568    let mut all_entries = Vec::new();
15569    let mut observed_entries = Vec::new();
15570    let replay_count = seq
15571        .checked_sub(after)
15572        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
15573    if replay_count > MAX_FEED_REPLAY_ENTRIES {
15574        return Err(invalid_feed(format!(
15575            "feed replay requires {replay_count} entries, over the client cap"
15576        )));
15577    }
15578    let mut replay_bytes = 0u64;
15579
15580    loop {
15581        let feed_bytes = ensure_raw_ok(
15582            request_raw(
15583                cfg,
15584                "GET",
15585                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
15586                None,
15587                Auth::Required,
15588                MAX_FEED_RESPONSE_BYTES,
15589            )?,
15590            "subscribe feed",
15591        )?;
15592        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
15593            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
15594        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
15595            return Err(invalid_feed("brain card and feed head disagree"));
15596        }
15597        if feed.entries.len() > FEED_PAGE_LIMIT {
15598            return Err(invalid_feed("feed page exceeds the requested entry limit"));
15599        }
15600        if feed.scope_limited {
15601            if require_full_chain {
15602                return Err(invalid_feed(
15603                    "path-scoped grants cannot verify a full snapshot chain",
15604                ));
15605            }
15606            return Ok(VerifiedRemote {
15607                head: Head {
15608                    brain: resolved_brain,
15609                    seq,
15610                    updated_at,
15611                    feed_hash: advertised_hash,
15612                    verified: false,
15613                },
15614                identity: None,
15615                head_entry: None,
15616                entries: Vec::new(),
15617                anchor: None,
15618            });
15619        }
15620        let page_identity = feed
15621            .identity
15622            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
15623        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
15624        if identity
15625            .as_ref()
15626            .is_some_and(|existing| existing != &page_identity)
15627        {
15628            return Err(invalid_feed("identity changed while reading the feed"));
15629        }
15630        if anchor
15631            .as_ref()
15632            .is_some_and(|existing| existing != &page_anchor)
15633        {
15634            return Err(invalid_feed(
15635                "identity anchor changed while reading the feed",
15636            ));
15637        }
15638        identity = Some(page_identity.clone());
15639        if anchor.is_none() {
15640            anchor = Some(page_anchor);
15641        }
15642        if feed.entries.is_empty() {
15643            return Err(invalid_feed("feed page was empty before the signed head"));
15644        }
15645
15646        for item in feed.entries {
15647            if item.entry.seq != expected_seq {
15648                return Err(invalid_feed(format!(
15649                    "expected entry {expected_seq}, feed served {}",
15650                    item.entry.seq
15651                )));
15652            }
15653            if item.entry.seq > seq {
15654                return Err(invalid_feed("feed advanced past the card snapshot"));
15655            }
15656            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
15657                return Err(invalid_feed(format!(
15658                    "entry {} does not chain to the local checkpoint",
15659                    item.entry.seq
15660                )));
15661            }
15662            verify_feed_item(&item, &page_identity)?;
15663            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
15664            replay_bytes = replay_bytes.saturating_add(
15665                serde_json::to_vec(&item)
15666                    .map_err(|_| invalid_feed("could not size feed entry"))?
15667                    .len() as u64,
15668            );
15669            if replay_bytes > MAX_FEED_REPLAY_BYTES {
15670                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
15671            }
15672            previous_hash = Some(item.hash.clone());
15673            after = item.entry.seq;
15674            expected_seq = expected_seq
15675                .checked_add(1)
15676                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
15677            if require_full_chain {
15678                all_entries.push(item.clone());
15679            }
15680            observed_entries.push(item.clone());
15681            head_entry = Some(item);
15682        }
15683        if after == seq {
15684            break;
15685        }
15686    }
15687
15688    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
15689        return Err(invalid_feed(
15690            "verified chain does not converge on the advertised head",
15691        ));
15692    }
15693    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
15694    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
15695    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
15696    save_canonical_pin_and_alias(
15697        cfg,
15698        &trust_directory,
15699        brain,
15700        &resolved_brain,
15701        TrustState {
15702            v: 2,
15703            origin: normalized_origin(&cfg.hub)?,
15704            requested: resolved_brain.clone(),
15705            brain: resolved_brain.clone(),
15706            home: None,
15707            anchor: anchor.clone(),
15708            current: format!("ed25519:{}", identity.fingerprint),
15709            head_seq: seq,
15710            feed_hash: advertised_hash.clone(),
15711            rotations: identity.rotations.clone(),
15712            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
15713            protocol_profile: pinned
15714                .as_ref()
15715                .and_then(|state| state.protocol_profile.clone()),
15716        },
15717        alias_binding.as_ref(),
15718    )?;
15719    Ok(VerifiedRemote {
15720        head: Head {
15721            brain: resolved_brain,
15722            seq,
15723            updated_at,
15724            feed_hash: advertised_hash,
15725            verified: true,
15726        },
15727        identity: Some(identity),
15728        head_entry,
15729        entries: all_entries,
15730        anchor: Some(anchor),
15731    })
15732}
15733
15734/// Read and locally verify the brain's current signed feed head. Identity
15735/// rotation is accepted only through an old-key-signed chain rooted at the
15736/// local TOFU anchor; sequence and hash checkpoints reject rollback and
15737/// equivocation across invocations.
15738pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
15739    if let Some(verified) = v2_verified_head(cfg, brain)? {
15740        let observation = Head {
15741            brain: verified.brain_id.clone(),
15742            seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
15743            updated_at: verified
15744                .pointer
15745                .as_ref()
15746                .map(|pointer| pointer.signed_at.clone()),
15747            feed_hash: verified
15748                .pointer
15749                .as_ref()
15750                .map(|pointer| pointer.feed_hash.clone()),
15751            verified: true,
15752        };
15753        accept_v2_head(cfg, &verified)?;
15754        return Ok(observation);
15755    }
15756    Ok(verified_remote_head(cfg, brain, false)?.head)
15757}
15758
15759#[cfg(test)]
15760mod tests {
15761    use super::*;
15762
15763    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
15764
15765    fn drain_test_http_request(stream: &mut std::net::TcpStream) {
15766        use std::io::Read as _;
15767
15768        stream
15769            .set_read_timeout(Some(std::time::Duration::from_secs(2)))
15770            .unwrap();
15771        let mut request = Vec::new();
15772        let mut chunk = [0_u8; 4096];
15773        loop {
15774            let read = stream.read(&mut chunk).unwrap();
15775            assert!(read > 0, "test client closed before its request completed");
15776            request.extend_from_slice(&chunk[..read]);
15777            let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") else {
15778                continue;
15779            };
15780            let headers = String::from_utf8_lossy(&request[..header_end]);
15781            let content_length = headers
15782                .lines()
15783                .find_map(|line| {
15784                    let (name, value) = line.split_once(':')?;
15785                    name.eq_ignore_ascii_case("content-length")
15786                        .then(|| value.trim().parse::<usize>().unwrap())
15787                })
15788                .unwrap_or(0);
15789            if request.len() >= header_end + 4 + content_length {
15790                return;
15791            }
15792        }
15793    }
15794
15795    fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
15796        json!({
15797            "sha256": "a".repeat(64),
15798            "bytes": 10,
15799            "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
15800        })
15801    }
15802
15803    #[test]
15804    fn upload_reservations_batch_by_count_and_by_size() {
15805        // A real store's first push declares one blob per changed file, which
15806        // does not fit in one request. Every batch has to stay inside both
15807        // bounds, and concatenating them has to reproduce the original order.
15808        let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
15809        let batches = batch_upload_declarations(declarations.clone());
15810
15811        assert!(batches.len() > 1, "5,000 blobs must not ride one request");
15812        for batch in &batches {
15813            assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
15814            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15815                .expect("batch serializes")
15816                .len();
15817            assert!(
15818                bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
15819                "batch body {bytes} exceeds the reservation budget"
15820            );
15821        }
15822        let flattened: Vec<Value> = batches.into_iter().flatten().collect();
15823        assert_eq!(
15824            flattened, declarations,
15825            "batching must preserve the set and order"
15826        );
15827    }
15828
15829    #[test]
15830    fn only_load_shaped_hub_answers_are_worth_asking_again() {
15831        // A migration opens dozens of staging windows, so a busy hub's 429 or
15832        // 5xx must not end the push — while a refusal that states something
15833        // about the request itself has to surface immediately rather than
15834        // being repeated six times behind a minute of waiting.
15835        for status in [408, 429, 500, 502, 503, 504] {
15836            assert!(is_retryable_hub_status(status), "{status} is load-shaped");
15837        }
15838        for status in [400, 401, 403, 404, 409, 413, 422] {
15839            assert!(
15840                !is_retryable_hub_status(status),
15841                "{status} states something about the request"
15842            );
15843        }
15844        // The waits must reach past a whole limiter window to be worth taking.
15845        let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
15846        assert!(total >= 60_000, "backoff totals only {total}ms");
15847    }
15848
15849    #[test]
15850    fn a_batch_shares_a_connection_only_within_one_authority() {
15851        // Sharing a pooled agent is what makes a migration stage in minutes,
15852        // but it must never widen where bytes can go: a batch that spans two
15853        // authorities, or names an unsafe URL, falls back to per-object agents
15854        // rather than reusing a connection pinned to somewhere else.
15855        let cfg = HubConfig {
15856            hub: "https://www.sevrahq.com".to_string(),
15857            key: Some("k".to_string()),
15858            agent_key: None,
15859            brain_key: None,
15860            state_dir: PathBuf::from("."),
15861            store_selected: false,
15862        };
15863        assert!(shared_staging_agent(&cfg, &[]).is_none());
15864        assert!(
15865            shared_staging_agent(
15866                &cfg,
15867                &[
15868                    "https://one.example.com/a?sig=1",
15869                    "https://two.example.com/b?sig=2",
15870                ]
15871            )
15872            .is_none(),
15873            "two authorities must not share a pinned pool"
15874        );
15875        assert!(
15876            shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
15877            "an unsafe object-store URL must not produce an agent"
15878        );
15879        assert!(
15880            shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
15881            "credentials in the URL must not produce an agent"
15882        );
15883    }
15884
15885    #[test]
15886    fn a_staged_change_states_only_operations_and_blobs() {
15887        // The hub refuses a manifest with any other key, so the two sides have
15888        // to agree exactly. A drifting extra field would fail a whole migration
15889        // at the commit, after every byte had already been staged.
15890        let operations = vec![json!({
15891            "op": "put",
15892            "path": "records/a.md",
15893            "blob": "a".repeat(64),
15894            "bytes": 3,
15895        })];
15896        let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
15897        let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
15898        let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
15899        let keys: Vec<&str> = parsed
15900            .as_object()
15901            .expect("manifest is an object")
15902            .keys()
15903            .map(String::as_str)
15904            .collect();
15905        assert_eq!(keys, ["blobs", "operations"]);
15906        assert_eq!(parsed["operations"], Value::Array(operations));
15907        assert_eq!(parsed["blobs"], blobs);
15908    }
15909
15910    #[test]
15911    fn a_staged_push_signs_the_change_not_the_transport() {
15912        // Self-custody signs a request hash the hub recomputes from the parsed
15913        // change. A staged body no longer states its operations, so the signed
15914        // view has to restore them or every large self-custodied push would
15915        // sign a different request than the one the hub validated.
15916        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15917        let staged = json!({
15918            "mutation_id": "dbmd-1",
15919            "rebase": "strict",
15920            "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
15921        });
15922        let view = v2_signed_request_view(&staged, &operations);
15923        assert_eq!(view["operations"], Value::Array(operations.clone()));
15924        assert!(view.get("staged_change").is_none());
15925        assert_eq!(view["mutation_id"], staged["mutation_id"]);
15926
15927        let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
15928        assert_eq!(v2_signed_request_view(&inline, &[]), inline);
15929    }
15930
15931    #[test]
15932    fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
15933        let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
15934        let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
15935            .expect_err("an oversized change must not be staged");
15936        assert!(
15937            matches!(error, LinkError::PushTooLarge { .. }),
15938            "expected a size refusal, got {error:?}"
15939        );
15940    }
15941
15942    #[test]
15943    fn a_push_that_fits_the_request_is_left_inline() {
15944        // The staged lane costs a reservation and a round trip, so it must
15945        // engage only when the body genuinely does not fit. The hub here is a
15946        // closed port: reaching for it at all would fail this test.
15947        let cfg = HubConfig {
15948            hub: "http://127.0.0.1:9".to_string(),
15949            key: Some("k".to_string()),
15950            agent_key: None,
15951            brain_key: None,
15952            state_dir: PathBuf::from("."),
15953            store_selected: false,
15954        };
15955        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15956        let mut body = json!({
15957            "mutation_id": "dbmd-1",
15958            "operations": operations,
15959            "blobs": [],
15960        });
15961        stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
15962        assert!(body.get("staged_change").is_none());
15963        assert_eq!(body["operations"], Value::Array(operations));
15964    }
15965
15966    #[test]
15967    fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
15968        // Byte budget, not just count. One hash can carry many coordinates
15969        // (the same bytes reachable at several paths), so a batch sized only by
15970        // count would blow the body budget on a deduplicated store.
15971        let declarations: Vec<Value> = (0..2_000)
15972            .map(|index| {
15973                json!({
15974                    "sha256": "a".repeat(64),
15975                    "bytes": 10,
15976                    "coordinates": (0..24)
15977                        .map(|slot| format!(
15978                            "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15979                        ))
15980                        .collect::<Vec<_>>(),
15981                })
15982            })
15983            .collect();
15984        let batches = batch_upload_declarations(declarations);
15985        assert!(
15986            batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15987            "wide coordinate sets must bound the batch by size"
15988        );
15989        for batch in &batches {
15990            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15991                .expect("batch serializes")
15992                .len();
15993            assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15994        }
15995    }
15996
15997    #[test]
15998    fn a_small_push_still_rides_exactly_one_request() {
15999        let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
16000        assert_eq!(batch_upload_declarations(declarations).len(), 1);
16001        assert!(batch_upload_declarations(Vec::new()).is_empty());
16002    }
16003
16004    #[test]
16005    fn exact_source_move_becomes_one_provenance_preserving_rename() {
16006        let hash = "a".repeat(64);
16007        let operations = vec![
16008            json!({
16009                "op": "put",
16010                "path": "sources/curated/item.md",
16011                "expected": { "kind": "absent" },
16012                "blob": hash,
16013                "bytes": 19,
16014            }),
16015            json!({
16016                "op": "delete",
16017                "path": "sources/inbox/item.md",
16018                "expected": { "kind": "blob", "hash": hash },
16019            }),
16020        ];
16021
16022        assert_eq!(
16023            infer_exact_source_promotions(operations),
16024            vec![json!({
16025                "op": "rename",
16026                "from": "sources/inbox/item.md",
16027                "to": "sources/curated/item.md",
16028                "expected_from": { "kind": "blob", "hash": hash },
16029                "expected_to": { "kind": "absent" },
16030                "blob": hash,
16031                "bytes": 19,
16032            })]
16033        );
16034    }
16035
16036    #[test]
16037    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
16038        let hash = "b".repeat(64);
16039        let operations = vec![
16040            json!({
16041                "op": "delete",
16042                "path": "sources/inbox/a.md",
16043                "expected": { "kind": "blob", "hash": hash },
16044            }),
16045            json!({
16046                "op": "delete",
16047                "path": "sources/inbox/b.md",
16048                "expected": { "kind": "blob", "hash": hash },
16049            }),
16050            json!({
16051                "op": "put",
16052                "path": "sources/curated/item.md",
16053                "expected": { "kind": "absent" },
16054                "blob": hash,
16055                "bytes": 19,
16056            }),
16057        ];
16058
16059        assert_eq!(
16060            infer_exact_source_promotions(operations.clone()),
16061            operations,
16062            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
16063        );
16064    }
16065
16066    #[test]
16067    fn an_exact_dual_plane_source_move_remains_a_provenance_preserving_rename() {
16068        let hash = "c".repeat(64);
16069        let operations = vec![
16070            json!({
16071                "op": "delete",
16072                "path": "sources/inbox/item.md",
16073                "expected": { "kind": "blob", "hash": hash },
16074            }),
16075            json!({
16076                "op": "put_asset_content",
16077                "path": "sources/archive/item.md",
16078                "expected": { "kind": "absent" },
16079                "blob": hash,
16080                "bytes": 19,
16081            }),
16082        ];
16083        assert_eq!(
16084            infer_exact_source_promotions(operations),
16085            vec![json!({
16086                "op": "rename",
16087                "from": "sources/inbox/item.md",
16088                "to": "sources/archive/item.md",
16089                "expected_from": { "kind": "blob", "hash": hash },
16090                "expected_to": { "kind": "absent" },
16091                "blob": hash,
16092                "bytes": 19,
16093            })]
16094        );
16095    }
16096
16097    #[test]
16098    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
16099        let hash = "c".repeat(64);
16100        let mut candidate = std::collections::BTreeMap::from([(
16101            "sources/inbox/item.md".to_string(),
16102            V2BaselineFile {
16103                sha256: hash.clone(),
16104                bytes: 19,
16105                proof: None,
16106            },
16107        )]);
16108        let mut candidate_assets = std::collections::BTreeMap::new();
16109        let operations = vec![
16110            json!({
16111                "op": "rename",
16112                "from": "sources/inbox/item.md",
16113                "to": "sources/curated/item.md",
16114                "expected_from": { "kind": "blob", "hash": hash },
16115                "expected_to": { "kind": "absent" },
16116                "blob": hash,
16117                "bytes": 19,
16118            }),
16119            json!({
16120                "op": "put",
16121                "path": "records/rsvps/item.md",
16122                "expected": { "kind": "absent" },
16123                "blob": "d".repeat(64),
16124                "bytes": 23,
16125            }),
16126        ];
16127
16128        assert!(!apply_generated_v2_operations(
16129            &operations,
16130            &std::collections::BTreeMap::new(),
16131            &mut candidate,
16132            &mut candidate_assets,
16133        )
16134        .unwrap());
16135        assert!(!candidate.contains_key("sources/inbox/item.md"));
16136        assert_eq!(
16137            candidate
16138                .get("sources/curated/item.md")
16139                .map(|file| (&file.sha256, file.bytes)),
16140            Some((&hash, 19))
16141        );
16142        assert_eq!(
16143            candidate
16144                .get("records/rsvps/item.md")
16145                .map(|file| (file.sha256.as_str(), file.bytes)),
16146            Some((
16147                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
16148                23
16149            ))
16150        );
16151    }
16152
16153    fn merge_fixture(
16154        base: Option<&str>,
16155        remote: Option<&str>,
16156        local: Option<&str>,
16157        keep_local: bool,
16158    ) -> V2PulledMerge<String> {
16159        let map = |value: Option<&str>| {
16160            value
16161                .map(|value| [("records/a.md".to_string(), value.to_string())])
16162                .into_iter()
16163                .flatten()
16164                .collect::<std::collections::BTreeMap<_, _>>()
16165        };
16166        merge_v2_pulled_records(
16167            &map(base),
16168            &map(remote),
16169            &map(local),
16170            |value, _| value.clone(),
16171            |value, _| value.clone(),
16172            |_| keep_local,
16173        )
16174    }
16175
16176    #[test]
16177    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
16178        let path = "records/a.md".to_string();
16179
16180        let local_add = merge_fixture(None, None, Some("local"), false);
16181        assert_eq!(
16182            local_add.records.get(&path).map(String::as_str),
16183            Some("local")
16184        );
16185        assert!(local_add.accept_remote.is_empty());
16186        assert!(local_add.conflicts.is_empty());
16187
16188        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
16189        assert_eq!(
16190            local_edit.records.get(&path).map(String::as_str),
16191            Some("local")
16192        );
16193        assert!(local_edit.accept_remote.is_empty());
16194        assert!(local_edit.conflicts.is_empty());
16195
16196        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
16197        assert!(!local_delete.records.contains_key(&path));
16198        assert!(local_delete.accept_remote.is_empty());
16199        assert!(local_delete.conflicts.is_empty());
16200
16201        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
16202        assert_eq!(
16203            remote_edit.records.get(&path).map(String::as_str),
16204            Some("remote")
16205        );
16206        assert!(remote_edit.accept_remote.contains(&path));
16207        assert!(remote_edit.conflicts.is_empty());
16208
16209        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
16210        assert!(!remote_delete.records.contains_key(&path));
16211        assert!(remote_delete.accept_remote.contains(&path));
16212        assert!(remote_delete.conflicts.is_empty());
16213
16214        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
16215        assert_eq!(
16216            same_edit.records.get(&path).map(String::as_str),
16217            Some("same")
16218        );
16219        assert!(same_edit.accept_remote.contains(&path));
16220        assert!(same_edit.conflicts.is_empty());
16221
16222        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
16223        assert_eq!(conflict.conflicts, vec![path.clone()]);
16224        assert_eq!(
16225            conflict.records.get(&path).map(String::as_str),
16226            Some("local")
16227        );
16228        assert!(conflict.accept_remote.is_empty());
16229
16230        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
16231        assert_eq!(
16232            kept_home.records.get(&path).map(String::as_str),
16233            Some("local")
16234        );
16235        assert!(kept_home.accept_remote.is_empty());
16236        assert!(kept_home.conflicts.is_empty());
16237    }
16238
16239    #[test]
16240    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
16241        let path = "sources/report.pdf";
16242        let record = crate::AssetRecord {
16243            path: path.to_string(),
16244            sha256: "a".repeat(64),
16245            bytes: 42,
16246            media_type: "application/pdf".to_string(),
16247            wrappers: vec!["gzip".to_string()],
16248            required: true,
16249        };
16250        let mut remote = V2BaselineAsset {
16251            blob_sha256: record.sha256.clone(),
16252            bytes: record.bytes,
16253            media_type: record.media_type.clone(),
16254            wrappers: record.wrappers.clone(),
16255            required: record.required,
16256            disposition: "withheld".to_string(),
16257            leaf_hash: "b".repeat(64),
16258        };
16259
16260        assert!(v2_asset_resumes_hosting(
16261            Some(&remote),
16262            path,
16263            &record,
16264            "hosted"
16265        ));
16266        assert!(!v2_asset_resumes_hosting(
16267            Some(&remote),
16268            path,
16269            &record,
16270            "withheld"
16271        ));
16272
16273        remote.disposition = "hosted".to_string();
16274        assert!(!v2_asset_resumes_hosting(
16275            Some(&remote),
16276            path,
16277            &record,
16278            "hosted"
16279        ));
16280
16281        remote.disposition = "withheld".to_string();
16282        remote.blob_sha256 = "c".repeat(64);
16283        assert!(!v2_asset_resumes_hosting(
16284            Some(&remote),
16285            path,
16286            &record,
16287            "hosted"
16288        ));
16289        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
16290    }
16291
16292    #[test]
16293    fn v2_fresh_clone_preserves_only_exact_inherited_withheld_asset_absence() {
16294        let path = "sources/report.pdf";
16295        let record = crate::AssetRecord {
16296            path: path.to_string(),
16297            sha256: "a".repeat(64),
16298            bytes: 42,
16299            media_type: "application/pdf".to_string(),
16300            wrappers: vec!["records/report.md".to_string()],
16301            required: true,
16302        };
16303        let mut base = V2BaselineAsset {
16304            blob_sha256: record.sha256.clone(),
16305            bytes: record.bytes,
16306            media_type: record.media_type.clone(),
16307            wrappers: record.wrappers.clone(),
16308            required: record.required,
16309            disposition: "withheld".to_string(),
16310            leaf_hash: "b".repeat(64),
16311        };
16312
16313        assert!(v2_asset_inherits_withheld_absence(
16314            Some(&base),
16315            Some(&record),
16316            Some(&record),
16317            false,
16318        ));
16319        assert!(!v2_asset_inherits_withheld_absence(
16320            Some(&base),
16321            Some(&record),
16322            Some(&record),
16323            true,
16324        ));
16325
16326        base.disposition = "hosted".to_string();
16327        assert!(!v2_asset_inherits_withheld_absence(
16328            Some(&base),
16329            Some(&record),
16330            Some(&record),
16331            false,
16332        ));
16333
16334        base.disposition = "withheld".to_string();
16335        let mut changed = record.clone();
16336        changed.bytes += 1;
16337        assert!(!v2_asset_inherits_withheld_absence(
16338            Some(&base),
16339            Some(&record),
16340            Some(&changed),
16341            false,
16342        ));
16343        assert!(!v2_asset_inherits_withheld_absence(
16344            None,
16345            None,
16346            Some(&record),
16347            false,
16348        ));
16349    }
16350
16351    #[test]
16352    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
16353        let path = "records/team/alpha.md".to_string();
16354        let deleted_path = "records/team/deleted.md".to_string();
16355        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
16356            sha256,
16357            bytes,
16358            file: None,
16359        };
16360        let files = vec![
16361            V2ConflictFile {
16362                path: path.clone(),
16363                base: coordinate(None, None),
16364                local: coordinate(Some("b".repeat(64)), Some(7)),
16365                remote: coordinate(Some("a".repeat(64)), Some(5)),
16366            },
16367            V2ConflictFile {
16368                path: deleted_path.clone(),
16369                base: coordinate(Some("c".repeat(64)), Some(9)),
16370                local: coordinate(Some("d".repeat(64)), Some(11)),
16371                remote: coordinate(None, None),
16372            },
16373        ];
16374        let proven = V2BaselineFile {
16375            sha256: "a".repeat(64),
16376            bytes: 5,
16377            proof: None,
16378        };
16379        let current = [(path.clone(), proven.clone())]
16380            .into_iter()
16381            .collect::<std::collections::BTreeMap<_, _>>();
16382
16383        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
16384        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
16385        assert_eq!(deleted, vec![deleted_path.clone()]);
16386
16387        let changed = [(
16388            path.clone(),
16389            V2BaselineFile {
16390                sha256: "e".repeat(64),
16391                bytes: 5,
16392                proof: None,
16393            },
16394        )]
16395        .into_iter()
16396        .collect::<std::collections::BTreeMap<_, _>>();
16397        assert!(v2_take_remote_selection(&files, &changed).is_err());
16398
16399        let resurrected = [
16400            (path, proven),
16401            (
16402                deleted_path,
16403                V2BaselineFile {
16404                    sha256: "f".repeat(64),
16405                    bytes: 13,
16406                    proof: None,
16407                },
16408            ),
16409        ]
16410        .into_iter()
16411        .collect::<std::collections::BTreeMap<_, _>>();
16412        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
16413    }
16414
16415    #[cfg(target_os = "linux")]
16416    #[test]
16417    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
16418        use std::os::fd::AsRawFd as _;
16419
16420        let sandbox = tempfile::TempDir::new().unwrap();
16421        let parent = std::fs::File::open(sandbox.path()).unwrap();
16422        let stage = std::ffi::CString::new("stage").unwrap();
16423        let destination = std::ffi::CString::new("brain").unwrap();
16424
16425        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
16426        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
16427        install_stage_at(
16428            parent.as_raw_fd(),
16429            stage.as_c_str(),
16430            destination.as_c_str(),
16431            false,
16432        )
16433        .unwrap();
16434        assert!(!sandbox.path().join("stage").exists());
16435        assert_eq!(
16436            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
16437            b"created"
16438        );
16439
16440        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
16441        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
16442        install_stage_at(
16443            parent.as_raw_fd(),
16444            stage.as_c_str(),
16445            destination.as_c_str(),
16446            true,
16447        )
16448        .unwrap();
16449        assert_eq!(
16450            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
16451            b"replacement"
16452        );
16453        assert_eq!(
16454            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
16455            b"created",
16456            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
16457        );
16458    }
16459
16460    struct SignedRemoteFixture {
16461        card: String,
16462        feed: String,
16463        key: AgentSigningKey,
16464        identity: FeedIdentity,
16465    }
16466
16467    fn signed_remote_fixture() -> SignedRemoteFixture {
16468        let rng = ring::rand::SystemRandom::new();
16469        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16470        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16471        let (public_key, multikey) = public_identity_for(&pair);
16472        let identity = FeedIdentity {
16473            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16474            public_key_spki: public_key.clone(),
16475            previous: Vec::new(),
16476            rotations: Vec::new(),
16477        };
16478        let mut entry = FeedEntry {
16479            v: 1,
16480            seq: 1,
16481            ts: "2026-07-30T12:00:00.000Z".to_string(),
16482            brain: multikey.clone(),
16483            public_key: public_key.clone(),
16484            kind: "push".to_string(),
16485            op: "snapshot".to_string(),
16486            pack_sha256: "a".repeat(64),
16487            files: Vec::new(),
16488            removed: Vec::new(),
16489            prev_entry_hash: None,
16490            sig: String::new(),
16491        };
16492        let unsigned = UnsignedFeedEntry {
16493            v: entry.v,
16494            seq: entry.seq,
16495            ts: &entry.ts,
16496            brain: &entry.brain,
16497            public_key: &entry.public_key,
16498            kind: &entry.kind,
16499            op: &entry.op,
16500            pack_sha256: &entry.pack_sha256,
16501            files: &entry.files,
16502            removed: &entry.removed,
16503            prev_entry_hash: &entry.prev_entry_hash,
16504        };
16505        entry.sig =
16506            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16507        let mut exact = serde_json::to_vec(&entry).unwrap();
16508        exact.push(b'\n');
16509        let hash = content_sha256(&exact);
16510        let card = json!({
16511            "id": TEST_BRAIN_ID,
16512            "headSeq": 1,
16513            "feedHash": hash,
16514            "identity": identity.clone(),
16515        })
16516        .to_string();
16517        let feed = json!({
16518            "headSeq": 1,
16519            "feedHash": hash,
16520            "identity": identity.clone(),
16521            "entries": [{"hash": hash, "entry": entry}],
16522            "scopeLimited": false,
16523        })
16524        .to_string();
16525        SignedRemoteFixture {
16526            card,
16527            feed,
16528            key: AgentSigningKey {
16529                pkcs8: pkcs8.as_ref().to_vec(),
16530                multikey,
16531                public_key_spki: public_key,
16532            },
16533            identity,
16534        }
16535    }
16536
16537    #[test]
16538    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
16539        let file = |path: &str, byte: char| FeedFile {
16540            path: path.to_string(),
16541            sha256: byte.to_string().repeat(64),
16542            bytes: 1,
16543        };
16544        let a0 = file("records/a.md", 'a');
16545        let a1 = file("records/a.md", 'b');
16546        let stable = file("records/stable.md", 'c');
16547        let added = file("records/added.md", 'd');
16548        let removed_file = file("records/removed.md", 'e');
16549        let previous = vec![a0, stable.clone(), removed_file.clone()];
16550        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
16551        let removed = vec![removed_file.path.clone()];
16552
16553        assert_eq!(
16554            verify_v1_manifest_disclosure(
16555                "edit",
16556                &previous,
16557                &resulting,
16558                &[a1.clone(), added.clone()],
16559                &removed,
16560            ),
16561            Ok(())
16562        );
16563        assert_eq!(
16564            verify_v1_manifest_disclosure(
16565                "edit",
16566                &previous,
16567                &resulting,
16568                &[stable.clone(), added.clone(), a1.clone()],
16569                &removed,
16570            ),
16571            Ok(())
16572        );
16573        assert_eq!(
16574            verify_v1_manifest_disclosure(
16575                "edit",
16576                &previous,
16577                &resulting,
16578                std::slice::from_ref(&added),
16579                &removed,
16580            ),
16581            Err(V1DisclosureError::EditMissingChange)
16582        );
16583        assert_eq!(
16584            verify_v1_manifest_disclosure(
16585                "edit",
16586                &previous,
16587                &resulting,
16588                &[file("records/a.md", 'f'), added.clone()],
16589                &removed,
16590            ),
16591            Err(V1DisclosureError::EditFalseFile)
16592        );
16593        assert_eq!(
16594            verify_v1_manifest_disclosure(
16595                "edit",
16596                &previous,
16597                &resulting,
16598                &[a1.clone(), added.clone()],
16599                &[],
16600            ),
16601            Err(V1DisclosureError::RemovedMismatch)
16602        );
16603        assert_eq!(
16604            verify_v1_manifest_disclosure(
16605                "push",
16606                &previous,
16607                &resulting,
16608                &[added.clone(), stable, a1],
16609                &removed,
16610            ),
16611            Ok(())
16612        );
16613        assert_eq!(
16614            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
16615            Err(V1DisclosureError::PushManifestMismatch)
16616        );
16617    }
16618
16619    #[test]
16620    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
16621        let fixture = signed_remote_fixture();
16622        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
16623        let item = feed["entries"][0].to_string();
16624        let oversized_page = format!(
16625            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
16626            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
16627                .collect::<Vec<_>>()
16628                .join(",")
16629        );
16630        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
16631
16632        let oversized_identity = format!(
16633            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
16634            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
16635                .collect::<Vec<_>>()
16636                .join(",")
16637        );
16638        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
16639
16640        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
16641        let oversized_entry = format!(
16642            "{{\"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\"}}",
16643            "a".repeat(64),
16644            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
16645                .collect::<Vec<_>>()
16646                .join(",")
16647        );
16648        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
16649    }
16650
16651    #[test]
16652    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
16653        let id = "01arz3ndektsv4rrffq69g5fav";
16654        let digest = "a".repeat(64);
16655        assert_eq!(
16656            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
16657            V2BulkConfirmation {
16658                id: id.to_string(),
16659                digest,
16660            }
16661        );
16662        for invalid in [
16663            "",
16664            "01arz3ndektsv4rrffq69g5fav",
16665            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
16666            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
16667            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
16668        ] {
16669            assert!(matches!(
16670                V2BulkConfirmation::parse(invalid),
16671                Err(LinkError::InvalidPack { .. })
16672            ));
16673        }
16674    }
16675
16676    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
16677        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
16678        use std::net::TcpListener;
16679
16680        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16681        let url = format!("http://{}", listener.local_addr().unwrap());
16682        let handle = std::thread::spawn(move || {
16683            for (status, body) in responses {
16684                let (stream, _) = listener.accept().unwrap();
16685                let mut reader = BufReader::new(stream);
16686                let mut line = String::new();
16687                reader.read_line(&mut line).unwrap();
16688                let mut content_length = 0usize;
16689                loop {
16690                    line.clear();
16691                    reader.read_line(&mut line).unwrap();
16692                    if line == "\r\n" || line == "\n" || line.is_empty() {
16693                        break;
16694                    }
16695                    if let Some((name, value)) = line.split_once(':') {
16696                        if name.eq_ignore_ascii_case("content-length") {
16697                            content_length = value.trim().parse().unwrap();
16698                        }
16699                    }
16700                }
16701                let mut request_body = vec![0_u8; content_length];
16702                reader.read_exact(&mut request_body).unwrap();
16703                let response = format!(
16704                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
16705                    body.len()
16706                );
16707                reader.get_mut().write_all(response.as_bytes()).unwrap();
16708            }
16709        });
16710        (url, handle)
16711    }
16712
16713    fn routed_json_hub(
16714        requests: usize,
16715        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
16716    ) -> (String, std::thread::JoinHandle<()>) {
16717        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
16718        use std::net::TcpListener;
16719
16720        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16721        let url = format!("http://{}", listener.local_addr().unwrap());
16722        let handle = std::thread::spawn(move || {
16723            for _ in 0..requests {
16724                let (stream, _) = listener.accept().unwrap();
16725                let mut reader = BufReader::new(stream);
16726                let mut line = String::new();
16727                reader.read_line(&mut line).unwrap();
16728                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
16729                let mut content_length = 0usize;
16730                loop {
16731                    line.clear();
16732                    reader.read_line(&mut line).unwrap();
16733                    if line == "\r\n" || line == "\n" || line.is_empty() {
16734                        break;
16735                    }
16736                    if let Some((name, value)) = line.split_once(':') {
16737                        if name.eq_ignore_ascii_case("content-length") {
16738                            content_length = value.trim().parse().unwrap();
16739                        }
16740                    }
16741                }
16742                let mut request_body = vec![0_u8; content_length];
16743                reader.read_exact(&mut request_body).unwrap();
16744                let (status, body) = respond(&path);
16745                let response = format!(
16746                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
16747                    body.len()
16748                );
16749                reader.get_mut().write_all(response.as_bytes()).unwrap();
16750            }
16751        });
16752        (url, handle)
16753    }
16754
16755    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
16756        HubConfig {
16757            hub,
16758            key: Some("test-key".to_string()),
16759            agent_key: None,
16760            brain_key: None,
16761            state_dir,
16762            store_selected: false,
16763        }
16764    }
16765
16766    #[cfg(any(unix, windows))]
16767    #[test]
16768    fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
16769        use std::sync::{Arc, Mutex};
16770
16771        let bytes = b"immutable asset bytes".to_vec();
16772        let sha256 = content_sha256(&bytes);
16773        let commit_hash = "c".repeat(64);
16774        let base_url = Arc::new(Mutex::new(String::new()));
16775        let server_base = Arc::clone(&base_url);
16776        let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16777        let server_attempt = Arc::clone(&object_attempt);
16778        let response_bytes = bytes.clone();
16779        let response_sha = sha256.clone();
16780        let response_commit = commit_hash.clone();
16781        let (hub, server) = routed_json_hub(4, move |path| {
16782            if path.contains("/v2/assets/downloads") {
16783                let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
16784                let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
16785                return (
16786                    200,
16787                    json!({
16788                        "v": 2,
16789                        "commit": response_commit,
16790                        "downloads": [{
16791                            "path": "assets/proof.bin",
16792                            "sha256": response_sha,
16793                            "bytes": response_bytes.len(),
16794                            "url": url,
16795                            "method": "GET"
16796                        }]
16797                    })
16798                    .to_string(),
16799                );
16800            }
16801            let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16802            if attempt == 0 {
16803                (403, "{}".to_string())
16804            } else {
16805                (200, String::from_utf8(response_bytes.clone()).unwrap())
16806            }
16807        });
16808        *base_url.lock().unwrap() = hub.clone();
16809
16810        let temp = tempfile::tempdir().unwrap();
16811        let cache = temp.path().join("cache");
16812        std::fs::create_dir(&cache).unwrap();
16813        let cfg = test_hub_config(hub, temp.path().to_path_buf());
16814        let pointer = V2PointerBody {
16815            v: 2,
16816            brain: TEST_BRAIN_ID.to_string(),
16817            seq: 1,
16818            commit_hash,
16819            feed_hash: "f".repeat(64),
16820            content_root: Some("a".repeat(64)),
16821            asset_root: Some("b".repeat(64)),
16822            materializer: "m".repeat(64),
16823            signer_epoch: 1,
16824            control_revision: "d".repeat(64),
16825            backup_preparation: "ready".to_string(),
16826            prior_pointer_hash: None,
16827            signed_at: "2026-08-23T00:00:00Z".to_string(),
16828        };
16829        let path = "assets/proof.bin".to_string();
16830        let asset = V2BaselineAsset {
16831            blob_sha256: sha256.clone(),
16832            bytes: bytes.len() as u64,
16833            media_type: "application/octet-stream".to_string(),
16834            wrappers: Vec::new(),
16835            required: true,
16836            disposition: "hosted".to_string(),
16837            leaf_hash: "e".repeat(64),
16838        };
16839
16840        let staged = stage_v2_asset_download_window(
16841            &cfg,
16842            TEST_BRAIN_ID,
16843            &pointer,
16844            &cache,
16845            &[(&path, &asset)],
16846        )
16847        .expect("a fresh authority-checked capability recovers an expired one");
16848        assert_eq!(staged.len(), 1);
16849        assert_eq!(staged[0].path, path);
16850        assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
16851        assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
16852        server.join().unwrap();
16853    }
16854
16855    #[cfg(any(unix, windows))]
16856    #[test]
16857    fn asset_capability_windows_cannot_exceed_the_worker_bound() {
16858        let temp = tempfile::tempdir().unwrap();
16859        let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
16860        let pointer = V2PointerBody {
16861            v: 2,
16862            brain: TEST_BRAIN_ID.to_string(),
16863            seq: 1,
16864            commit_hash: "c".repeat(64),
16865            feed_hash: "f".repeat(64),
16866            content_root: Some("a".repeat(64)),
16867            asset_root: Some("b".repeat(64)),
16868            materializer: "m".repeat(64),
16869            signer_epoch: 1,
16870            control_revision: "d".repeat(64),
16871            backup_preparation: "ready".to_string(),
16872            prior_pointer_hash: None,
16873            signed_at: "2026-08-23T00:00:00Z".to_string(),
16874        };
16875        let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
16876            .map(|index| format!("assets/{index}.bin"))
16877            .collect::<Vec<_>>();
16878        let assets = paths
16879            .iter()
16880            .map(|_| V2BaselineAsset {
16881                blob_sha256: "a".repeat(64),
16882                bytes: 1,
16883                media_type: "application/octet-stream".to_string(),
16884                wrappers: Vec::new(),
16885                required: true,
16886                disposition: "hosted".to_string(),
16887                leaf_hash: "b".repeat(64),
16888            })
16889            .collect::<Vec<_>>();
16890        let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
16891
16892        let error =
16893            stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
16894                .expect_err("an oversized window must fail before any network request");
16895        assert!(matches!(error, LinkError::InvalidFeed { .. }));
16896    }
16897
16898    #[test]
16899    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
16900        use ring::signature::KeyPair as _;
16901
16902        let rng = ring::rand::SystemRandom::new();
16903        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16904        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16905        let (spki, multikey) = public_identity_for(&pair);
16906        let key = AgentSigningKey {
16907            pkcs8: pkcs8.as_ref().to_vec(),
16908            multikey,
16909            public_key_spki: spki,
16910        };
16911        let header = linkmd_sig_header(
16912            &key,
16913            "https://hub-a.example",
16914            "post",
16915            "/api/hub/brains/brain/push?mode=exact",
16916            Some("{\"ok\":true}"),
16917        )
16918        .unwrap();
16919        assert!(header.starts_with("LinkMD-Sig v2,"));
16920        let ts = header
16921            .split(",ts=")
16922            .nth(1)
16923            .unwrap()
16924            .split(',')
16925            .next()
16926            .unwrap();
16927        let signature = URL_SAFE_NO_PAD
16928            .decode(header.rsplit(",sig=").next().unwrap())
16929            .unwrap();
16930        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
16931        let accepted = format!(
16932            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16933        );
16934        let replayed = format!(
16935            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16936        );
16937        let public = pair.public_key().as_ref();
16938        assert!(UnparsedPublicKey::new(&ED25519, public)
16939            .verify(accepted.as_bytes(), &signature)
16940            .is_ok());
16941        assert!(
16942            UnparsedPublicKey::new(&ED25519, public)
16943                .verify(replayed.as_bytes(), &signature)
16944                .is_err(),
16945            "a proof captured at hub A must not authenticate at hub B"
16946        );
16947    }
16948
16949    #[test]
16950    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
16951        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16952        let card = json!({
16953            "id": other,
16954            "headSeq": 0,
16955            "identity": signed_remote_fixture().identity,
16956        })
16957        .to_string();
16958        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16959        let state = tempfile::tempdir().unwrap();
16960        let cfg = test_hub_config(hub, state.path().to_path_buf());
16961        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16962        assert!(
16963            error.contains("differs from the explicitly requested"),
16964            "{error}"
16965        );
16966        server.join().unwrap();
16967    }
16968
16969    #[test]
16970    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
16971        let first = signed_remote_fixture().identity;
16972        let second = signed_remote_fixture().identity;
16973        let card = |identity: FeedIdentity| {
16974            json!({
16975                "id": TEST_BRAIN_ID,
16976                "headSeq": 0,
16977                "identity": identity,
16978            })
16979            .to_string()
16980        };
16981        let (hub, server) = scripted_json_hub(vec![
16982            (404, "{}".to_string()),
16983            (200, card(first)),
16984            (404, "{}".to_string()),
16985            (200, card(second)),
16986        ]);
16987        let state = tempfile::tempdir().unwrap();
16988        let cfg = test_hub_config(hub, state.path().to_path_buf());
16989        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16990        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16991        assert!(
16992            error.contains("pinned anchor") || error.contains("forked away"),
16993            "{error}"
16994        );
16995        server.join().unwrap();
16996    }
16997
16998    #[test]
16999    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
17000        let old = signed_remote_fixture();
17001        let new = signed_remote_fixture();
17002        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
17003        let unsigned = serde_json::to_string(&UnsignedRotation {
17004            v: 1,
17005            op: "rotate",
17006            brain: &old.key.multikey,
17007            public_key: &old.key.public_key_spki,
17008            new_brain: &new.key.multikey,
17009            new_public_key: &new.key.public_key_spki,
17010            prior_head_seq: 1,
17011            prior_feed_hash: Some(&"a".repeat(64)),
17012            ts: "2026-07-30T12:00:00.000Z".to_string(),
17013        })
17014        .unwrap();
17015        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
17016        let rotation = format!(
17017            "{},\"sig\":\"{}\"}}",
17018            &unsigned[..unsigned.len() - 1],
17019            signature
17020        );
17021        let identity = FeedIdentity {
17022            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
17023            public_key_spki: new.key.public_key_spki,
17024            previous: vec![PreviousIdentity {
17025                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
17026                public_key_spki: old.key.public_key_spki,
17027            }],
17028            rotations: vec![rotation],
17029        };
17030        let card = json!({
17031            "id": TEST_BRAIN_ID,
17032            "headSeq": 0,
17033            "feedHash": null,
17034            "identity": identity,
17035        })
17036        .to_string();
17037        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
17038        let state = tempfile::tempdir().unwrap();
17039        let cfg = test_hub_config(hub, state.path().to_path_buf());
17040        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
17041        assert!(
17042            error.contains("rotation claims a feed boundary beyond the advertised head"),
17043            "{error}"
17044        );
17045        assert!(
17046            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
17047            "an inconsistent empty-head identity must not become the TOFU checkpoint"
17048        );
17049        server.join().unwrap();
17050    }
17051
17052    #[test]
17053    fn trust_checkpoint_rejects_a_later_fork() {
17054        let fixture = signed_remote_fixture();
17055        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
17056        fork["feedHash"] = Value::String("b".repeat(64));
17057        let (hub, server) = scripted_json_hub(vec![
17058            (404, "{}".to_string()),
17059            (200, fixture.card),
17060            (200, fixture.feed),
17061            (404, "{}".to_string()),
17062            (200, fork.to_string()),
17063        ]);
17064        let state = tempfile::tempdir().unwrap();
17065        let cfg = test_hub_config(hub, state.path().to_path_buf());
17066        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
17067        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
17068        server.join().unwrap();
17069    }
17070
17071    #[test]
17072    fn alias_and_canonical_id_share_one_identity_checkpoint() {
17073        let trusted = signed_remote_fixture();
17074        let attacker = signed_remote_fixture();
17075        let (hub, server) = scripted_json_hub(vec![
17076            (404, "{}".to_string()),
17077            (200, trusted.card),
17078            (200, trusted.feed),
17079            (404, "{}".to_string()),
17080            (200, attacker.card),
17081        ]);
17082        let state = tempfile::tempdir().unwrap();
17083        let cfg = test_hub_config(hub, state.path().to_path_buf());
17084        assert!(head(&cfg, "trusted-slug").unwrap().verified);
17085        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
17086        assert!(
17087            error.contains("equivocation")
17088                || error.contains("pinned")
17089                || error.contains("identity"),
17090            "{error}"
17091        );
17092        server.join().unwrap();
17093    }
17094
17095    #[test]
17096    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
17097        let state = tempfile::tempdir().unwrap();
17098        let cfg = test_hub_config(
17099            "https://hub.example".to_string(),
17100            state.path().to_path_buf(),
17101        );
17102        let directory = open_trust_dir(&cfg).unwrap();
17103        let old = TEST_BRAIN_ID;
17104        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17105        save_alias_in(
17106            &cfg,
17107            &directory,
17108            &AliasBinding {
17109                v: 1,
17110                origin: normalized_origin(&cfg.hub).unwrap(),
17111                requested: "company-brain".to_string(),
17112                brain: old.to_string(),
17113                home: Some("company-brain".to_string()),
17114            },
17115        )
17116        .unwrap();
17117
17118        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
17119        assert!(matches!(
17120            error,
17121            LinkError::AliasRebindRequired {
17122                alias,
17123                from,
17124                to
17125            } if alias == "company-brain" && from == old && to == new
17126        ));
17127        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
17128            .unwrap()
17129            .unwrap();
17130        assert_eq!(unchanged.brain, old);
17131        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
17132    }
17133
17134    #[test]
17135    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
17136        let alpha = signed_remote_fixture();
17137        let beta = signed_remote_fixture();
17138        let alpha_card = alpha.card.clone();
17139        let alpha_feed = alpha.feed.clone();
17140        let beta_card = beta.card.clone();
17141        let beta_feed = beta.feed.clone();
17142        let (hub, server) = routed_json_hub(5, move |path| {
17143            if path.ends_with("/v2/head") {
17144                (404, "{}".to_string())
17145            } else if path.contains("/alpha/feed?") {
17146                (200, alpha_feed.clone())
17147            } else if path.contains("/beta/feed?") {
17148                (200, beta_feed.clone())
17149            } else if path.ends_with("/alpha") {
17150                (200, alpha_card.clone())
17151            } else if path.ends_with("/beta") {
17152                (200, beta_card.clone())
17153            } else {
17154                (500, r#"{"error":"unexpected path"}"#.to_string())
17155            }
17156        });
17157        let state = tempfile::tempdir().unwrap();
17158        let cfg = test_hub_config(hub, state.path().to_path_buf());
17159        let alpha_cfg = cfg.clone();
17160        let beta_cfg = cfg;
17161        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
17162        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
17163        let results = [first.join().unwrap(), second.join().unwrap()];
17164        assert_eq!(
17165            results.iter().filter(|result| result.is_ok()).count(),
17166            1,
17167            "only one alias identity may establish canonical TOFU: {results:?}"
17168        );
17169        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
17170        server.join().unwrap();
17171    }
17172
17173    #[cfg(unix)]
17174    #[test]
17175    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
17176        use std::os::unix::fs::symlink;
17177
17178        let fixture = signed_remote_fixture();
17179        let card = json!({
17180            "id": TEST_BRAIN_ID,
17181            "headSeq": 0,
17182            "feedHash": Value::Null,
17183            "identity": fixture.identity,
17184        })
17185        .to_string();
17186        let work = tempfile::tempdir().unwrap();
17187        let outside = tempfile::tempdir().unwrap();
17188        let state = work.path().join("state");
17189        let moved = work.path().join("state-held");
17190        let swap_state = state.clone();
17191        let swap_moved = moved.clone();
17192        let outside_path = outside.path().to_path_buf();
17193        let (hub, server) = routed_json_hub(1, move |_| {
17194            // The client has already opened state/trust before this response.
17195            std::fs::rename(&swap_state, &swap_moved).unwrap();
17196            symlink(&outside_path, &swap_state).unwrap();
17197            (200, card.clone())
17198        });
17199        let cfg = test_hub_config(hub, state);
17200
17201        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
17202        assert_eq!(verified.head.seq, 0);
17203        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
17204        assert!(std::fs::read_dir(moved.join("trust"))
17205            .unwrap()
17206            .flatten()
17207            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
17208        server.join().unwrap();
17209    }
17210
17211    #[test]
17212    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
17213        let remote = signed_remote_fixture();
17214        let unrelated = signed_remote_fixture().key;
17215        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
17216        let state = tempfile::tempdir().unwrap();
17217        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
17218        cfg.brain_key = Some(unrelated);
17219        let error = sync_push(
17220            &cfg,
17221            TEST_BRAIN_ID,
17222            &[("DB.md".to_string(), "signed local content".to_string())],
17223        )
17224        .unwrap_err()
17225        .to_string();
17226        assert!(
17227            error.contains("not the verified current brain identity"),
17228            "{error}"
17229        );
17230        server.join().unwrap();
17231    }
17232
17233    #[test]
17234    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
17235        let remote = signed_remote_fixture();
17236        let new = signed_remote_fixture().key;
17237        let state = tempfile::tempdir().unwrap();
17238        let new_file = state.path().join("new.key");
17239        std::fs::write(
17240            &new_file,
17241            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
17242        )
17243        .unwrap();
17244        #[cfg(unix)]
17245        {
17246            use std::os::unix::fs::PermissionsExt as _;
17247            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
17248        }
17249        let forged = json!({
17250            "brain": TEST_BRAIN_ID,
17251            "identity": {
17252                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
17253                "publicKeySpki": new.public_key_spki,
17254            }
17255        })
17256        .to_string();
17257        let (hub, server) = scripted_json_hub(vec![
17258            (404, "{}".to_string()),
17259            (200, remote.card.clone()),
17260            (200, remote.feed.clone()),
17261            (200, forged),
17262            (200, remote.card),
17263            (200, remote.feed),
17264        ]);
17265        let cfg = test_hub_config(hub, state.path().to_path_buf());
17266        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
17267            .unwrap_err()
17268            .to_string();
17269        assert!(
17270            error.contains("without committing the verified new identity"),
17271            "{error}"
17272        );
17273        server.join().unwrap();
17274    }
17275
17276    #[test]
17277    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
17278        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17279        let raw = format!(
17280            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
17281        );
17282        let pack = build_store_pack(&[
17283            (
17284                "DB.md".to_string(),
17285                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
17286            ),
17287            ("records/clients/truth.md".to_string(), raw.clone()),
17288        ])
17289        .unwrap();
17290        let by_id = resolve_from_verified_pack(
17291            "01j5qc3v9k4ym8rwbn2tqe6f7d",
17292            &AddressTarget::Id(record_id.to_string()),
17293            pack.clone(),
17294        )
17295        .unwrap();
17296        assert_eq!(by_id["document"]["summary"], "Signed truth");
17297        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
17298        assert_eq!(
17299            by_id["document"]["contentSha"],
17300            content_sha256(raw.as_bytes())
17301        );
17302
17303        let by_path = resolve_from_verified_pack(
17304            "01j5qc3v9k4ym8rwbn2tqe6f7d",
17305            &AddressTarget::Path("records/clients/truth.md".to_string()),
17306            pack,
17307        )
17308        .unwrap();
17309        assert_eq!(by_path["document"]["id"], record_id);
17310        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
17311
17312        let wrong_id = resolve_from_verified_record_bytes(
17313            TEST_BRAIN_ID,
17314            &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
17315            "records/clients/truth.md".to_string(),
17316            raw.as_bytes().to_vec(),
17317        )
17318        .unwrap_err()
17319        .to_string();
17320        assert!(wrong_id.contains("id differs"), "{wrong_id}");
17321
17322        let wrong_path = resolve_from_verified_record_bytes(
17323            TEST_BRAIN_ID,
17324            &AddressTarget::Path("records/clients/other.md".to_string()),
17325            "records/clients/truth.md".to_string(),
17326            raw.into_bytes(),
17327        )
17328        .unwrap_err()
17329        .to_string();
17330        assert!(wrong_path.contains("path differs"), "{wrong_path}");
17331    }
17332
17333    #[test]
17334    fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
17335        let path = "records/clients/truth.md";
17336        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17337        let raw = format!(
17338            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
17339        );
17340        let sha256 = content_sha256(raw.as_bytes());
17341        let mut nonce = 0_u128;
17342        let tree = crate::linkmd_v2::build_content_tree(
17343            &[crate::linkmd_v2::ContentFile {
17344                path: path.to_string(),
17345                blob_hash: sha256.clone(),
17346                bytes: raw.len() as u64,
17347            }],
17348            None,
17349            &mut || {
17350                nonce += 1;
17351                format!("{nonce:032x}")
17352            },
17353        )
17354        .unwrap();
17355        let root = tree.root.clone().unwrap();
17356        let mut directory_root = root.clone();
17357        let mut proof = Vec::new();
17358        for component in path.split('/') {
17359            let inclusion =
17360                crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
17361            let child = match &inclusion {
17362                crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
17363                crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
17364                    panic!("fixture path must have an inclusion proof")
17365                }
17366            };
17367            proof.push(json!({
17368                "directory_root": directory_root,
17369                "component": component,
17370                "proof": inclusion,
17371            }));
17372            directory_root = child;
17373        }
17374        let commit_hash = "c".repeat(64);
17375        let pointer = V2PointerBody {
17376            v: 2,
17377            brain: TEST_BRAIN_ID.to_string(),
17378            seq: 1,
17379            commit_hash: commit_hash.clone(),
17380            feed_hash: "f".repeat(64),
17381            content_root: Some(root.clone()),
17382            asset_root: None,
17383            materializer: "dbmd-projection-v1".to_string(),
17384            signer_epoch: 1,
17385            control_revision: "d".repeat(64),
17386            backup_preparation: "e".repeat(64),
17387            prior_pointer_hash: None,
17388            signed_at: "2026-08-21T12:00:00.000Z".to_string(),
17389        };
17390        let manifest = json!({
17391            "v": 2,
17392            "commit": commit_hash,
17393            "content_root": root,
17394            "files": [{
17395                "path": path,
17396                "sha256": sha256,
17397                "bytes": raw.len(),
17398                "proof": proof,
17399            }],
17400            "next_cursor": Value::Null,
17401        })
17402        .to_string();
17403
17404        let path_manifest = manifest.clone();
17405        let (hub, server) = routed_json_hub(1, move |request| {
17406            assert_eq!(
17407                request,
17408                format!(
17409                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
17410                    "c".repeat(64)
17411                )
17412            );
17413            (200, path_manifest.clone())
17414        });
17415        let state = tempfile::tempdir().unwrap();
17416        let cfg = test_hub_config(hub, state.path().to_path_buf());
17417        let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
17418            .unwrap()
17419            .unwrap();
17420        assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
17421        assert!(by_path.proof.is_some());
17422        server.join().unwrap();
17423
17424        let (hub, server) = routed_json_hub(1, move |request| {
17425            assert_eq!(
17426                request,
17427                format!(
17428                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
17429                    "c".repeat(64)
17430                )
17431            );
17432            (404, r#"{"error":"File not found"}"#.to_string())
17433        });
17434        let state = tempfile::tempdir().unwrap();
17435        let cfg = test_hub_config(hub, state.path().to_path_buf());
17436        assert!(
17437            v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
17438                .unwrap()
17439                .is_none()
17440        );
17441        server.join().unwrap();
17442
17443        let id_manifest = manifest;
17444        let (hub, server) = routed_json_hub(1, move |request| {
17445            assert_eq!(
17446                request,
17447                format!(
17448                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
17449                    "c".repeat(64)
17450                )
17451            );
17452            (200, id_manifest.clone())
17453        });
17454        let state = tempfile::tempdir().unwrap();
17455        let cfg = test_hub_config(hub, state.path().to_path_buf());
17456        let (located_path, by_id) =
17457            v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
17458        assert_eq!(located_path, path);
17459        assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
17460        server.join().unwrap();
17461    }
17462
17463    #[test]
17464    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
17465        let unsorted = vec![
17466            ("records/a.md".to_string(), "alpha\n".to_string()),
17467            ("DB.md".to_string(), "# db\n".to_string()),
17468        ];
17469        let sorted = vec![
17470            ("DB.md".to_string(), "# db\n".to_string()),
17471            ("records/a.md".to_string(), "alpha\n".to_string()),
17472        ];
17473        let pack = build_store_pack(&unsorted).unwrap();
17474
17475        // This digest is shared with the hub implementation. It locks every
17476        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
17477        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
17478        // absence of descriptors/extras/comments/ZIP64.
17479        assert_eq!(pack.len(), 219);
17480        assert_eq!(
17481            content_sha256(&pack),
17482            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
17483        );
17484        assert_eq!(pack, build_store_pack(&sorted).unwrap());
17485        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
17486        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
17487        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
17488
17489        assert_eq!(
17490            parse_store_pack(pack).unwrap(),
17491            vec![
17492                ("DB.md".to_string(), b"# db\n".to_vec()),
17493                ("records/a.md".to_string(), b"alpha\n".to_vec()),
17494            ]
17495        );
17496    }
17497
17498    #[test]
17499    fn canonical_store_pack_validates_every_path_before_writing() {
17500        let duplicate = vec![
17501            ("DB.md".to_string(), "first".to_string()),
17502            ("DB.md".to_string(), "second".to_string()),
17503        ];
17504        assert!(build_store_pack(&duplicate)
17505            .unwrap_err()
17506            .to_string()
17507            .contains("duplicate path"));
17508        assert!(matches!(
17509            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
17510            Err(LinkError::UnsafePath { .. })
17511        ));
17512    }
17513
17514    #[test]
17515    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
17516        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
17517        // One byte stands in for the central directory. The trailer itself is
17518        // structurally valid; the count is the sole reason for refusal.
17519        let mut bytes = vec![0_u8];
17520        let zip64_offset = bytes.len() as u64;
17521        bytes.extend_from_slice(b"PK\x06\x06");
17522        bytes.extend_from_slice(&44_u64.to_le_bytes());
17523        bytes.extend_from_slice(&[0_u8; 12]);
17524        bytes.extend_from_slice(&COUNT.to_le_bytes());
17525        bytes.extend_from_slice(&COUNT.to_le_bytes());
17526        bytes.extend_from_slice(&1_u64.to_le_bytes());
17527        bytes.extend_from_slice(&0_u64.to_le_bytes());
17528        bytes.extend_from_slice(b"PK\x06\x07");
17529        bytes.extend_from_slice(&0_u32.to_le_bytes());
17530        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
17531        bytes.extend_from_slice(&1_u32.to_le_bytes());
17532        bytes.extend_from_slice(b"PK\x05\x06");
17533        bytes.extend_from_slice(&0_u16.to_le_bytes());
17534        bytes.extend_from_slice(&0_u16.to_le_bytes());
17535        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17536        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17537        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17538        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17539        bytes.extend_from_slice(&0_u16.to_le_bytes());
17540
17541        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
17542            .unwrap_err()
17543            .to_string();
17544        assert!(error.contains("invalid file count"), "{error}");
17545    }
17546
17547    #[test]
17548    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
17549        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
17550        let mut bytes = vec![0_u8];
17551        let zip64_offset = bytes.len() as u64;
17552        bytes.extend_from_slice(b"PK\x06\x06");
17553        bytes.extend_from_slice(&44_u64.to_le_bytes());
17554        bytes.extend_from_slice(&[0_u8; 12]);
17555        bytes.extend_from_slice(&COUNT.to_le_bytes());
17556        bytes.extend_from_slice(&COUNT.to_le_bytes());
17557        bytes.extend_from_slice(&1_u64.to_le_bytes());
17558        bytes.extend_from_slice(&0_u64.to_le_bytes());
17559        bytes.extend_from_slice(b"PK\x06\x07");
17560        bytes.extend_from_slice(&0_u32.to_le_bytes());
17561        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
17562        bytes.extend_from_slice(&1_u32.to_le_bytes());
17563        bytes.extend_from_slice(b"PK\x05\x06");
17564        bytes.extend_from_slice(&0_u16.to_le_bytes());
17565        bytes.extend_from_slice(&0_u16.to_le_bytes());
17566        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17567        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17568        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17569        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17570        bytes.extend_from_slice(&0_u16.to_le_bytes());
17571        // The old parser trusted this last low-count signature, while
17572        // ZipArchive fell back to the real Zip64 directory and allocated for
17573        // COUNT entries. Its central-directory offsets are deliberately fake.
17574        let fake_eocd = bytes.len() as u32;
17575        bytes.extend_from_slice(b"PK\x05\x06");
17576        bytes.extend_from_slice(&0_u16.to_le_bytes());
17577        bytes.extend_from_slice(&0_u16.to_le_bytes());
17578        bytes.extend_from_slice(&1_u16.to_le_bytes());
17579        bytes.extend_from_slice(&1_u16.to_le_bytes());
17580        bytes.extend_from_slice(&0_u32.to_le_bytes());
17581        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
17582        bytes.extend_from_slice(&0_u16.to_le_bytes());
17583
17584        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
17585            .unwrap_err()
17586            .to_string();
17587        assert!(error.contains("central directory"), "{error}");
17588    }
17589
17590    #[test]
17591    fn strict_http_status_handling_rejects_redirects_without_panicking() {
17592        let error = ensure_ok(
17593            HubResponse {
17594                status: 302,
17595                body: Some(json!({"redirect": "/elsewhere"})),
17596            },
17597            "mutation",
17598        )
17599        .unwrap_err();
17600        assert!(matches!(error, LinkError::Http { status: 302, .. }));
17601
17602        let error = ensure_raw_ok(
17603            RawHubResponse {
17604                status: 302,
17605                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
17606            },
17607            "feed",
17608        )
17609        .unwrap_err();
17610        assert!(matches!(error, LinkError::Http { status: 302, .. }));
17611    }
17612
17613    #[cfg(unix)]
17614    #[test]
17615    fn collect_push_files_refuses_external_symlink_and_nested_store() {
17616        use std::os::unix::fs::symlink;
17617
17618        let root = tempfile::tempdir().unwrap();
17619        std::fs::write(
17620            root.path().join("DB.md"),
17621            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
17622        )
17623        .unwrap();
17624        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
17625
17626        let external = tempfile::tempdir().unwrap();
17627        let secret = external.path().join("secret.md");
17628        std::fs::write(&secret, "TOP SECRET").unwrap();
17629        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
17630
17631        let store = Store::open_strict(root.path()).unwrap();
17632        let err = collect_push_files(&store).unwrap_err().to_string();
17633        assert!(err.contains("cannot push"), "{err}");
17634        assert!(
17635            !err.contains("TOP SECRET"),
17636            "external bytes must never leak"
17637        );
17638
17639        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
17640        let nested = root.path().join("records/nested");
17641        std::fs::create_dir_all(&nested).unwrap();
17642        std::fs::write(
17643            nested.join("DB.md"),
17644            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
17645        )
17646        .unwrap();
17647        let err = collect_push_files(&store).unwrap_err().to_string();
17648        assert!(err.contains("nested db.md store"), "{err}");
17649    }
17650
17651    #[test]
17652    fn collect_push_files_carries_curator_history_but_not_derived_catalogs() {
17653        let root = tempfile::tempdir().unwrap();
17654        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
17655        std::fs::create_dir_all(root.path().join("log")).unwrap();
17656        std::fs::write(
17657            root.path().join("DB.md"),
17658            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
17659        )
17660        .unwrap();
17661        std::fs::write(root.path().join("index.md"), "derived root catalog\n").unwrap();
17662        std::fs::write(
17663            root.path().join("records/notes/index.md"),
17664            "derived type catalog\n",
17665        )
17666        .unwrap();
17667        std::fs::write(
17668            root.path().join("records/notes/owned.md"),
17669            "---\ntype: note\nsummary: owned\ncreated: 2026-08-26T00:00:00Z\nupdated: 2026-08-26T00:00:00Z\n---\n",
17670        )
17671        .unwrap();
17672        std::fs::write(
17673            root.path().join("log.md"),
17674            "---\ntype: log\n---\n\n# Curator log\n",
17675        )
17676        .unwrap();
17677        std::fs::write(
17678            root.path().join("log/2026-07.md"),
17679            "---\ntype: log\n---\n\n# Curator log — 2026-07\n",
17680        )
17681        .unwrap();
17682        std::fs::write(root.path().join("log/README.txt"), "not a log archive\n").unwrap();
17683
17684        let store = Store::open_strict(root.path()).unwrap();
17685        let paths: Vec<String> = collect_push_files(&store)
17686            .unwrap()
17687            .into_iter()
17688            .map(|(path, _)| path)
17689            .collect();
17690
17691        assert!(paths.contains(&"DB.md".to_string()));
17692        assert!(paths.contains(&"records/notes/owned.md".to_string()));
17693        assert!(paths.contains(&"log.md".to_string()));
17694        assert!(paths.contains(&"log/2026-07.md".to_string()));
17695        assert!(!paths.contains(&"index.md".to_string()));
17696        assert!(!paths.contains(&"records/notes/index.md".to_string()));
17697        assert!(!paths.contains(&"log/README.txt".to_string()));
17698    }
17699
17700    #[cfg(unix)]
17701    #[test]
17702    fn remote_push_uses_opened_root_after_path_replacement() {
17703        use std::os::unix::fs::symlink;
17704
17705        let sandbox = tempfile::tempdir().unwrap();
17706        let root = sandbox.path().join("store");
17707        std::fs::create_dir_all(root.join("records/notes")).unwrap();
17708        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17709        std::fs::write(
17710            root.join("records/notes/owned.md"),
17711            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
17712        )
17713        .unwrap();
17714        let store = Store::open_strict(&root).unwrap();
17715        let detached = sandbox.path().join("detached");
17716        std::fs::rename(&root, &detached).unwrap();
17717
17718        let replacement = sandbox.path().join("replacement");
17719        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
17720        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17721        std::fs::write(
17722            replacement.join("records/notes/secret.md"),
17723            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
17724        )
17725        .unwrap();
17726        symlink(&replacement, &root).unwrap();
17727
17728        let files = collect_push_files(&store).unwrap();
17729        let wire_text = files
17730            .iter()
17731            .map(|(path, content)| format!("{path}\n{content}"))
17732            .collect::<Vec<_>>()
17733            .join("\n");
17734        assert!(wire_text.contains("owned upload"));
17735        assert!(!wire_text.contains("replacement sentinel"));
17736        assert!(!wire_text.contains("records/notes/secret.md"));
17737
17738        let remote = signed_remote_fixture();
17739        let (hub, server) = scripted_json_hub(vec![
17740            (200, remote.card),
17741            (200, remote.feed),
17742            (200, json!({"ok": true}).to_string()),
17743        ]);
17744        let state = tempfile::tempdir().unwrap();
17745        let cfg = test_hub_config(hub, state.path().to_path_buf());
17746        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
17747        assert_eq!(pushed, json!({"ok": true}));
17748        server.join().unwrap();
17749    }
17750
17751    #[test]
17752    fn signed_feed_item_verifies_identity_hash_and_signature() {
17753        use ring::rand::SystemRandom;
17754        use ring::signature::{Ed25519KeyPair, KeyPair};
17755
17756        const PREFIX: &[u8] = &[
17757            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
17758        ];
17759        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
17760        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17761        let mut spki = PREFIX.to_vec();
17762        spki.extend_from_slice(pair.public_key().as_ref());
17763        let public_key = URL_SAFE_NO_PAD.encode(&spki);
17764        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
17765        let mut entry = FeedEntry {
17766            v: 1,
17767            seq: 1,
17768            ts: "2026-07-14T00:00:00.000Z".to_string(),
17769            brain: format!("ed25519:{fingerprint}"),
17770            public_key: public_key.clone(),
17771            kind: "push".to_string(),
17772            op: "snapshot".to_string(),
17773            pack_sha256: "a".repeat(64),
17774            files: vec![FeedFile {
17775                path: "DB.md".to_string(),
17776                sha256: "b".repeat(64),
17777                bytes: 3,
17778            }],
17779            removed: vec![],
17780            prev_entry_hash: None,
17781            sig: String::new(),
17782        };
17783        let unsigned = UnsignedFeedEntry {
17784            v: entry.v,
17785            seq: entry.seq,
17786            ts: &entry.ts,
17787            brain: &entry.brain,
17788            public_key: &entry.public_key,
17789            kind: &entry.kind,
17790            op: &entry.op,
17791            pack_sha256: &entry.pack_sha256,
17792            files: &entry.files,
17793            removed: &entry.removed,
17794            prev_entry_hash: &entry.prev_entry_hash,
17795        };
17796        entry.sig =
17797            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
17798        let mut exact = serde_json::to_vec(&entry).unwrap();
17799        exact.push(b'\n');
17800        let item = FeedItem {
17801            hash: format!("{:x}", Sha256::digest(&exact)),
17802            entry,
17803        };
17804        let identity = FeedIdentity {
17805            fingerprint,
17806            public_key_spki: public_key,
17807            previous: Vec::new(),
17808            rotations: Vec::new(),
17809        };
17810        assert!(verify_feed_item(&item, &identity).is_ok());
17811        let mut tampered = item;
17812        tampered.entry.pack_sha256 = "c".repeat(64);
17813        assert!(verify_feed_item(&tampered, &identity).is_err());
17814    }
17815
17816    #[test]
17817    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
17818        let rng = ring::rand::SystemRandom::new();
17819        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17820        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17821        let (spki, multikey) = public_identity_for(&pair);
17822        let identity = V2HeadIdentity {
17823            custody: "self".to_string(),
17824            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17825            public_key_spki: spki.clone(),
17826            previous: Vec::new(),
17827            rotations: Vec::new(),
17828        };
17829        let unsigned = json!({
17830            "actor_ref": "a".repeat(64),
17831            "asset_root": Value::Null,
17832            "brain": multikey,
17833            "changes_sha256": "b".repeat(64),
17834            "control_revision": "c".repeat(64),
17835            "materializer": "dbmd-projection-v1",
17836            "op": "changeset",
17837            "parent_asset_root": Value::Null,
17838            "parent_commit": Value::Null,
17839            "parent_root": Value::Null,
17840            "prev_entry_hash": Value::Null,
17841            "public_key": spki,
17842            "seq": 1,
17843            "signer_epoch": 1,
17844            "state_root": "d".repeat(64),
17845            "ts": "2026-08-19T12:00:00.000Z",
17846            "v": 2,
17847            "v1_bridge": {
17848                "feed_hash": "e".repeat(64),
17849                "head_seq": 7,
17850                "pack_sha256": "f".repeat(64),
17851            },
17852        });
17853        let sign_value = |value: Value| {
17854            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17855            let mut object = value.as_object().unwrap().clone();
17856            object.insert(
17857                "sig".to_string(),
17858                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17859            );
17860            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17861        };
17862        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
17863
17864        let mut extra = unsigned.clone();
17865        extra
17866            .as_object_mut()
17867            .unwrap()
17868            .insert("future".to_string(), Value::Bool(true));
17869        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
17870
17871        let mut missing = unsigned.clone();
17872        missing.as_object_mut().unwrap().remove("v1_bridge");
17873        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
17874
17875        let mut invalid_bridge = unsigned;
17876        invalid_bridge.as_object_mut().unwrap().insert(
17877            "v1_bridge".to_string(),
17878            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
17879        );
17880        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
17881    }
17882
17883    #[test]
17884    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
17885        let vector: Value = serde_json::from_str(include_str!(
17886            "../tests/vectors/linkmd-v2-commit-bridge.json"
17887        ))
17888        .unwrap();
17889        let identity_value = vector.get("identity").unwrap();
17890        let identity = V2HeadIdentity {
17891            custody: "self".to_string(),
17892            fingerprint: identity_value
17893                .get("fingerprint")
17894                .and_then(Value::as_str)
17895                .unwrap()
17896                .to_string(),
17897            public_key_spki: identity_value
17898                .get("public_key_spki")
17899                .and_then(Value::as_str)
17900                .unwrap()
17901                .to_string(),
17902            previous: Vec::new(),
17903            rotations: Vec::new(),
17904        };
17905        let private = URL_SAFE_NO_PAD
17906            .decode(
17907                identity_value
17908                    .get("private_key_pkcs8")
17909                    .and_then(Value::as_str)
17910                    .unwrap(),
17911            )
17912            .unwrap();
17913        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
17914            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
17915            .unwrap();
17916        let base = vector.get("body").unwrap().as_object().unwrap();
17917
17918        for item in vector.get("valid").unwrap().as_array().unwrap() {
17919            let mut body = base.clone();
17920            body.insert(
17921                "v1_bridge".to_string(),
17922                item.get("v1_bridge").unwrap().clone(),
17923            );
17924            body.insert(
17925                "sig".to_string(),
17926                item.get("signature_base64url").unwrap().clone(),
17927            );
17928            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17929            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
17930            assert_eq!(
17931                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
17932                item.get("commit_hash").and_then(Value::as_str).unwrap()
17933            );
17934            assert_eq!(
17935                format!("{:x}", Sha256::digest(&signed)),
17936                item.get("feed_hash").and_then(Value::as_str).unwrap()
17937            );
17938        }
17939
17940        for item in vector.get("invalid").unwrap().as_array().unwrap() {
17941            let mut body = base.clone();
17942            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
17943                for field in remove {
17944                    body.remove(field.as_str().unwrap());
17945                }
17946            }
17947            if let Some(set) = item.get("set").and_then(Value::as_object) {
17948                for (field, value) in set {
17949                    body.insert(field.clone(), value.clone());
17950                }
17951            }
17952            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
17953            body.insert(
17954                "sig".to_string(),
17955                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17956            );
17957            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17958            assert!(
17959                verified_v2_commit_object(&signed, &identity).is_err(),
17960                "accepted invalid shared vector {}",
17961                item.get("reason").and_then(Value::as_str).unwrap()
17962            );
17963        }
17964    }
17965
17966    #[test]
17967    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
17968        let vector: Value = serde_json::from_str(include_str!(
17969            "../tests/vectors/linkmd-v2-changeset-withheld.json"
17970        ))
17971        .unwrap();
17972        assert_eq!(
17973            vector.get("profile").and_then(Value::as_str),
17974            Some("link.md-v2-changeset-withheld")
17975        );
17976        let canonical =
17977            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
17978        let expected = STANDARD
17979            .decode(
17980                vector
17981                    .get("canonical_base64")
17982                    .and_then(Value::as_str)
17983                    .unwrap(),
17984            )
17985            .unwrap();
17986        assert_eq!(canonical, expected);
17987        assert_eq!(
17988            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
17989            vector.get("domain_hash").and_then(Value::as_str).unwrap()
17990        );
17991    }
17992
17993    #[test]
17994    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
17995        let remote = signed_remote_fixture();
17996        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
17997        let legacy_item = legacy.entries.first().unwrap();
17998        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
17999        let body = json!({
18000            "actor_ref": "a".repeat(64),
18001            "asset_root": Value::Null,
18002            "brain": remote.key.multikey,
18003            "changes_sha256": "b".repeat(64),
18004            "control_revision": "c".repeat(64),
18005            "materializer": "dbmd-projection-v1",
18006            "op": "changeset",
18007            "parent_asset_root": Value::Null,
18008            "parent_commit": Value::Null,
18009            "parent_root": Value::Null,
18010            "prev_entry_hash": Value::Null,
18011            "public_key": remote.key.public_key_spki,
18012            "seq": 1,
18013            "signer_epoch": 1,
18014            "state_root": "d".repeat(64),
18015            "ts": "2026-08-19T12:00:00.000Z",
18016            "v": 2,
18017            "v1_bridge": {
18018                "feed_hash": legacy_item.hash,
18019                "head_seq": legacy_item.entry.seq,
18020                "pack_sha256": legacy_item.entry.pack_sha256,
18021            },
18022        });
18023        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
18024        let mut signed = body.as_object().unwrap().clone();
18025        signed.insert(
18026            "sig".to_string(),
18027            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
18028        );
18029        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
18030        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
18031        let feed_hash = content_sha256(&raw);
18032        let pointer = V2PointerBody {
18033            v: 2,
18034            brain: TEST_BRAIN_ID.to_string(),
18035            seq: 1,
18036            commit_hash: commit_hash.clone(),
18037            feed_hash: feed_hash.clone(),
18038            content_root: Some("d".repeat(64)),
18039            asset_root: None,
18040            materializer: "dbmd-projection-v1".to_string(),
18041            signer_epoch: 1,
18042            control_revision: "c".repeat(64),
18043            backup_preparation: "e".repeat(64),
18044            prior_pointer_hash: None,
18045            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
18046        };
18047        let v2_page = json!({
18048            "v": 2,
18049            "head_seq": 1,
18050            "head_commit_hash": commit_hash,
18051            "head_feed_hash": feed_hash,
18052            "entries": [{
18053                "seq": 1,
18054                "commit_hash": pointer.commit_hash,
18055                "feed_hash": pointer.feed_hash,
18056                "bytes_base64": STANDARD.encode(&raw),
18057            }],
18058            "next_after": 1,
18059            "complete": true,
18060        })
18061        .to_string();
18062        let identity = V2HeadIdentity {
18063            custody: "self".to_string(),
18064            fingerprint: remote.identity.fingerprint.clone(),
18065            public_key_spki: remote.identity.public_key_spki.clone(),
18066            previous: Vec::new(),
18067            rotations: Vec::new(),
18068        };
18069        let checkpoint = TrustState {
18070            v: 2,
18071            origin: "unused".to_string(),
18072            requested: TEST_BRAIN_ID.to_string(),
18073            brain: TEST_BRAIN_ID.to_string(),
18074            home: None,
18075            anchor: remote.key.multikey.clone(),
18076            current: remote.key.multikey,
18077            head_seq: legacy_item.entry.seq,
18078            feed_hash: Some(legacy_item.hash.clone()),
18079            rotations: Vec::new(),
18080            hub_signer: None,
18081            protocol_profile: None,
18082        };
18083        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
18084        let state = tempfile::tempdir().unwrap();
18085        let cfg = test_hub_config(hub, state.path().to_path_buf());
18086        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
18087        server.join().unwrap();
18088
18089        let mut wrong = checkpoint;
18090        wrong.feed_hash = Some("0".repeat(64));
18091        let (hub, server) = scripted_json_hub(vec![(
18092            200,
18093            json!({
18094                "v": 2,
18095                "head_seq": 1,
18096                "head_commit_hash": pointer.commit_hash,
18097                "head_feed_hash": pointer.feed_hash,
18098                "entries": [{
18099                    "seq": 1,
18100                    "commit_hash": pointer.commit_hash,
18101                    "feed_hash": pointer.feed_hash,
18102                    "bytes_base64": STANDARD.encode(&raw),
18103                }],
18104                "next_after": 1,
18105                "complete": true,
18106            })
18107            .to_string(),
18108        )]);
18109        let state = tempfile::tempdir().unwrap();
18110        let cfg = test_hub_config(hub, state.path().to_path_buf());
18111        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
18112        server.join().unwrap();
18113    }
18114
18115    #[test]
18116    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
18117        let rng = ring::rand::SystemRandom::new();
18118        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18119        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
18120        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18121        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
18122        let (old_spki, old_multikey) = public_identity_for(&old);
18123        let (new_spki, new_multikey) = public_identity_for(&new);
18124        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
18125            v: 1,
18126            op: "rotate",
18127            brain: &old_multikey,
18128            public_key: &old_spki,
18129            new_brain: &new_multikey,
18130            new_public_key: &new_spki,
18131            prior_head_seq: 1,
18132            prior_feed_hash: Some(&"9".repeat(64)),
18133            ts: "2026-08-19T12:01:00.000Z".to_string(),
18134        })
18135        .unwrap();
18136        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
18137        let rotation = format!(
18138            "{},\"sig\":\"{}\"}}",
18139            &rotation_unsigned[..rotation_unsigned.len() - 1],
18140            rotation_sig
18141        );
18142        let identity = V2HeadIdentity {
18143            custody: "self".to_string(),
18144            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
18145            public_key_spki: new_spki.clone(),
18146            previous: vec![V2PreviousIdentity {
18147                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
18148                public_key_spki: old_spki.clone(),
18149            }],
18150            rotations: vec![rotation],
18151        };
18152        let commit = |seq: u64,
18153                      epoch: u64,
18154                      multikey: &str,
18155                      spki: &str,
18156                      pair: &ring::signature::Ed25519KeyPair| {
18157            let value = json!({
18158                "actor_ref": "a".repeat(64),
18159                "asset_root": Value::Null,
18160                "brain": multikey,
18161                "changes_sha256": "b".repeat(64),
18162                "control_revision": "c".repeat(64),
18163                "materializer": "dbmd-projection-v1",
18164                "op": "changeset",
18165                "parent_asset_root": Value::Null,
18166                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
18167                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
18168                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
18169                "public_key": spki,
18170                "seq": seq,
18171                "signer_epoch": epoch,
18172                "state_root": "1".repeat(64),
18173                "ts": "2026-08-19T12:00:00.000Z",
18174                "v": 2,
18175                "v1_bridge": Value::Null,
18176            });
18177            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
18178            let mut object = value.as_object().unwrap().clone();
18179            object.insert(
18180                "sig".to_string(),
18181                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
18182            );
18183            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
18184        };
18185
18186        assert!(verified_v2_commit_object(
18187            &commit(1, 1, &old_multikey, &old_spki, &old),
18188            &identity,
18189        )
18190        .is_ok());
18191        assert!(verified_v2_commit_object(
18192            &commit(2, 2, &new_multikey, &new_spki, &new),
18193            &identity,
18194        )
18195        .is_ok());
18196        assert!(verified_v2_commit_object(
18197            &commit(2, 1, &old_multikey, &old_spki, &old),
18198            &identity,
18199        )
18200        .is_err());
18201        assert!(verified_v2_commit_object(
18202            &commit(1, 2, &new_multikey, &new_spki, &new),
18203            &identity,
18204        )
18205        .is_err());
18206    }
18207
18208    #[test]
18209    fn a_self_custody_entry_verifies_like_any_hub_entry() {
18210        let rng = ring::rand::SystemRandom::new();
18211        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18212        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18213        let (spki, multikey) = public_identity_for(&pair);
18214        let key = AgentSigningKey {
18215            pkcs8: pkcs8.as_ref().to_vec(),
18216            multikey: multikey.clone(),
18217            public_key_spki: spki.clone(),
18218        };
18219        let files = vec![WireFeedFile {
18220            path: "DB.md".to_string(),
18221            sha256: "a".repeat(64),
18222            bytes: 3,
18223        }];
18224        let raw = self_custody_entry(
18225            &key,
18226            1,
18227            "2026-07-23T12:00:00.000Z".to_string(),
18228            &"c".repeat(64),
18229            &files,
18230            None,
18231        )
18232        .unwrap();
18233        // The exact client serialization parses as a feed entry and passes the
18234        // SAME verifier every subscribe read runs — the self-custody path
18235        // produces first-class wire-profile-v1 entries.
18236        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
18237        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
18238        let item = FeedItem { hash, entry };
18239        let identity = FeedIdentity {
18240            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
18241            public_key_spki: spki,
18242            previous: Vec::new(),
18243            rotations: Vec::new(),
18244        };
18245        assert!(verify_feed_item(&item, &identity).is_ok());
18246    }
18247
18248    #[test]
18249    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
18250        let rng = ring::rand::SystemRandom::new();
18251        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18252        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
18253        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18254        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
18255        let (old_spki, old_multikey) = public_identity_for(&old);
18256        let (new_spki, new_multikey) = public_identity_for(&new);
18257        let unsigned = serde_json::to_string(&UnsignedRotation {
18258            v: 1,
18259            op: "rotate",
18260            brain: &old_multikey,
18261            public_key: &old_spki,
18262            new_brain: &new_multikey,
18263            new_public_key: &new_spki,
18264            prior_head_seq: 1,
18265            prior_feed_hash: Some(&"a".repeat(64)),
18266            ts: "2026-07-30T12:00:00.000Z".to_string(),
18267        })
18268        .unwrap();
18269        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
18270        let rotation = format!(
18271            "{},\"sig\":\"{}\"}}",
18272            &unsigned[..unsigned.len() - 1],
18273            signature
18274        );
18275        let identity = FeedIdentity {
18276            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
18277            public_key_spki: new_spki,
18278            previous: vec![PreviousIdentity {
18279                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
18280                public_key_spki: old_spki,
18281            }],
18282            rotations: vec![rotation],
18283        };
18284        let pin = TrustState {
18285            v: 2,
18286            origin: "https://hub.example".to_string(),
18287            requested: "brain".to_string(),
18288            brain: "brain".to_string(),
18289            home: None,
18290            anchor: old_multikey.clone(),
18291            current: old_multikey.clone(),
18292            head_seq: 1,
18293            feed_hash: Some("a".repeat(64)),
18294            rotations: Vec::new(),
18295            hub_signer: None,
18296            protocol_profile: None,
18297        };
18298        assert_eq!(
18299            verify_identity_chain(&identity, Some(&pin)).unwrap(),
18300            old_multikey
18301        );
18302        let mut accepted = pin.clone();
18303        accepted.current = new_multikey.clone();
18304        accepted.rotations = identity.rotations.clone();
18305        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
18306            v: 1,
18307            op: "rotate",
18308            brain: &old_multikey,
18309            public_key: &identity.previous[0].public_key_spki,
18310            new_brain: &new_multikey,
18311            new_public_key: &identity.public_key_spki,
18312            prior_head_seq: 1,
18313            prior_feed_hash: Some(&"a".repeat(64)),
18314            ts: "2026-07-30T12:00:01.000Z".to_string(),
18315        })
18316        .unwrap();
18317        let alternate_signature =
18318            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
18319        let mut rewritten = identity.clone();
18320        rewritten.rotations[0] = format!(
18321            "{},\"sig\":\"{}\"}}",
18322            &alternate_unsigned[..alternate_unsigned.len() - 1],
18323            alternate_signature
18324        );
18325        assert!(
18326            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
18327            "an alternate valid statement must not rewrite accepted history"
18328        );
18329
18330        let mut stale_entry = FeedEntry {
18331            v: 1,
18332            seq: 2,
18333            ts: "2026-07-30T12:01:00.000Z".to_string(),
18334            brain: pin.current.clone(),
18335            public_key: identity.previous[0].public_key_spki.clone(),
18336            kind: "push".to_string(),
18337            op: "snapshot".to_string(),
18338            pack_sha256: "b".repeat(64),
18339            files: Vec::new(),
18340            removed: Vec::new(),
18341            prev_entry_hash: pin.feed_hash.clone(),
18342            sig: String::new(),
18343        };
18344        let stale_unsigned = UnsignedFeedEntry {
18345            v: stale_entry.v,
18346            seq: stale_entry.seq,
18347            ts: &stale_entry.ts,
18348            brain: &stale_entry.brain,
18349            public_key: &stale_entry.public_key,
18350            kind: &stale_entry.kind,
18351            op: &stale_entry.op,
18352            pack_sha256: &stale_entry.pack_sha256,
18353            files: &stale_entry.files,
18354            removed: &stale_entry.removed,
18355            prev_entry_hash: &stale_entry.prev_entry_hash,
18356        };
18357        stale_entry.sig = URL_SAFE_NO_PAD.encode(
18358            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
18359                .as_ref(),
18360        );
18361        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
18362        stale_exact.push(b'\n');
18363        let stale_item = FeedItem {
18364            hash: content_sha256(&stale_exact),
18365            entry: stale_entry,
18366        };
18367        assert!(
18368            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
18369                .is_err(),
18370            "a key retired before the checkpoint must never append after it"
18371        );
18372        assert!(
18373            verify_feed_item(&stale_item, &identity).is_err(),
18374            "an old key must never append after its signed rotation boundary"
18375        );
18376
18377        let mut missing = identity.clone();
18378        missing.rotations.clear();
18379        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
18380
18381        let mut tampered = identity;
18382        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
18383        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
18384    }
18385
18386    #[cfg(unix)]
18387    #[test]
18388    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
18389        use std::os::unix::fs::symlink;
18390
18391        let dir = tempfile::tempdir().unwrap();
18392        let target = dir.path().join("valuable.txt");
18393        let planted = dir.path().join("agent.key");
18394        std::fs::write(&target, "do not overwrite").unwrap();
18395        symlink(&target, &planted).unwrap();
18396
18397        assert!(matches!(
18398            generate_agent_key(&planted),
18399            Err(LinkError::BadAgentKey { .. })
18400        ));
18401        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
18402    }
18403
18404    #[cfg(unix)]
18405    #[test]
18406    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
18407        use std::os::unix::fs::symlink;
18408
18409        let root = tempfile::tempdir().unwrap();
18410        let outside = tempfile::tempdir().unwrap();
18411        symlink(outside.path(), root.path().join("redirect")).unwrap();
18412
18413        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
18414        assert!(!outside.path().join("agent.key").exists());
18415    }
18416
18417    // ── Address parsing ─────────────────────────────────────────────────────
18418
18419    #[test]
18420    fn address_bare_brain_with_and_without_sigil() {
18421        for raw in ["@acme-ops", "acme-ops"] {
18422            let a = Address::parse(raw).expect(raw);
18423            assert_eq!(a.brain, "acme-ops");
18424            assert_eq!(a.target, None);
18425        }
18426    }
18427
18428    #[test]
18429    fn address_ulid_target_parses_as_id() {
18430        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
18431        assert_eq!(a.brain, "acme");
18432        assert_eq!(
18433            a.target,
18434            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
18435        );
18436    }
18437
18438    #[test]
18439    fn address_md_path_target_parses_as_path() {
18440        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
18441        assert_eq!(
18442            a.target,
18443            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
18444        );
18445    }
18446
18447    #[test]
18448    fn address_rejects_malformed_forms() {
18449        for raw in [
18450            "",
18451            "@",
18452            "@/x",
18453            "@acme/",
18454            "@acme/../etc/passwd",
18455            "@acme/records/.hidden.md",
18456            "@ACME",             // uppercase is not a hub ref shape
18457            "@acme/notes/x.txt", // target is neither ULID nor .md path
18458            "@a b",              // whitespace
18459        ] {
18460            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
18461        }
18462    }
18463
18464    // ── Path safety ─────────────────────────────────────────────────────────
18465
18466    #[test]
18467    fn safe_paths_accept_store_shapes_and_reject_escapes() {
18468        for ok in [
18469            "DB.md",
18470            "assets.jsonl",
18471            "records/clients/lumio.md",
18472            "sources/emails/2026/07/x.md",
18473        ] {
18474            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
18475        }
18476        for bad in [
18477            "",
18478            "/etc/passwd",
18479            "../up.md",
18480            "records/../../up.md",
18481            "records//x.md",
18482            ".dbmd/config",
18483            "records/.hidden/x.md",
18484            "records/a b.md",
18485            "records\\win.md",
18486        ] {
18487            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
18488        }
18489    }
18490
18491    #[cfg(unix)]
18492    #[test]
18493    fn opened_destination_capability_survives_an_ancestor_path_swap() {
18494        use std::os::unix::fs::symlink;
18495
18496        let work = tempfile::tempdir().unwrap();
18497        let outside = tempfile::tempdir().unwrap();
18498        let original = work.path().join("destination");
18499        let moved = work.path().join("destination-moved");
18500        let directory = open_or_create_dir_nofollow(&original).unwrap();
18501
18502        std::fs::rename(&original, &moved).unwrap();
18503        symlink(outside.path(), &original).unwrap();
18504        write_pull_entries_beneath_dir(
18505            &directory,
18506            &[("records/note.md".to_string(), b"held inode".to_vec())],
18507        )
18508        .unwrap();
18509
18510        assert_eq!(
18511            std::fs::read(moved.join("records/note.md")).unwrap(),
18512            b"held inode"
18513        );
18514        assert!(!outside.path().join("records/note.md").exists());
18515    }
18516
18517    // ── Config resolution (flag + file precedence; env is covered by the CLI
18518    //    integration tests, where a child process isolates it) ───────────────
18519
18520    #[test]
18521    fn hub_config_flag_beats_file_and_requires_some_source() {
18522        let dir = tempfile::tempdir().unwrap();
18523        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
18524        std::fs::write(
18525            dir.path().join(CONFIG_REL_PATH),
18526            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
18527        )
18528        .unwrap();
18529
18530        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
18531        assert_eq!(from_flag.hub, "https://flag.example.com");
18532
18533        let from_file = hub_config(None, dir.path()).unwrap();
18534        assert_eq!(from_file.hub, "https://file.example.com");
18535
18536        let none = hub_config(None, tempfile::tempdir().unwrap().path());
18537        assert!(matches!(none, Err(LinkError::NoHub)));
18538    }
18539
18540    #[test]
18541    fn https_guard_allows_loopback_only_for_plain_http() {
18542        assert!(assert_safe_hub("https://hub.example.com").is_ok());
18543        assert!(assert_safe_hub("http://localhost:3000").is_ok());
18544        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
18545        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
18546        assert!(matches!(
18547            assert_safe_hub("http://hub.example.com"),
18548            Err(LinkError::UnsafeHub { .. })
18549        ));
18550        assert!(matches!(
18551            assert_safe_hub("hub.example.com"),
18552            Err(LinkError::UnsafeHub { .. })
18553        ));
18554        assert!(matches!(
18555            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
18556            Err(LinkError::UnsafeHub { .. })
18557        ));
18558        assert!(matches!(
18559            assert_safe_hub("https://hub.example.com@attacker.example"),
18560            Err(LinkError::UnsafeHub { .. })
18561        ));
18562        assert!(matches!(
18563            assert_safe_hub("https://hub.example.com/base"),
18564            Err(LinkError::UnsafeHub { .. })
18565        ));
18566    }
18567
18568    #[test]
18569    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
18570        for blocked in [
18571            "127.0.0.1",
18572            "10.0.0.1",
18573            "100.64.0.1",
18574            "169.254.169.254",
18575            "172.16.0.1",
18576            "192.168.0.1",
18577            "192.88.99.1",
18578            "198.18.0.1",
18579            "203.0.113.1",
18580            "::1",
18581            "fe80::1",
18582            "fd00::1",
18583            "2001:db8::1",
18584            "2001:1::1",
18585            "2002:7f00:1::",
18586            "3fff::1",
18587        ] {
18588            assert!(
18589                !is_public_registry_ip(blocked.parse().unwrap()),
18590                "must block {blocked}"
18591            );
18592        }
18593        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
18594        assert!(is_public_registry_ip(
18595            "2606:4700:4700::1111".parse().unwrap()
18596        ));
18597        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
18598    }
18599
18600    #[test]
18601    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
18602        use ureq::Resolver as _;
18603
18604        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
18605        let resolver = PinnedRegistryResolver {
18606            netloc: "home.example:443".to_string(),
18607            addresses: vec![pinned],
18608        };
18609        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
18610        assert!(resolver.resolve("127.0.0.1:443").is_err());
18611        assert_eq!(
18612            resolver.resolve("home.example:443").unwrap(),
18613            vec![pinned],
18614            "subsequent connects reuse the validated answer instead of DNS"
18615        );
18616    }
18617
18618    #[test]
18619    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
18620        let cfg = HubConfig {
18621            hub: "https://hub.example".to_string(),
18622            key: None,
18623            agent_key: None,
18624            brain_key: None,
18625            state_dir: tempfile::tempdir().unwrap().keep(),
18626            store_selected: false,
18627        };
18628        assert!(
18629            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
18630            "a production hub must not turn its presigned URL into an SSRF primitive"
18631        );
18632
18633        let store_selected = HubConfig {
18634            hub: "https://127.0.0.1".to_string(),
18635            store_selected: true,
18636            ..cfg
18637        };
18638        assert!(
18639            hub_agent(&store_selected).is_err(),
18640            "bytes in a cloned store must not select a private-network hub"
18641        );
18642    }
18643
18644    #[test]
18645    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
18646        assert_eq!(
18647            one_past_bounded_limit(MAX_PACK_BYTES),
18648            Some(MAX_PACK_BYTES + 1),
18649            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
18650        );
18651        assert_eq!(
18652            presigned_download_read_limit(),
18653            MAX_PACK_BYTES + 1,
18654            "the presigned reader is capped by the client constant, not a hub response"
18655        );
18656        assert_eq!(
18657            one_past_bounded_limit(u64::MAX),
18658            None,
18659            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
18660        );
18661    }
18662
18663    #[test]
18664    fn https_guard_matches_the_scheme_case_insensitively() {
18665        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
18666        // hub is still HTTPS, never a misleading non-HTTPS refusal.
18667        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
18668        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
18669        // And an uppercase plain-HTTP hub is still refused outside loopback.
18670        assert!(matches!(
18671            assert_safe_hub("HTTP://hub.example.com"),
18672            Err(LinkError::UnsafeHub { .. })
18673        ));
18674    }
18675
18676    #[test]
18677    fn clean_key_refuses_paste_artifacts_without_echoing() {
18678        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
18679        for bad in ["vc account", "vc\naccount", "ключ", ""] {
18680            let err = clean_key(bad).unwrap_err();
18681            assert!(matches!(err, LinkError::BadKey));
18682            assert!(
18683                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
18684                "error must not echo the key"
18685            );
18686        }
18687    }
18688
18689    // ── Verb entry gates: refs must never reshape the request path ──────────
18690
18691    /// A config whose hub passes the loopback guard but is never listened on:
18692    /// every refusal below must come from the entry gate BEFORE a request
18693    /// exists — a dial on this dead port would surface `Transport` instead.
18694    fn dead_hub() -> HubConfig {
18695        HubConfig {
18696            hub: "http://127.0.0.1:9".to_string(),
18697            key: Some("k".to_string()),
18698            agent_key: None,
18699            brain_key: None,
18700            state_dir: PathBuf::from("."),
18701            store_selected: false,
18702        }
18703    }
18704
18705    #[test]
18706    fn request_retries_a_connection_failure_before_sending() {
18707        use std::io::{Read as _, Write as _};
18708        use std::net::TcpListener;
18709        use std::thread;
18710        use std::time::Duration;
18711
18712        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
18713        let address = probe.local_addr().unwrap();
18714        drop(probe);
18715        let server = thread::spawn(move || {
18716            thread::sleep(Duration::from_millis(40));
18717            let listener = TcpListener::bind(address).unwrap();
18718            let (mut stream, _) = listener.accept().unwrap();
18719            let mut request_bytes = [0_u8; 1024];
18720            let _ = stream.read(&mut request_bytes).unwrap();
18721            stream
18722                .write_all(
18723                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18724                )
18725                .unwrap();
18726        });
18727        let cfg = HubConfig {
18728            hub: format!("http://{address}"),
18729            key: None,
18730            agent_key: None,
18731            brain_key: None,
18732            state_dir: tempfile::tempdir().unwrap().keep(),
18733            store_selected: false,
18734        };
18735
18736        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
18737        assert_eq!(response.status, 200);
18738        assert_eq!(response.body, Some(json!({ "ok": true })));
18739        server.join().unwrap();
18740    }
18741
18742    #[test]
18743    fn a_commit_goes_back_for_a_receipt_it_lost() {
18744        use std::io::Write as _;
18745        use std::net::TcpListener;
18746        use std::thread;
18747
18748        // The mutation coordinate is derived from the observed base and the
18749        // exact operation set so a repeat is the SAME effect. A commit that
18750        // lands durably and then loses its connection must therefore be asked
18751        // after, not reported as a failure — which is what happened to a
18752        // 32,338-file store that had already committed.
18753        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18754        let address = listener.local_addr().unwrap();
18755        let server = thread::spawn(move || {
18756            // First attempt: promise a body, hang up mid-way.
18757            let (mut first, _) = listener.accept().unwrap();
18758            drain_test_http_request(&mut first);
18759            first
18760                .write_all(
18761                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
18762                )
18763                .unwrap();
18764            drop(first);
18765            // Second attempt: the receipt for the commit that already landed.
18766            let (mut second, _) = listener.accept().unwrap();
18767            drain_test_http_request(&mut second);
18768            second
18769                .write_all(
18770                    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\"}",
18771                )
18772                .unwrap();
18773        });
18774        let cfg = HubConfig {
18775            hub: format!("http://{address}"),
18776            key: Some("k".to_string()),
18777            agent_key: None,
18778            brain_key: None,
18779            state_dir: tempfile::tempdir().unwrap().keep(),
18780            store_selected: false,
18781        };
18782
18783        let response = request_patient(
18784            &cfg,
18785            "POST",
18786            "/api/hub/brains/b/v2/commits",
18787            Some(&json!({ "mutation_id": "dbmd-1" })),
18788            Auth::Required,
18789        )
18790        .expect("the receipt is collected on the second ask");
18791        assert_eq!(response.status, 200);
18792        assert_eq!(
18793            response
18794                .body
18795                .as_ref()
18796                .and_then(|value| value.get("outcome"))
18797                .and_then(Value::as_str),
18798            Some("converged"),
18799            "an already-applied mutation answers with its receipt"
18800        );
18801        server.join().unwrap();
18802    }
18803
18804    #[test]
18805    fn a_patient_commit_waits_for_its_typed_post_acceptance_receipt_lag() {
18806        use std::io::Write as _;
18807        use std::net::TcpListener;
18808        use std::thread;
18809
18810        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18811        let address = listener.local_addr().unwrap();
18812        let server = thread::spawn(move || {
18813            let lag = br#"{"error":"validation recovery state is not at the source head","details":{"code":"validation_index_catching_up"}}"#;
18814            let receipt = br#"{"v":2,"outcome":"converged"}"#;
18815            for (status, body) in [
18816                ("422 Unprocessable Entity", lag.as_slice()),
18817                ("200 OK", receipt.as_slice()),
18818            ] {
18819                let (mut stream, _) = listener.accept().unwrap();
18820                drain_test_http_request(&mut stream);
18821                write!(
18822                    stream,
18823                    "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
18824                    body.len()
18825                )
18826                .unwrap();
18827                stream.write_all(body).unwrap();
18828            }
18829        });
18830        let cfg = HubConfig {
18831            hub: format!("http://{address}"),
18832            key: Some("k".to_string()),
18833            agent_key: None,
18834            brain_key: None,
18835            state_dir: tempfile::tempdir().unwrap().keep(),
18836            store_selected: false,
18837        };
18838
18839        let response = request_patient(
18840            &cfg,
18841            "POST",
18842            "/api/hub/brains/b/v2/commits",
18843            Some(&json!({ "mutation_id": "dbmd-1" })),
18844            Auth::Required,
18845        )
18846        .expect("typed projection lag is retried until the exact receipt is available");
18847        assert_eq!(response.status, 200);
18848        assert_eq!(
18849            response
18850                .body
18851                .as_ref()
18852                .and_then(|value| value.get("outcome"))
18853                .and_then(Value::as_str),
18854            Some("converged")
18855        );
18856        server.join().unwrap();
18857    }
18858
18859    #[test]
18860    fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
18861        use std::io::{Read as _, Write as _};
18862        use std::net::TcpListener;
18863        use std::thread;
18864
18865        // A connection that fails while the body is arriving is the same class
18866        // of problem as one that fails before it arrives at all. Typed as a
18867        // bare io error it matched no retry path and named no peer, and one
18868        // stalled body ended a whole migration reporting only "timed out
18869        // reading response".
18870        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18871        let address = listener.local_addr().unwrap();
18872        let server = thread::spawn(move || {
18873            let (mut stream, _) = listener.accept().unwrap();
18874            let mut request_bytes = [0_u8; 1024];
18875            let _ = stream.read(&mut request_bytes).unwrap();
18876            // Promise a hundred bytes, send ten, hang up.
18877            stream
18878                .write_all(
18879                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18880                )
18881                .unwrap();
18882        });
18883        let cfg = HubConfig {
18884            hub: format!("http://{address}"),
18885            key: None,
18886            agent_key: None,
18887            brain_key: None,
18888            state_dir: tempfile::tempdir().unwrap().keep(),
18889            store_selected: false,
18890        };
18891
18892        let error = request(&cfg, "POST", "/truncated", None, Auth::None)
18893            .expect_err("a truncated body must not read as success");
18894        match error {
18895            LinkError::Transport { hub, .. } => {
18896                assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
18897            }
18898            other => panic!("expected a transport failure, got {other:?}"),
18899        }
18900        server.join().unwrap();
18901    }
18902
18903    #[test]
18904    fn a_safe_get_retries_when_its_body_dies_mid_stream() {
18905        use std::io::{Read as _, Write as _};
18906        use std::net::{TcpListener, TcpStream};
18907        use std::thread;
18908
18909        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18910        let address = listener.local_addr().unwrap();
18911        let server = thread::spawn(move || {
18912            let read_request = |stream: &mut TcpStream| {
18913                let mut request = Vec::new();
18914                let mut bytes = [0_u8; 1024];
18915                loop {
18916                    let read = stream.read(&mut bytes).unwrap();
18917                    if read == 0 {
18918                        break;
18919                    }
18920                    request.extend_from_slice(&bytes[..read]);
18921                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18922                    else {
18923                        continue;
18924                    };
18925                    let headers = String::from_utf8_lossy(&request[..header_end]);
18926                    let content_length = headers
18927                        .lines()
18928                        .find_map(|line| {
18929                            let (name, value) = line.split_once(':')?;
18930                            name.eq_ignore_ascii_case("content-length")
18931                                .then(|| value.trim().parse::<usize>().ok())
18932                                .flatten()
18933                        })
18934                        .unwrap_or(0);
18935                    if request.len() >= header_end + 4 + content_length {
18936                        break;
18937                    }
18938                }
18939            };
18940            let (mut first, _) = listener.accept().unwrap();
18941            read_request(&mut first);
18942            first
18943                .write_all(
18944                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18945                )
18946                .unwrap();
18947            drop(first);
18948
18949            let (mut second, _) = listener.accept().unwrap();
18950            read_request(&mut second);
18951            second
18952                .write_all(
18953                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18954                )
18955                .unwrap();
18956        });
18957        let cfg = HubConfig {
18958            hub: format!("http://{address}"),
18959            key: None,
18960            agent_key: None,
18961            brain_key: None,
18962            state_dir: tempfile::tempdir().unwrap().keep(),
18963            store_selected: false,
18964        };
18965
18966        let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
18967            .expect("a safe read retries the interrupted body");
18968        assert_eq!(response.status, 200);
18969        assert_eq!(response.body, Some(json!({ "ok": true })));
18970        server.join().unwrap();
18971    }
18972
18973    #[test]
18974    fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
18975        use std::io::{Read as _, Write as _};
18976        use std::net::{TcpListener, TcpStream};
18977        use std::thread;
18978
18979        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18980        let address = listener.local_addr().unwrap();
18981        let server = thread::spawn(move || {
18982            let read_request = |stream: &mut TcpStream| {
18983                let mut request = Vec::new();
18984                let mut bytes = [0_u8; 1024];
18985                loop {
18986                    let read = stream.read(&mut bytes).unwrap();
18987                    if read == 0 {
18988                        break;
18989                    }
18990                    request.extend_from_slice(&bytes[..read]);
18991                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18992                    else {
18993                        continue;
18994                    };
18995                    let headers = String::from_utf8_lossy(&request[..header_end]);
18996                    let content_length = headers
18997                        .lines()
18998                        .find_map(|line| {
18999                            let (name, value) = line.split_once(':')?;
19000                            name.eq_ignore_ascii_case("content-length")
19001                                .then(|| value.trim().parse::<usize>().ok())
19002                                .flatten()
19003                        })
19004                        .unwrap_or(0);
19005                    if request.len() >= header_end + 4 + content_length {
19006                        break;
19007                    }
19008                }
19009            };
19010            let (mut first, _) = listener.accept().unwrap();
19011            read_request(&mut first);
19012            first
19013                .write_all(
19014                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
19015                )
19016                .unwrap();
19017            drop(first);
19018
19019            let (mut second, _) = listener.accept().unwrap();
19020            read_request(&mut second);
19021            second
19022                .write_all(
19023                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
19024                )
19025                .unwrap();
19026        });
19027        let cfg = HubConfig {
19028            hub: format!("http://{address}"),
19029            key: None,
19030            agent_key: None,
19031            brain_key: None,
19032            state_dir: tempfile::tempdir().unwrap().keep(),
19033            store_selected: false,
19034        };
19035
19036        let response = request_raw_retryable_read(
19037            &cfg,
19038            "POST",
19039            "/v2/stream",
19040            Some(&json!({ "files": ["proof"] })),
19041            Auth::None,
19042            1_024,
19043        )
19044        .expect("an explicitly safe POST retries the interrupted body");
19045        assert_eq!(response.status, 200);
19046        assert_eq!(
19047            serde_json::from_slice::<Value>(&response.body).unwrap(),
19048            json!({ "ok": true })
19049        );
19050        server.join().unwrap();
19051    }
19052
19053    #[test]
19054    fn object_store_transport_errors_never_render_presigned_urls() {
19055        use std::net::TcpListener;
19056
19057        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19058        let address = listener.local_addr().unwrap();
19059        drop(listener);
19060        let signature = "do-not-render-this-presigned-signature";
19061        let raw =
19062            format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
19063        let error = ureq::get(&raw)
19064            .timeout(std::time::Duration::from_millis(250))
19065            .call()
19066            .expect_err("the closed local port must fail");
19067        let ureq::Error::Transport(transport) = error else {
19068            panic!("expected a transport failure");
19069        };
19070
19071        let rendered = object_store_transport_error(transport).to_string();
19072        assert!(rendered.contains("the object store"));
19073        assert!(rendered.contains("network error"));
19074        assert!(!rendered.contains(&raw));
19075        assert!(!rendered.contains(signature));
19076        assert!(!rendered.contains("X-Amz-"));
19077    }
19078
19079    #[test]
19080    fn endpoint_cap_refuses_a_body_before_json_parsing() {
19081        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
19082        let cfg = HubConfig {
19083            hub,
19084            key: None,
19085            agent_key: None,
19086            brain_key: None,
19087            state_dir: tempfile::tempdir().unwrap().keep(),
19088            store_selected: false,
19089        };
19090
19091        assert!(matches!(
19092            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
19093            Err(LinkError::ResponseTooLarge { .. })
19094        ));
19095        server.join().unwrap();
19096    }
19097
19098    #[test]
19099    fn overall_deadline_stops_a_dribbled_response_body() {
19100        use std::io::{Read as _, Write as _};
19101        use std::net::TcpListener;
19102        use std::time::{Duration, Instant};
19103
19104        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19105        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
19106        let server = std::thread::spawn(move || {
19107            let (mut stream, _) = listener.accept().unwrap();
19108            let mut request = [0_u8; 1024];
19109            let _ = stream.read(&mut request);
19110            stream
19111                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
19112                .unwrap();
19113            for byte in [b'x'; 32] {
19114                if stream.write_all(&[byte]).is_err() {
19115                    break;
19116                }
19117                std::thread::sleep(Duration::from_millis(40));
19118            }
19119        });
19120        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
19121        let started = Instant::now();
19122        let response = http.get(&url).call().unwrap();
19123        let mut body = Vec::new();
19124        let error = response
19125            .into_reader()
19126            .read_to_end(&mut body)
19127            .expect_err("per-read progress must not reset the overall deadline");
19128        assert!(
19129            started.elapsed() < Duration::from_millis(700),
19130            "dribbled body exceeded the wall-clock budget: {error}"
19131        );
19132        server.join().unwrap();
19133    }
19134
19135    #[test]
19136    fn overall_deadline_stops_a_stalled_upload() {
19137        use std::net::TcpListener;
19138        use std::time::{Duration, Instant};
19139
19140        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19141        let url = format!("http://{}/upload", listener.local_addr().unwrap());
19142        let server = std::thread::spawn(move || {
19143            let (_stream, _) = listener.accept().unwrap();
19144            // Never consume the request body. Once the kernel send buffer fills,
19145            // the client must leave on its absolute write deadline.
19146            std::thread::sleep(Duration::from_millis(600));
19147        });
19148        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
19149        let body = vec![0x5a; 32 * 1024 * 1024];
19150        let started = Instant::now();
19151        let error = http
19152            .put(&url)
19153            .send_bytes(&body)
19154            .expect_err("stalled request-body writes must time out");
19155        assert!(
19156            started.elapsed() < Duration::from_millis(700),
19157            "stalled upload exceeded the wall-clock budget: {error}"
19158        );
19159        server.join().unwrap();
19160    }
19161
19162    #[test]
19163    fn presigned_source_retries_share_one_upload_deadline() {
19164        use std::net::TcpListener;
19165        use std::time::{Duration, Instant};
19166
19167        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19168        let address = listener.local_addr().unwrap();
19169        let signature = "do-not-render-this-stalled-upload-signature";
19170        let url = format!(
19171            "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
19172        );
19173        let server = std::thread::spawn(move || {
19174            let (_stream, _) = listener.accept().unwrap();
19175            // Keep the socket open without consuming the request. The first
19176            // attempt must spend the complete shared budget; retries may not
19177            // each restart that budget.
19178            std::thread::sleep(Duration::from_millis(600));
19179        });
19180
19181        let directory = tempfile::tempdir().unwrap();
19182        std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
19183        std::fs::create_dir(directory.path().join("records")).unwrap();
19184        let relative = "records/stalled.bin";
19185        let bytes = vec![0x5a; 32 * 1024 * 1024];
19186        std::fs::write(directory.path().join(relative), &bytes).unwrap();
19187        let store = Store::open_strict(directory.path()).unwrap();
19188        let cfg = HubConfig {
19189            hub: format!("http://{address}"),
19190            key: None,
19191            agent_key: None,
19192            brain_key: None,
19193            state_dir: tempfile::tempdir().unwrap().keep(),
19194            store_selected: false,
19195        };
19196        let source = V2UploadSource {
19197            path: relative.to_string(),
19198            bytes: bytes.len() as u64,
19199        };
19200
19201        let started = Instant::now();
19202        let error = put_presigned_source_with_budget(
19203            &cfg,
19204            &url,
19205            &json!({ "content-length": source.bytes.to_string() }),
19206            &store,
19207            &source,
19208            None,
19209            Duration::from_millis(150),
19210        )
19211        .expect_err("a black-holed upload must leave at its shared deadline");
19212        assert!(
19213            started.elapsed() < Duration::from_millis(700),
19214            "presigned retries exceeded their shared budget: {error}"
19215        );
19216        let rendered = error.to_string();
19217        assert!(rendered.contains("the object store"));
19218        assert!(!rendered.contains(&url));
19219        assert!(!rendered.contains(signature));
19220        server.join().unwrap();
19221    }
19222
19223    #[test]
19224    fn verb_entry_gates_accept_the_hub_ref_shapes() {
19225        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
19226            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
19227            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
19228            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
19229        }
19230    }
19231
19232    #[test]
19233    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
19234        let cfg = dead_hub();
19235        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
19236            assert!(
19237                matches!(
19238                    sync_pull(&cfg, bad, None),
19239                    Err(LinkError::BadAddress { .. })
19240                ),
19241                "sync_pull must refuse {bad:?}"
19242            );
19243            assert!(
19244                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
19245                "sync_push must refuse {bad:?}"
19246            );
19247            assert!(
19248                matches!(
19249                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
19250                    Err(LinkError::BadAddress { .. })
19251                ),
19252                "grant_issue must refuse {bad:?}"
19253            );
19254            assert!(
19255                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
19256                "grant_list must refuse {bad:?}"
19257            );
19258            assert!(
19259                matches!(
19260                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
19261                    Err(LinkError::BadAddress { .. })
19262                ),
19263                "grant_revoke must refuse brain {bad:?}"
19264            );
19265            assert!(
19266                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
19267                "head must refuse {bad:?}"
19268            );
19269        }
19270    }
19271
19272    #[test]
19273    fn grant_revoke_refuses_url_reshaping_grant_ids() {
19274        let cfg = dead_hub();
19275        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
19276            assert!(
19277                matches!(
19278                    grant_revoke(&cfg, "acme", bad),
19279                    Err(LinkError::BadGrantId { .. })
19280                ),
19281                "grant_revoke must refuse grant id {bad:?}"
19282            );
19283        }
19284    }
19285
19286    #[test]
19287    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
19288        let cfg = dead_hub();
19289        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
19290            assert!(
19291                matches!(
19292                    propose(&cfg, bad, "intake", "hi"),
19293                    Err(LinkError::BadAddress { .. })
19294                ),
19295                "propose must refuse handle {bad:?}"
19296            );
19297        }
19298        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
19299        assert!(matches!(
19300            propose(&cfg, "acme-site", "intake", &oversize),
19301            Err(LinkError::ProposeTooLarge { .. })
19302        ));
19303        // A clean handle + in-cap body passes both gates: the failure is now
19304        // the (dead) wire, proving the gates refuse shape, not the verb.
19305        assert!(matches!(
19306            propose(&cfg, "acme-site", "intake", "hi"),
19307            Err(LinkError::Transport { .. })
19308        ));
19309    }
19310
19311    #[test]
19312    fn resolve_refuses_a_hand_built_unsafe_address() {
19313        let cfg = dead_hub();
19314        for brain in ["../up", "a/b", "a?x", "a#f"] {
19315            let addr = Address {
19316                brain: brain.to_string(),
19317                target: None,
19318            };
19319            assert!(
19320                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
19321                "resolve must refuse brain {brain:?}"
19322            );
19323        }
19324        for target in [
19325            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
19326            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
19327            AddressTarget::Path("../up.md".to_string()),
19328            AddressTarget::Path("records/x.md#frag".to_string()),
19329        ] {
19330            let addr = Address {
19331                brain: "acme".to_string(),
19332                target: Some(target.clone()),
19333            };
19334            assert!(
19335                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
19336                "resolve must refuse target {target:?}"
19337            );
19338        }
19339    }
19340
19341    #[test]
19342    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
19343        let mut local = std::collections::BTreeMap::new();
19344        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
19345        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
19346        let mut remote = std::collections::BTreeMap::new();
19347        remote.insert(
19348            "records/a.md".to_string(),
19349            V2BaselineFile {
19350                sha256: "c".repeat(64),
19351                bytes: 1,
19352                proof: None,
19353            },
19354        );
19355        remote.insert(
19356            "records/b.md".to_string(),
19357            V2BaselineFile {
19358                sha256: "b".repeat(64),
19359                bytes: 1,
19360                proof: None,
19361            },
19362        );
19363        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
19364    }
19365
19366    #[test]
19367    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
19368        let local = std::collections::BTreeMap::new();
19369        let mut remote = std::collections::BTreeMap::new();
19370        remote.insert(
19371            "private/local.md".to_string(),
19372            V2BaselineFile {
19373                sha256: "d".repeat(64),
19374                bytes: 1,
19375                proof: None,
19376            },
19377        );
19378        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
19379        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
19380    }
19381
19382    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
19383        V2VerifiedHead {
19384            requested: TEST_BRAIN_ID.to_string(),
19385            brain_id: TEST_BRAIN_ID.to_string(),
19386            view_kind: "scoped".to_string(),
19387            view_revision: revision.to_string(),
19388            control_revision: revision.to_string(),
19389            identity: V2HeadIdentity {
19390                custody: "hub".to_string(),
19391                fingerprint: "test".to_string(),
19392                public_key_spki: "test".to_string(),
19393                previous: Vec::new(),
19394                rotations: Vec::new(),
19395            },
19396            pointer: None,
19397            trust: TrustState {
19398                v: 2,
19399                origin: "https://hub.example".to_string(),
19400                requested: TEST_BRAIN_ID.to_string(),
19401                brain: TEST_BRAIN_ID.to_string(),
19402                home: None,
19403                anchor: "ed25519:test".to_string(),
19404                current: "ed25519:test".to_string(),
19405                head_seq: 0,
19406                feed_hash: None,
19407                rotations: Vec::new(),
19408                hub_signer: None,
19409                protocol_profile: Some("link-v2".to_string()),
19410            },
19411            alias: None,
19412        }
19413    }
19414
19415    #[test]
19416    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
19417        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
19418        assert!(accepted_as_v2(&trust));
19419
19420        trust.protocol_profile = None;
19421        trust.hub_signer = Some("ed25519:hub".to_string());
19422        assert!(accepted_as_v2(&trust));
19423
19424        trust.hub_signer = None;
19425        assert!(!accepted_as_v2(&trust));
19426    }
19427
19428    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
19429        V2SyncBaseline {
19430            v: 2,
19431            origin: "https://hub.example".to_string(),
19432            brain: TEST_BRAIN_ID.to_string(),
19433            checkout_id: Some("c".repeat(64)),
19434            head_seq: Some(0),
19435            commit_hash: None,
19436            content_root: None,
19437            asset_root: None,
19438            assets: std::collections::BTreeMap::new(),
19439            view_kind: Some("scoped".to_string()),
19440            view_revision: Some(revision.to_string()),
19441            control_revision: Some(revision.to_string()),
19442            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
19443            files: std::collections::BTreeMap::new(),
19444            scan_cache: std::collections::BTreeMap::new(),
19445            local_policy_digest: None,
19446            local_eligibility: std::collections::BTreeMap::new(),
19447            remote_copy_remains: std::collections::BTreeMap::new(),
19448        }
19449    }
19450
19451    #[test]
19452    fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
19453        let cfg = test_hub_config(
19454            "https://hub.example".to_string(),
19455            tempfile::tempdir().unwrap().keep(),
19456        );
19457        let mut baseline = scoped_test_baseline(&"a".repeat(64));
19458        baseline.assets.insert(
19459            "assets/archive.bin".to_string(),
19460            V2BaselineAsset {
19461                blob_sha256: "b".repeat(64),
19462                bytes: MAX_STORE_BYTES + 1,
19463                media_type: "application/octet-stream".to_string(),
19464                wrappers: vec!["records/archive.md".to_string()],
19465                required: true,
19466                disposition: "hosted".to_string(),
19467                leaf_hash: "c".repeat(64),
19468            },
19469        );
19470
19471        let accepted = serde_json::to_vec(&baseline).unwrap();
19472        assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
19473
19474        baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
19475        let refused = serde_json::to_vec(&baseline).unwrap();
19476        assert!(matches!(
19477            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
19478            Err(LinkError::InvalidFeed { .. })
19479        ));
19480    }
19481
19482    #[test]
19483    fn v2_local_view_keeps_markdown_assets_in_content_but_excludes_binary_assets() {
19484        let directory = tempfile::tempdir().unwrap();
19485        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19486        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
19487        std::fs::write(
19488            directory.path().join("DB.md"),
19489            b"---\nname: Dual plane test\n---\n",
19490        )
19491        .unwrap();
19492        let markdown = b"---\ntype: note\n---\nintegrity tracked\n";
19493        let binary = b"pdf bytes";
19494        std::fs::write(directory.path().join("records/notes/a.md"), markdown).unwrap();
19495        std::fs::write(directory.path().join("sources/files/a.pdf"), binary).unwrap();
19496        let store = Store::open_strict(directory.path()).unwrap();
19497        crate::assets::write_manifest(
19498            &store,
19499            &[
19500                crate::AssetRecord {
19501                    path: "records/notes/a.md".to_string(),
19502                    sha256: content_sha256(markdown),
19503                    bytes: markdown.len() as u64,
19504                    media_type: "text/markdown".to_string(),
19505                    wrappers: vec!["records/notes/a.md".to_string()],
19506                    required: true,
19507                },
19508                crate::AssetRecord {
19509                    path: "sources/files/a.pdf".to_string(),
19510                    sha256: content_sha256(binary),
19511                    bytes: binary.len() as u64,
19512                    media_type: "application/pdf".to_string(),
19513                    wrappers: vec!["records/notes/a.md".to_string()],
19514                    required: true,
19515                },
19516            ],
19517        )
19518        .unwrap();
19519
19520        let view = v2_local_files(&store).unwrap();
19521        assert_eq!(
19522            view.riding.get("records/notes/a.md"),
19523            Some(&(content_sha256(markdown), markdown.len() as u64))
19524        );
19525        assert!(!view.riding.contains_key("sources/files/a.pdf"));
19526    }
19527
19528    #[test]
19529    fn v2_markdown_asset_binding_requires_identical_cross_root_state() {
19530        let path = "sources/notes/a.md".to_string();
19531        let hash = "a".repeat(64);
19532        let mut content = std::collections::BTreeMap::from([(
19533            path.clone(),
19534            V2BaselineFile {
19535                sha256: hash.clone(),
19536                bytes: 7,
19537                proof: None,
19538            },
19539        )]);
19540        let mut assets = std::collections::BTreeMap::from([(
19541            path.clone(),
19542            V2BaselineAsset {
19543                blob_sha256: hash,
19544                bytes: 7,
19545                media_type: "text/markdown".to_string(),
19546                wrappers: vec![path.clone()],
19547                required: true,
19548                disposition: "hosted".to_string(),
19549                leaf_hash: "b".repeat(64),
19550            },
19551        )]);
19552        assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_ok());
19553
19554        content.get_mut(&path).unwrap().bytes = 8;
19555        assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_err());
19556        content.remove(&path);
19557        assets.get_mut(&path).unwrap().disposition = "withheld".to_string();
19558        assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_ok());
19559        content.insert(
19560            path,
19561            V2BaselineFile {
19562                sha256: "a".repeat(64),
19563                bytes: 7,
19564                proof: None,
19565            },
19566        );
19567        assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_err());
19568    }
19569
19570    #[test]
19571    fn v2_markdown_asset_writes_use_the_typed_dual_plane_operation() {
19572        let path = "sources/notes/a.md";
19573        let assets = std::collections::BTreeMap::from([(
19574            path.to_string(),
19575            crate::AssetRecord {
19576                path: path.to_string(),
19577                sha256: "a".repeat(64),
19578                bytes: 7,
19579                media_type: "text/markdown".to_string(),
19580                wrappers: vec![path.to_string()],
19581                required: true,
19582            },
19583        )]);
19584        assert_eq!(
19585            v2_content_put_operation_kind(path, &assets),
19586            "put_asset_content"
19587        );
19588        assert_eq!(
19589            v2_content_put_operation_kind("records/ordinary.md", &assets),
19590            "put"
19591        );
19592        assert!(v2_withdrawal_includes_content(path, &assets));
19593        let mut binary_assets = assets.clone();
19594        binary_assets.insert(
19595            "sources/files/a.pdf".to_string(),
19596            crate::AssetRecord {
19597                path: "sources/files/a.pdf".to_string(),
19598                sha256: "d".repeat(64),
19599                bytes: 3,
19600                media_type: "application/pdf".to_string(),
19601                wrappers: vec![path.to_string()],
19602                required: true,
19603            },
19604        );
19605        assert!(!v2_withdrawal_includes_content(
19606            "sources/files/a.pdf",
19607            &binary_assets
19608        ));
19609
19610        let hash = "b".repeat(64);
19611        let operations = vec![json!({
19612            "op": "put_asset_content",
19613            "path": path,
19614            "expected": { "kind": "absent" },
19615            "blob": hash,
19616            "bytes": 11,
19617        })];
19618        let mut content = std::collections::BTreeMap::new();
19619        let mut remote_assets = std::collections::BTreeMap::new();
19620        apply_generated_v2_operations(&operations, &assets, &mut content, &mut remote_assets)
19621            .unwrap();
19622        assert_eq!(content.get(path).unwrap().sha256, "b".repeat(64));
19623    }
19624
19625    #[cfg(unix)]
19626    #[test]
19627    fn v2_stat_cache_waits_out_racy_files_and_binds_file_identity() {
19628        use std::os::unix::fs::MetadataExt as _;
19629
19630        let directory = tempfile::tempdir().unwrap();
19631        let first = directory.path().join("first.md");
19632        let second = directory.path().join("second.md");
19633        std::fs::write(&first, b"same").unwrap();
19634        std::fs::write(&second, b"same").unwrap();
19635        let first = std::fs::metadata(first).unwrap();
19636        let second = std::fs::metadata(second).unwrap();
19637        let observed_ns = |metadata: &std::fs::Metadata| {
19638            let mtime =
19639                i128::from(metadata.mtime()) * 1_000_000_000 + i128::from(metadata.mtime_nsec());
19640            let ctime =
19641                i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
19642            mtime.max(ctime)
19643        };
19644
19645        assert!(v2_scan_fingerprint_at(&first, observed_ns(&first) + 1_000_000_000).is_none());
19646        let first_fingerprint =
19647            v2_scan_fingerprint_at(&first, observed_ns(&first) + 3_000_000_000).unwrap();
19648        let second_fingerprint =
19649            v2_scan_fingerprint_at(&second, observed_ns(&second) + 3_000_000_000).unwrap();
19650        assert_ne!(first_fingerprint, second_fingerprint);
19651    }
19652
19653    #[test]
19654    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
19655        let directory = tempfile::tempdir().unwrap();
19656        std::fs::write(
19657            directory.path().join("DB.md"),
19658            scoped_projection_bytes(TEST_BRAIN_ID),
19659        )
19660        .unwrap();
19661        let store = Store::open_strict(directory.path()).unwrap();
19662        let head = scoped_test_head(&"a".repeat(64));
19663        let baseline = scoped_test_baseline(&"a".repeat(64));
19664        let mut view = v2_local_files(&store).unwrap();
19665        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
19666        assert!(!view.riding.contains_key("DB.md"));
19667        assert!(!view.eligibility.contains_key("DB.md"));
19668    }
19669
19670    #[test]
19671    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
19672        let directory = tempfile::tempdir().unwrap();
19673        std::fs::write(
19674            directory.path().join("DB.md"),
19675            scoped_projection_bytes(TEST_BRAIN_ID),
19676        )
19677        .unwrap();
19678        let store = Store::open_strict(directory.path()).unwrap();
19679        let head = scoped_test_head(&"a".repeat(64));
19680        let baseline = scoped_test_baseline(&"a".repeat(64));
19681
19682        let mut carried = v2_local_files(&store).unwrap();
19683        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
19684        let handed_off =
19685            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
19686        assert!(!handed_off.riding.contains_key("DB.md"));
19687
19688        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
19689        assert!(!freshly_scanned.riding.contains_key("DB.md"));
19690
19691        std::fs::write(
19692            directory.path().join("DB.md"),
19693            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
19694        )
19695        .unwrap();
19696        let tampered = Store::open_strict(directory.path()).unwrap();
19697        assert!(matches!(
19698            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
19699            Err(LinkError::ScopedProjectionModified)
19700        ));
19701    }
19702
19703    #[test]
19704    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
19705        let directory = tempfile::tempdir().unwrap();
19706        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19707        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
19708        std::fs::write(
19709            directory.path().join("DB.md"),
19710            b"---\nname: Kept home test\n---\n",
19711        )
19712        .unwrap();
19713        std::fs::write(
19714            directory.path().join("records/notes/a.md"),
19715            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
19716        )
19717        .unwrap();
19718        std::fs::write(
19719            directory.path().join("sources/private/secret.md"),
19720            b"---\ntype: note\n---\nlocal only\n",
19721        )
19722        .unwrap();
19723        std::fs::write(
19724            directory.path().join("sources/private/unlinked.md"),
19725            b"---\ntype: note\n---\nnot disclosed\n",
19726        )
19727        .unwrap();
19728        std::fs::write(
19729            directory.path().join(".sevralocal"),
19730            b"sources/private/**\n",
19731        )
19732        .unwrap();
19733
19734        let store = Store::open_strict(directory.path()).unwrap();
19735        let view = v2_local_files(&store).unwrap();
19736        assert!(!view.riding.contains_key("sources/private/secret.md"));
19737        assert_eq!(
19738            view.withheld_links,
19739            vec![V2WithheldLink {
19740                source: "records/notes/a.md".to_string(),
19741                target: "sources/private/secret.md".to_string(),
19742            }]
19743        );
19744    }
19745
19746    #[test]
19747    fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
19748        // An export omits kept-home content by construction, so a store
19749        // restored from one links to names it does not carry. Without this the
19750        // owner's own export could never be pushed back — the links would read
19751        // as broken and the commit would be refused.
19752        let directory = tempfile::tempdir().unwrap();
19753        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19754        std::fs::write(
19755            directory.path().join("DB.md"),
19756            b"---\nname: Restored export\n---\n",
19757        )
19758        .unwrap();
19759        std::fs::write(
19760            directory.path().join("records/notes/a.md"),
19761            b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
19762        )
19763        .unwrap();
19764        std::fs::write(
19765            directory.path().join(".sevralocal"),
19766            b"sources/private/**\n",
19767        )
19768        .unwrap();
19769
19770        let store = Store::open_strict(directory.path()).unwrap();
19771        let view = v2_local_files(&store).unwrap();
19772        assert_eq!(
19773            view.withheld_links,
19774            vec![V2WithheldLink {
19775                source: "records/notes/a.md".to_string(),
19776                target: "sources/private/absent.md".to_string(),
19777            }]
19778        );
19779        // A dangling link the owner never claimed is still dangling.
19780        std::fs::write(
19781            directory.path().join("records/notes/b.md"),
19782            b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
19783        )
19784        .unwrap();
19785        let store = Store::open_strict(directory.path()).unwrap();
19786        let view = v2_local_files(&store).unwrap();
19787        assert!(
19788            !view
19789                .withheld_links
19790                .iter()
19791                .any(|link| link.target == "records/notes/nowhere.md"),
19792            "an unclaimed dangling target must not be declared withheld"
19793        );
19794    }
19795
19796    #[test]
19797    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
19798        let directory = tempfile::tempdir().unwrap();
19799        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
19800        std::fs::write(
19801            directory.path().join("DB.md"),
19802            b"---\nname: Withdrawal test\n---\n",
19803        )
19804        .unwrap();
19805        let source = b"---\ntype: note\n---\nlocal evidence\n";
19806        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
19807        std::fs::write(
19808            directory.path().join(".sevralocal"),
19809            b"sources/private/**\n",
19810        )
19811        .unwrap();
19812        let store = Store::open_strict(directory.path()).unwrap();
19813        let view = v2_local_files(&store).unwrap();
19814        let mut remote = std::collections::BTreeMap::new();
19815        remote.insert(
19816            "sources/private/evidence.md".to_string(),
19817            V2BaselineFile {
19818                sha256: content_sha256(source),
19819                bytes: source.len() as u64,
19820                proof: None,
19821            },
19822        );
19823        assert_eq!(
19824            v2_content_withdrawal_operation(
19825                &store,
19826                &view,
19827                &remote,
19828                "sources/private/evidence.md",
19829                "approved retention change",
19830            )
19831            .unwrap(),
19832            json!({
19833                "op": "withdraw_from_hosting",
19834                "path": "sources/private/evidence.md",
19835                "expected": { "kind": "blob", "hash": content_sha256(source) },
19836                "reason": "approved retention change",
19837            })
19838        );
19839
19840        std::fs::write(
19841            directory.path().join("sources/private/evidence.md"),
19842            b"changed after review",
19843        )
19844        .unwrap();
19845        assert!(matches!(
19846            v2_content_withdrawal_operation(
19847                &store,
19848                &view,
19849                &remote,
19850                "sources/private/evidence.md",
19851                "approved retention change",
19852            ),
19853            Err(LinkError::InvalidPack { .. })
19854        ));
19855    }
19856
19857    #[test]
19858    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
19859        let directory = tempfile::tempdir().unwrap();
19860        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
19861        std::fs::write(
19862            directory.path().join("DB.md"),
19863            b"---\nname: Asset withdrawal test\n---\n",
19864        )
19865        .unwrap();
19866        let bytes = b"private binary";
19867        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
19868        std::fs::write(
19869            directory.path().join(".sevralocal"),
19870            b"sources/files/private.pdf\n",
19871        )
19872        .unwrap();
19873        let store = Store::open_strict(directory.path()).unwrap();
19874        let view = v2_local_files(&store).unwrap();
19875        let local = crate::AssetRecord {
19876            path: "sources/files/private.pdf".to_string(),
19877            sha256: content_sha256(bytes),
19878            bytes: bytes.len() as u64,
19879            media_type: "application/pdf".to_string(),
19880            wrappers: vec![
19881                "sources/files/private.md".to_string(),
19882                "sources/redacted/private.md".to_string(),
19883            ],
19884            required: false,
19885        };
19886        let current = V2BaselineAsset {
19887            blob_sha256: local.sha256.clone(),
19888            bytes: local.bytes,
19889            media_type: local.media_type.clone(),
19890            wrappers: vec!["sources/files/private.md".to_string()],
19891            required: true,
19892            disposition: "hosted".to_string(),
19893            leaf_hash: "d".repeat(64),
19894        };
19895        assert_eq!(
19896            v2_asset_withdrawal_operation(
19897                &store,
19898                &view,
19899                &local.path,
19900                &local,
19901                &current,
19902                "approved retention change",
19903            )
19904            .unwrap(),
19905            json!({
19906                "op": "asset_withdraw",
19907                "path": local.path,
19908                "expected": { "kind": "asset", "hash": "d".repeat(64) },
19909                "asset": {
19910                    "blob_sha256": local.sha256.clone(),
19911                    "bytes": local.bytes,
19912                    "media_type": local.media_type.clone(),
19913                    "wrappers": local.wrappers.clone(),
19914                    "required": false,
19915                    "disposition": "withheld",
19916                },
19917                "reason": "approved retention change",
19918            })
19919        );
19920
19921        let mut mismatched = current.clone();
19922        mismatched.blob_sha256 = "f".repeat(64);
19923        assert!(matches!(
19924            v2_asset_withdrawal_operation(
19925                &store,
19926                &view,
19927                &local.path,
19928                &local,
19929                &mismatched,
19930                "approved retention change",
19931            ),
19932            Err(LinkError::InvalidPack { .. })
19933        ));
19934    }
19935
19936    #[test]
19937    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
19938        let first = v2_checkout_id(None).unwrap();
19939        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
19940        assert_ne!(first, v2_checkout_id(None).unwrap());
19941        assert!(is_sha256(&first));
19942    }
19943
19944    #[test]
19945    fn moved_checkout_relocates_its_path_bound_baseline_without_rehash_ambiguity() {
19946        let sandbox = tempfile::tempdir().unwrap();
19947        let stage_root = sandbox.path().join("stage");
19948        let live_root = sandbox.path().join("live");
19949        let from = stage_root.join("db");
19950        let to = live_root.join("db");
19951        std::fs::create_dir_all(from.join("records/items")).unwrap();
19952        std::fs::write(
19953            from.join("DB.md"),
19954            b"---\ntype: db-md\nscope: company\nowner: test\n---\n",
19955        )
19956        .unwrap();
19957        std::fs::write(
19958            from.join("records/items/example.md"),
19959            b"---\ntype: item\n---\n\n# Example\n",
19960        )
19961        .unwrap();
19962        let cfg = test_hub_config(
19963            "https://hub.example".to_string(),
19964            sandbox.path().join("state"),
19965        );
19966        let store = Store::open_strict(&from).unwrap();
19967        let local = v2_local_files(&store).unwrap();
19968        let files = local
19969            .riding
19970            .iter()
19971            .map(|(path, (sha256, bytes))| {
19972                (
19973                    path.clone(),
19974                    V2BaselineFile {
19975                        sha256: sha256.clone(),
19976                        bytes: *bytes,
19977                        proof: None,
19978                    },
19979                )
19980            })
19981            .collect();
19982        let baseline = V2SyncBaseline {
19983            v: 2,
19984            origin: "https://hub.example".to_string(),
19985            brain: TEST_BRAIN_ID.to_string(),
19986            checkout_id: Some("c".repeat(64)),
19987            head_seq: Some(7),
19988            commit_hash: Some("a".repeat(64)),
19989            content_root: Some("b".repeat(64)),
19990            asset_root: None,
19991            assets: std::collections::BTreeMap::new(),
19992            view_kind: Some("full".to_string()),
19993            view_revision: Some("d".repeat(64)),
19994            control_revision: Some("e".repeat(64)),
19995            projection_sha256: None,
19996            files,
19997            scan_cache: local.scan_cache.clone(),
19998            local_policy_digest: Some(local.policy.digest.clone()),
19999            local_eligibility: local.eligibility.clone(),
20000            remote_copy_remains: std::collections::BTreeMap::new(),
20001        };
20002        save_v2_baseline(&cfg, TEST_BRAIN_ID, &from, &baseline).unwrap();
20003        std::fs::rename(&stage_root, &live_root).unwrap();
20004
20005        let first = relocate_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from, &to).unwrap();
20006        assert_eq!(first.get("moved").and_then(Value::as_bool), Some(true));
20007        assert!(!has_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from).unwrap());
20008        assert!(has_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &to).unwrap());
20009
20010        let retry = relocate_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from, &to).unwrap();
20011        assert_eq!(retry.get("moved").and_then(Value::as_bool), Some(false));
20012    }
20013
20014    #[test]
20015    fn scoped_projection_edit_and_scope_transition_fail_closed() {
20016        let directory = tempfile::tempdir().unwrap();
20017        std::fs::write(
20018            directory.path().join("DB.md"),
20019            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
20020        )
20021        .unwrap();
20022        let store = Store::open_strict(directory.path()).unwrap();
20023        let head = scoped_test_head(&"a".repeat(64));
20024        let baseline = scoped_test_baseline(&"a".repeat(64));
20025        let mut view = v2_local_files(&store).unwrap();
20026        assert!(matches!(
20027            remove_scoped_projection(&head, Some(&baseline), &mut view),
20028            Err(LinkError::ScopedProjectionModified)
20029        ));
20030
20031        let changed = scoped_test_head(&"b".repeat(64));
20032        assert!(matches!(
20033            ensure_v2_view_compatible(&changed, Some(&baseline)),
20034            Err(LinkError::ScopedViewChanged)
20035        ));
20036
20037        let mut same_view_new_control = head.clone();
20038        same_view_new_control.control_revision = "c".repeat(64);
20039        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
20040        assert!(!same_v2_head(&head, &same_view_new_control));
20041    }
20042
20043    #[test]
20044    fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
20045        let mut head = scoped_test_head(&"a".repeat(64));
20046        head.control_revision = "b".repeat(64);
20047        head.pointer = Some(V2PointerBody {
20048            v: 2,
20049            brain: TEST_BRAIN_ID.to_string(),
20050            seq: 7,
20051            commit_hash: "c".repeat(64),
20052            feed_hash: "d".repeat(64),
20053            content_root: Some("e".repeat(64)),
20054            asset_root: Some("f".repeat(64)),
20055            materializer: "dbmd-projection-v1".to_string(),
20056            signer_epoch: 1,
20057            control_revision: head.control_revision.clone(),
20058            backup_preparation: "0".repeat(64),
20059            prior_pointer_hash: Some("1".repeat(64)),
20060            signed_at: "2026-08-24T00:00:00.000Z".to_string(),
20061        });
20062        let mut baseline = scoped_test_baseline(&head.view_revision);
20063        baseline.head_seq = Some(7);
20064        baseline.commit_hash = Some("c".repeat(64));
20065        baseline.content_root = Some("e".repeat(64));
20066        baseline.asset_root = Some("f".repeat(64));
20067        baseline.control_revision = Some(head.control_revision.clone());
20068        assert!(v2_baseline_matches_head(&head, &baseline));
20069
20070        let mut changed = baseline.clone();
20071        changed.head_seq = Some(8);
20072        assert!(!v2_baseline_matches_head(&head, &changed));
20073        let mut changed = baseline.clone();
20074        changed.commit_hash = Some("2".repeat(64));
20075        assert!(!v2_baseline_matches_head(&head, &changed));
20076        let mut changed = baseline.clone();
20077        changed.content_root = Some("3".repeat(64));
20078        assert!(!v2_baseline_matches_head(&head, &changed));
20079        let mut changed = baseline.clone();
20080        changed.asset_root = Some("4".repeat(64));
20081        assert!(!v2_baseline_matches_head(&head, &changed));
20082        let mut changed = baseline.clone();
20083        changed.view_revision = Some("5".repeat(64));
20084        assert!(!v2_baseline_matches_head(&head, &changed));
20085        let mut changed = baseline.clone();
20086        changed.control_revision = Some("6".repeat(64));
20087        assert!(!v2_baseline_matches_head(&head, &changed));
20088
20089        let mut changed_head = head.clone();
20090        changed_head.view_kind = "full".to_string();
20091        assert!(!v2_baseline_matches_head(&changed_head, &baseline));
20092    }
20093
20094    #[test]
20095    fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
20096        let sandbox = tempfile::tempdir().unwrap();
20097        let cfg = test_hub_config(
20098            "https://hub.example".to_string(),
20099            sandbox.path().to_path_buf(),
20100        );
20101        let head = scoped_test_head(&"a".repeat(64));
20102        let baseline = scoped_test_baseline(&head.view_revision);
20103        let mut encoded = serde_json::to_value(&baseline).unwrap();
20104        encoded.as_object_mut().unwrap().remove("control_revision");
20105        let parsed =
20106            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
20107        assert!(parsed.control_revision.is_none());
20108        assert!(!v2_baseline_matches_head(&head, &parsed));
20109    }
20110
20111    #[test]
20112    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
20113        let scoped = scoped_test_head(&"a".repeat(64));
20114        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
20115        assert!(matches!(
20116            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
20117            Err(LinkError::ScopedProjectionModified)
20118        ));
20119
20120        let mut full = scoped.clone();
20121        full.view_kind = "full".to_string();
20122        let mut full_baseline = scoped_baseline.clone();
20123        full_baseline.view_kind = Some("full".to_string());
20124        full_baseline.projection_sha256 = None;
20125        assert!(matches!(
20126            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
20127            Err(LinkError::InvalidPack { .. })
20128        ));
20129
20130        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
20131        assert!(
20132            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
20133        );
20134    }
20135
20136    #[test]
20137    fn scoped_view_metadata_is_explicitly_non_authoritative() {
20138        let head = scoped_test_head(&"a".repeat(64));
20139        let value: Value =
20140            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
20141        assert_eq!(value["kind"], "link.md-scoped-view");
20142        assert_eq!(value["authoritative"], false);
20143        assert_eq!(value["visible_files"], 7);
20144        assert_eq!(value["brain"], TEST_BRAIN_ID);
20145    }
20146
20147    #[test]
20148    fn local_scoped_marker_requires_the_exact_generated_projection() {
20149        let directory = tempfile::tempdir().unwrap();
20150        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
20151        std::fs::write(
20152            directory.path().join("DB.md"),
20153            scoped_projection_bytes(TEST_BRAIN_ID),
20154        )
20155        .unwrap();
20156        let head = scoped_test_head(&"a".repeat(64));
20157        std::fs::write(
20158            directory.path().join(".dbmd/view.json"),
20159            scoped_view_metadata(&head, 0).unwrap(),
20160        )
20161        .unwrap();
20162        let store = Store::open_strict(directory.path()).unwrap();
20163        assert!(has_verified_local_scoped_view(&store));
20164
20165        std::fs::write(
20166            directory.path().join("DB.md"),
20167            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
20168        )
20169        .unwrap();
20170        let altered = Store::open_strict(directory.path()).unwrap();
20171        assert!(!has_verified_local_scoped_view(&altered));
20172    }
20173
20174    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
20175        use ring::signature::KeyPair as _;
20176
20177        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
20178        let rng = ring::rand::SystemRandom::new();
20179        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
20180        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
20181        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
20182        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
20183        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
20184        let blob = b"new";
20185        let blob_hash = content_sha256(blob);
20186        let changes = json!({
20187            "mutation_id": "sync:proposal-fixture",
20188            "operations": [{
20189                "blob": blob_hash,
20190                "bytes": blob.len(),
20191                "expected": null,
20192                "op": "put",
20193                "path": "records/new.md",
20194            }],
20195            "reason": "fixture",
20196            "v": 2,
20197        });
20198        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
20199        let changes_base64 = STANDARD.encode(&changes_bytes);
20200        let descriptor = json!({
20201            "base": null,
20202            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
20203            "changes_base64": changes_base64,
20204            "rebase": "strict",
20205            "v": 2,
20206        });
20207        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
20208        let payload_hash = "b".repeat(64);
20209        let submitted_at = "2026-08-19T12:00:00.000Z";
20210        let claim = json!({
20211            "actor_root": {
20212                "actor_class": "foreign_key",
20213                "credential": "ed25519:fixture",
20214                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
20215                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
20216                "principal": "key:fixture",
20217                "role": null,
20218            },
20219            "brain": TEST_BRAIN_ID,
20220            "clear_sha256": clear_hash,
20221            "control_revision": "c".repeat(64),
20222            "mutation_id": "sync:proposal-fixture",
20223            "payload_sha256": payload_hash,
20224            "proposal_id": proposal_id,
20225            "submitted_at": submitted_at,
20226            "v": 2,
20227        });
20228        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
20229        let envelope = json!({
20230            "claim": claim,
20231            "fingerprint": fingerprint,
20232            "public_key": public_key,
20233            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
20234        });
20235        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
20236        let submission_hash =
20237            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
20238        let mut head = scoped_test_head(&"c".repeat(64));
20239        head.view_kind = "full".to_string();
20240        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
20241        let value = json!({
20242            "proposal": {
20243                "base": null,
20244                "blobs": [{
20245                    "bytes": blob.len(),
20246                    "endpoint": format!(
20247                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
20248                    ),
20249                    "sha256": blob_hash,
20250                }],
20251                "changes_base64": changes_base64,
20252                "clear_sha256": clear_hash,
20253                "expires_at": "2026-08-26T12:00:00.000Z",
20254                "id": proposal_id,
20255                "payload_sha256": payload_hash,
20256                "proposer": { "class": "foreign_key" },
20257                "rebase": "strict",
20258                "state": "pending",
20259                "submission_claim_base64": STANDARD.encode(envelope_bytes),
20260                "submission_claim_sha256": submission_hash,
20261                "submitted_at": submitted_at,
20262            },
20263            "v": 2,
20264        });
20265        (head, proposal_id, value)
20266    }
20267
20268    #[test]
20269    fn v2_proposal_verifier_accepts_exact_signed_payload() {
20270        let (head, proposal_id, value) = signed_proposal_fixture();
20271        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
20272        assert_eq!(verified.blobs.len(), 1);
20273        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
20274    }
20275
20276    #[test]
20277    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
20278        let (head, proposal_id, value) = signed_proposal_fixture();
20279
20280        let mut changed = value.clone();
20281        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
20282        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
20283
20284        let mut redirected = value.clone();
20285        redirected["proposal"]["blobs"][0]["endpoint"] =
20286            Value::String("https://attacker.example/blob".to_string());
20287        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
20288
20289        let mut forged = value;
20290        let encoded = forged["proposal"]["submission_claim_base64"]
20291            .as_str()
20292            .unwrap();
20293        let mut envelope: Value =
20294            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
20295        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
20296        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
20297        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
20298        forged["proposal"]["submission_claim_sha256"] = Value::String(
20299            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
20300        );
20301        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
20302    }
20303
20304    #[cfg(unix)]
20305    #[test]
20306    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
20307        let sandbox = tempfile::tempdir().unwrap();
20308        let destination = sandbox.path().join("brain");
20309        let entries = vec![
20310            (
20311                "DB.md".to_string(),
20312                scoped_projection_bytes(TEST_BRAIN_ID),
20313            ),
20314            (
20315                "records/contacts/a.md".to_string(),
20316                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
20317                    .to_vec(),
20318            ),
20319        ];
20320        install_pulled_delta(&destination, &entries, &[], true).unwrap();
20321        assert!(destination.join("index.md").is_file());
20322        assert!(destination.join("records/index.md").is_file());
20323        assert!(destination.join("records/contacts/index.md").is_file());
20324        assert!(destination.join("records/contacts/index.jsonl").is_file());
20325    }
20326
20327    #[cfg(unix)]
20328    #[test]
20329    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
20330        let sandbox = tempfile::tempdir().unwrap();
20331        let destination = sandbox.path().join("brain");
20332        let cache = sandbox.path().join("cache");
20333        std::fs::create_dir(&cache).unwrap();
20334        let db = scoped_projection_bytes(TEST_BRAIN_ID);
20335        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
20336        let db_source = cache.join("db");
20337        let shared_source = cache.join("shared");
20338        crate::fsx::write_atomic(&db_source, &db).unwrap();
20339        crate::fsx::write_atomic(&shared_source, shared).unwrap();
20340        let mut entries = vec![V2StagedFile {
20341            path: "DB.md".to_string(),
20342            source: db_source,
20343            sha256: content_sha256(&db),
20344            bytes: db.len() as u64,
20345        }];
20346        for index in 0..512 {
20347            entries.push(V2StagedFile {
20348                path: format!("records/items/{index:05}.md"),
20349                source: shared_source.clone(),
20350                sha256: content_sha256(shared),
20351                bytes: shared.len() as u64,
20352            });
20353        }
20354        install_pulled_delta_sources(
20355            &destination,
20356            &entries,
20357            &[],
20358            false,
20359            None,
20360            &scoped_test_head(&"c".repeat(64)),
20361        )
20362        .unwrap();
20363        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
20364        for index in 0..512 {
20365            assert_eq!(
20366                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
20367                shared
20368            );
20369        }
20370        assert!(
20371            std::fs::read_dir(sandbox.path())
20372                .unwrap()
20373                .all(|entry| !entry
20374                    .unwrap()
20375                    .file_name()
20376                    .to_string_lossy()
20377                    .contains("pull-stage")),
20378            "the private stage must be atomically installed or removed"
20379        );
20380    }
20381
20382    #[cfg(unix)]
20383    #[test]
20384    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
20385        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
20386
20387        let sandbox = tempfile::tempdir().unwrap();
20388        let root = sandbox.path().join("brain");
20389        std::fs::create_dir_all(root.join("records/items")).unwrap();
20390        let db = scoped_projection_bytes(TEST_BRAIN_ID);
20391        let old = b"---\ntype: note\n---\n\nold\n";
20392        let new = b"---\ntype: note\n---\n\nnew\n";
20393        let removed = b"---\ntype: note\n---\n\nremove me\n";
20394        std::fs::write(root.join("DB.md"), &db).unwrap();
20395        std::fs::write(root.join("records/items/change.md"), old).unwrap();
20396        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
20397        for index in 0..512 {
20398            std::fs::write(
20399                root.join(format!("records/items/untouched-{index:04}.md")),
20400                old,
20401            )
20402            .unwrap();
20403        }
20404        let untouched = root.join("records/items/untouched-0256.md");
20405        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
20406        let source = sandbox.path().join("changed-source");
20407        crate::fsx::write_atomic(&source, new).unwrap();
20408        let same_source = sandbox.path().join("unchanged-source");
20409        crate::fsx::write_atomic(&same_source, old).unwrap();
20410        let same_entry = V2StagedFile {
20411            path: "records/items/change.md".to_string(),
20412            source: same_source,
20413            sha256: content_sha256(old),
20414            bytes: old.len() as u64,
20415        };
20416        let entry = V2StagedFile {
20417            path: "records/items/change.md".to_string(),
20418            source,
20419            sha256: content_sha256(new),
20420            bytes: new.len() as u64,
20421        };
20422        let head = scoped_test_head(&"c".repeat(64));
20423
20424        // A no-op established pull neither clones the checkout nor arms a
20425        // journal. Inode identity is a direct regression guard against the
20426        // former brain-wide private-copy implementation.
20427        install_established_v2_delta(
20428            Store::open_strict(&root).unwrap(),
20429            &[same_entry],
20430            &["records/items/already-absent.md".to_string()],
20431            true,
20432            None,
20433            &head,
20434        )
20435        .unwrap();
20436        assert_eq!(
20437            std::fs::metadata(&untouched).unwrap().ino(),
20438            untouched_inode
20439        );
20440        assert!(!root.join(V2_PULL_JOURNAL).exists());
20441
20442        install_established_v2_delta(
20443            Store::open_strict(&root).unwrap(),
20444            &[entry],
20445            &["records/items/delete.md".to_string()],
20446            false,
20447            None,
20448            &head,
20449        )
20450        .unwrap();
20451        assert_eq!(
20452            std::fs::read(root.join("records/items/change.md")).unwrap(),
20453            new
20454        );
20455        assert!(!root.join("records/items/delete.md").exists());
20456        assert_eq!(
20457            std::fs::metadata(&untouched).unwrap().ino(),
20458            untouched_inode
20459        );
20460        assert!(root.join(V2_PULL_JOURNAL).is_file());
20461        assert_eq!(
20462            std::fs::metadata(root.join(V2_PULL_JOURNAL))
20463                .unwrap()
20464                .permissions()
20465                .mode()
20466                & 0o777,
20467            0o600
20468        );
20469        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
20470            .unwrap()
20471            .unwrap();
20472        assert_eq!(
20473            std::fs::metadata(root.join(&journal.backup_dir))
20474                .unwrap()
20475                .permissions()
20476                .mode()
20477                & 0o777,
20478            0o700
20479        );
20480        for entry in &journal.entries {
20481            if let Some(backup) = &entry.backup {
20482                assert_eq!(
20483                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
20484                        .unwrap()
20485                        .permissions()
20486                        .mode()
20487                        & 0o777,
20488                    0o600
20489                );
20490            }
20491        }
20492
20493        let cfg = test_hub_config(
20494            "https://example.test".to_string(),
20495            sandbox.path().join("state"),
20496        );
20497        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20498        assert_eq!(
20499            std::fs::read(root.join("records/items/change.md")).unwrap(),
20500            old
20501        );
20502        assert_eq!(
20503            std::fs::read(root.join("records/items/delete.md")).unwrap(),
20504            removed
20505        );
20506        assert_eq!(
20507            std::fs::metadata(&untouched).unwrap().ino(),
20508            untouched_inode
20509        );
20510        assert!(!root.join(V2_PULL_JOURNAL).exists());
20511    }
20512
20513    #[test]
20514    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
20515        let body = b"bounded bytes";
20516        let path = "records/example.md".to_string();
20517        let file = V2BaselineFile {
20518            sha256: content_sha256(body),
20519            bytes: body.len() as u64,
20520            proof: None,
20521        };
20522        let header = serde_json::to_vec(&json!({
20523            "bytes": body.len(),
20524            "path": path,
20525            "sha256": file.sha256,
20526            "v": 2,
20527        }))
20528        .unwrap();
20529        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
20530        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
20531        stream.extend_from_slice(&header);
20532        stream.extend_from_slice(body);
20533        stream.extend_from_slice(&0_u32.to_be_bytes());
20534        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
20535        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
20536
20537        let mut tampered = stream.clone();
20538        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
20539        tampered[body_offset] ^= 1;
20540        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
20541
20542        let mut trailing = stream;
20543        trailing.push(0);
20544        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
20545    }
20546
20547    #[test]
20548    fn first_checkout_resolution_does_not_recreate_the_same_conflict() {
20549        let path = "records/value.md".to_string();
20550        let mut local = std::collections::BTreeMap::new();
20551        local.insert(path.clone(), (content_sha256(b"local"), 5));
20552        let mut remote = std::collections::BTreeMap::new();
20553        remote.insert(
20554            path.clone(),
20555            V2BaselineFile {
20556                sha256: content_sha256(b"remote"),
20557                bytes: 6,
20558                proof: None,
20559            },
20560        );
20561
20562        assert_eq!(
20563            v2_initial_content_conflicts(&local, &remote, false),
20564            vec![path]
20565        );
20566        assert!(v2_initial_content_conflicts(&local, &remote, true).is_empty());
20567
20568        let mut resolution = std::collections::BTreeMap::new();
20569        resolution.insert(
20570            "records/value.md".to_string(),
20571            V2ResolutionOverride {
20572                expected_remote: Some(content_sha256(b"remote")),
20573                selected_local: Some(content_sha256(b"local")),
20574            },
20575        );
20576        assert!(v2_resolution_allows_path(
20577            Some(&resolution),
20578            "records/value.md",
20579            true
20580        ));
20581        assert!(v2_resolution_allows_path(
20582            Some(&resolution),
20583            "records/new-target.md",
20584            false
20585        ));
20586        assert!(!v2_resolution_allows_path(
20587            Some(&resolution),
20588            "records/unreviewed-remote.md",
20589            true
20590        ));
20591    }
20592
20593    #[test]
20594    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
20595        let sandbox = tempfile::TempDir::new().unwrap();
20596        let root = sandbox.path().join("brain");
20597        std::fs::create_dir_all(&root).unwrap();
20598        std::fs::write(
20599            root.join("DB.md"),
20600            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20601        )
20602        .unwrap();
20603        let store = Store::open_strict(&root).unwrap();
20604        let incomplete = crate::ulid::mint();
20605        store
20606            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
20607            .unwrap();
20608        let expired = crate::ulid::mint();
20609        store
20610            .create_dir_all(&v2_conflict_relative(&expired, "files"))
20611            .unwrap();
20612        let plan = V2ConflictPlan {
20613            v: 2,
20614            class: "content_resolution_required".to_string(),
20615            bundle: expired.clone(),
20616            brain: TEST_BRAIN_ID.to_string(),
20617            origin: "https://example.test".to_string(),
20618            created_unix: 0,
20619            expires_unix: 0,
20620            base_seq: None,
20621            base_commit: None,
20622            remote_seq: 0,
20623            remote_commit: None,
20624            remote_content_root: None,
20625            view_kind: "full".to_string(),
20626            view_revision: "a".repeat(64),
20627            files: vec![V2ConflictFile {
20628                path: "records/value.md".to_string(),
20629                base: V2ConflictCoordinate {
20630                    sha256: None,
20631                    bytes: None,
20632                    file: None,
20633                },
20634                local: V2ConflictCoordinate {
20635                    sha256: None,
20636                    bytes: None,
20637                    file: None,
20638                },
20639                remote: V2ConflictCoordinate {
20640                    sha256: None,
20641                    bytes: None,
20642                    file: None,
20643                },
20644            }],
20645        };
20646        let mut bytes = serde_json::to_vec(&plan).unwrap();
20647        bytes.push(b'\n');
20648        store
20649            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
20650            .unwrap();
20651
20652        let listed = sync_conflicts(&root, false, false).unwrap();
20653        assert_eq!(listed["bundles"], 2);
20654        assert_eq!(listed["pruned"], 0);
20655        let pruned = sync_conflicts(&root, true, false).unwrap();
20656        assert_eq!(pruned["bundles"], 0);
20657        assert_eq!(pruned["pruned"], 2);
20658        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
20659        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
20660    }
20661
20662    #[test]
20663    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
20664        let sandbox = tempfile::TempDir::new().unwrap();
20665        let root = sandbox.path().join("brain");
20666        std::fs::create_dir_all(&root).unwrap();
20667        std::fs::write(
20668            root.join("DB.md"),
20669            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20670        )
20671        .unwrap();
20672        let store = Store::open_strict(&root).unwrap();
20673        let bundle = crate::ulid::mint();
20674        store
20675            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
20676            .unwrap();
20677        store
20678            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
20679            .unwrap();
20680
20681        assert!(sync_conflicts(&root, true, false).is_err());
20682        assert!(sync_conflicts(&root, false, true).is_err());
20683        let pruned = sync_conflicts(&root, true, true).unwrap();
20684        assert_eq!(pruned["pruned"], 1);
20685        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
20686    }
20687
20688    #[test]
20689    fn pull_journal_admits_large_asset_coordinates_but_keeps_a_hard_bound() {
20690        let coordinate = |bytes| V2PullJournal {
20691            v: 1,
20692            phase: V2PullPhase::Preparing,
20693            brain: TEST_BRAIN_ID.to_string(),
20694            previous: V2PullCoordinate {
20695                head_seq: None,
20696                commit_hash: None,
20697                view_kind: None,
20698                view_revision: None,
20699            },
20700            next: V2PullCoordinate {
20701                head_seq: Some(1),
20702                commit_hash: Some("a".repeat(64)),
20703                view_kind: Some("full".to_string()),
20704                view_revision: Some("b".repeat(64)),
20705            },
20706            backup_dir: format!(".dbmd/pull-backup-{}", crate::ulid::mint()),
20707            entries: vec![V2PullJournalEntry {
20708                path: "sources/media/large.mov".to_string(),
20709                old: None,
20710                new: Some(V2PullFileCoordinate {
20711                    sha256: "c".repeat(64),
20712                    bytes,
20713                }),
20714                backup: None,
20715            }],
20716        };
20717
20718        validate_v2_pull_journal(&coordinate(MAX_STORE_BYTES + 1)).unwrap();
20719        assert!(validate_v2_pull_journal(&coordinate(MAX_PULL_TRANSACTION_BYTES + 1)).is_err());
20720    }
20721
20722    #[test]
20723    fn ready_pull_journal_rolls_back_exact_preimages() {
20724        let sandbox = tempfile::TempDir::new().unwrap();
20725        let root = sandbox.path().join("brain");
20726        std::fs::create_dir_all(root.join("records")).unwrap();
20727        std::fs::write(
20728            root.join("DB.md"),
20729            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20730        )
20731        .unwrap();
20732        let path = "records/value.md";
20733        let old = b"---\ntype: note\n---\n\nold\n";
20734        let new = b"---\ntype: note\n---\n\nnew\n";
20735        std::fs::write(root.join(path), old).unwrap();
20736        let store = Store::open_strict(&root).unwrap();
20737        let bundle = crate::ulid::mint();
20738        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20739        store
20740            .create_private_dir_all(Path::new(&backup_dir))
20741            .unwrap();
20742        store
20743            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
20744            .unwrap();
20745        let journal = V2PullJournal {
20746            v: 1,
20747            phase: V2PullPhase::Ready,
20748            brain: TEST_BRAIN_ID.to_string(),
20749            previous: V2PullCoordinate {
20750                head_seq: None,
20751                commit_hash: None,
20752                view_kind: None,
20753                view_revision: None,
20754            },
20755            next: V2PullCoordinate {
20756                head_seq: Some(2),
20757                commit_hash: Some("c".repeat(64)),
20758                view_kind: Some("full".to_string()),
20759                view_revision: Some("d".repeat(64)),
20760            },
20761            backup_dir: backup_dir.clone(),
20762            entries: vec![V2PullJournalEntry {
20763                path: path.to_string(),
20764                old: Some(V2PullFileCoordinate {
20765                    sha256: content_sha256(old),
20766                    bytes: old.len() as u64,
20767                }),
20768                new: Some(V2PullFileCoordinate {
20769                    sha256: content_sha256(new),
20770                    bytes: new.len() as u64,
20771                }),
20772                backup: Some("00000000".to_string()),
20773            }],
20774        };
20775        validate_v2_pull_journal(&journal).unwrap();
20776        store
20777            .write_private_atomic_new(
20778                Path::new(V2_PULL_JOURNAL),
20779                &v2_pull_journal_bytes(&journal).unwrap(),
20780            )
20781            .unwrap();
20782        store.write_atomic(Path::new(path), new).unwrap();
20783
20784        let cfg = test_hub_config(
20785            "https://example.test".to_string(),
20786            sandbox.path().join("state"),
20787        );
20788        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20789        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
20790        assert!(!root.join(V2_PULL_JOURNAL).exists());
20791        assert!(!root.join(backup_dir).exists());
20792    }
20793
20794    #[test]
20795    fn preparing_pull_journal_discards_only_private_staging() {
20796        let sandbox = tempfile::TempDir::new().unwrap();
20797        let root = sandbox.path().join("brain");
20798        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
20799        std::fs::write(
20800            root.join("DB.md"),
20801            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20802        )
20803        .unwrap();
20804        let store = Store::open_strict(&root).unwrap();
20805        let bundle = crate::ulid::mint();
20806        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20807        store
20808            .create_private_dir_all(Path::new(&backup_dir))
20809            .unwrap();
20810        let journal = V2PullJournal {
20811            v: 1,
20812            phase: V2PullPhase::Preparing,
20813            brain: TEST_BRAIN_ID.to_string(),
20814            previous: V2PullCoordinate {
20815                head_seq: None,
20816                commit_hash: None,
20817                view_kind: None,
20818                view_revision: None,
20819            },
20820            next: V2PullCoordinate {
20821                head_seq: Some(1),
20822                commit_hash: Some("a".repeat(64)),
20823                view_kind: Some("full".to_string()),
20824                view_revision: Some("b".repeat(64)),
20825            },
20826            backup_dir: backup_dir.clone(),
20827            entries: vec![V2PullJournalEntry {
20828                path: "records/new.md".to_string(),
20829                old: None,
20830                new: Some(V2PullFileCoordinate {
20831                    sha256: "c".repeat(64),
20832                    bytes: 1,
20833                }),
20834                backup: None,
20835            }],
20836        };
20837        store
20838            .write_private_atomic_new(
20839                Path::new(V2_PULL_JOURNAL),
20840                &v2_pull_journal_bytes(&journal).unwrap(),
20841            )
20842            .unwrap();
20843        let cfg = test_hub_config(
20844            "https://example.test".to_string(),
20845            sandbox.path().join("state"),
20846        );
20847
20848        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20849
20850        assert!(root.join("DB.md").is_file());
20851        assert!(!root.join(V2_PULL_JOURNAL).exists());
20852        assert!(!root.join(backup_dir).exists());
20853    }
20854
20855    #[test]
20856    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
20857        let sandbox = tempfile::TempDir::new().unwrap();
20858        let root = sandbox.path().join("brain");
20859        std::fs::create_dir_all(root.join("records")).unwrap();
20860        std::fs::write(
20861            root.join("DB.md"),
20862            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20863        )
20864        .unwrap();
20865        let new = b"---\ntype: note\n---\n\nnew\n";
20866        std::fs::write(root.join("records/value.md"), new).unwrap();
20867        let store = Store::open_strict(&root).unwrap();
20868        let bundle = crate::ulid::mint();
20869        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20870        store
20871            .create_private_dir_all(Path::new(&backup_dir))
20872            .unwrap();
20873        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
20874        store.create_private_dir_all(Path::new(&orphan)).unwrap();
20875        let next = V2PullCoordinate {
20876            head_seq: Some(2),
20877            commit_hash: Some("c".repeat(64)),
20878            view_kind: Some("full".to_string()),
20879            view_revision: Some("d".repeat(64)),
20880        };
20881        let journal = V2PullJournal {
20882            v: 1,
20883            phase: V2PullPhase::Ready,
20884            brain: TEST_BRAIN_ID.to_string(),
20885            previous: V2PullCoordinate {
20886                head_seq: Some(1),
20887                commit_hash: Some("a".repeat(64)),
20888                view_kind: Some("full".to_string()),
20889                view_revision: Some("b".repeat(64)),
20890            },
20891            next: next.clone(),
20892            backup_dir: backup_dir.clone(),
20893            entries: vec![V2PullJournalEntry {
20894                path: "records/value.md".to_string(),
20895                old: Some(V2PullFileCoordinate {
20896                    sha256: "e".repeat(64),
20897                    bytes: new.len() as u64,
20898                }),
20899                new: Some(V2PullFileCoordinate {
20900                    sha256: content_sha256(new),
20901                    bytes: new.len() as u64,
20902                }),
20903                backup: Some("00000000".to_string()),
20904            }],
20905        };
20906        store
20907            .write_private_atomic_new(
20908                Path::new(V2_PULL_JOURNAL),
20909                &v2_pull_journal_bytes(&journal).unwrap(),
20910            )
20911            .unwrap();
20912        let cfg = test_hub_config(
20913            "https://example.test".to_string(),
20914            sandbox.path().join("state"),
20915        );
20916        save_v2_baseline(
20917            &cfg,
20918            TEST_BRAIN_ID,
20919            &root,
20920            &V2SyncBaseline {
20921                v: 2,
20922                origin: "https://example.test".to_string(),
20923                brain: TEST_BRAIN_ID.to_string(),
20924                checkout_id: Some("c".repeat(64)),
20925                head_seq: next.head_seq,
20926                commit_hash: next.commit_hash.clone(),
20927                content_root: Some("f".repeat(64)),
20928                asset_root: None,
20929                assets: Default::default(),
20930                view_kind: next.view_kind.clone(),
20931                view_revision: next.view_revision.clone(),
20932                control_revision: Some("d".repeat(64)),
20933                projection_sha256: None,
20934                files: Default::default(),
20935                scan_cache: Default::default(),
20936                local_policy_digest: None,
20937                local_eligibility: Default::default(),
20938                remote_copy_remains: Default::default(),
20939            },
20940        )
20941        .unwrap();
20942
20943        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20944
20945        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
20946        assert!(!root.join(V2_PULL_JOURNAL).exists());
20947        assert!(!root.join(backup_dir).exists());
20948        assert!(!root.join(orphan).exists());
20949    }
20950
20951    #[test]
20952    fn only_typed_validation_projection_lag_retries_v2_head() {
20953        let typed = HubResponse {
20954            status: 422,
20955            body: Some(json!({ "code": "validation_index_catching_up" })),
20956        };
20957        let nested = HubResponse {
20958            status: 422,
20959            body: Some(json!({
20960                "details": { "code": "validation_index_catching_up" }
20961            })),
20962        };
20963        let unrelated = HubResponse {
20964            status: 422,
20965            body: Some(json!({ "code": "source_immutable" })),
20966        };
20967        let wrong_status = HubResponse {
20968            status: 403,
20969            body: typed.body.clone(),
20970        };
20971        assert!(v2_validation_catching_up(&typed));
20972        assert!(v2_validation_catching_up(&nested));
20973        assert!(!v2_validation_catching_up(&unrelated));
20974        assert!(!v2_validation_catching_up(&wrong_status));
20975    }
20976
20977    #[test]
20978    fn asset_resolution_is_limited_to_explicitly_resolved_wrappers() {
20979        let wrapper = "records/operational/package.md".to_string();
20980        let mut resolution = std::collections::BTreeMap::new();
20981        resolution.insert(
20982            wrapper.clone(),
20983            V2ResolutionOverride {
20984                expected_remote: Some("a".repeat(64)),
20985                selected_local: Some("b".repeat(64)),
20986            },
20987        );
20988        let local = crate::AssetRecord {
20989            path: "sources/package/object.blob".to_string(),
20990            sha256: "c".repeat(64),
20991            bytes: 1,
20992            media_type: "application/octet-stream".to_string(),
20993            wrappers: vec![wrapper.clone()],
20994            required: true,
20995        };
20996        assert!(v2_resolution_allows_asset(
20997            Some(&resolution),
20998            None,
20999            None,
21000            Some(&local),
21001        ));
21002        let unrelated = crate::AssetRecord {
21003            wrappers: vec!["records/unrelated.md".to_string()],
21004            ..local
21005        };
21006        assert!(!v2_resolution_allows_asset(
21007            Some(&resolution),
21008            None,
21009            None,
21010            Some(&unrelated),
21011        ));
21012        assert!(!v2_resolution_allows_asset(
21013            None,
21014            None,
21015            None,
21016            Some(&unrelated),
21017        ));
21018    }
21019}