Skip to main content

dbmd_core/
linkmd.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The **link.md client** — the five interconnect verbs `dbmd` speaks against
4//! a hub: `resolve`, `sync`, `grant`, `propose`, `subscribe`.
5//!
6//! One binary, two specs (the git precedent: one binary carries both the
7//! object format and the wire protocol). The db.md FORMAT is untouched by this
8//! module: a store never needs link.md to be valid db.md, record files stay
9//! plain markdown, and SPEC.md reserves only the `@brain/id` address *shape*.
10//! Everything with a wire or a trust boundary — addressing across stores,
11//! pulling/pushing a hosted copy, capability grants, the propose door, feed
12//! polling and signed-entry verification — lives here, as a *client
13//! capability*, never a format requirement.
14//!
15//! # What this client speaks
16//!
17//! The v0 HTTP binding a hub serves under its base URL:
18//!
19//! | verb | binding |
20//! | --- | --- |
21//! | `resolve` | `GET /api/hub/brains/<brain>` (the brain card); records are read from the exact signed snapshot pack named by that card's verified feed head |
22//! | `sync` (pull) | `GET /api/hub/brains/<brain>/export?format=pack&atSeq=<n>&feedHash=<hash>` — the exact verified snapshot |
23//! | `sync` (push) | `POST /api/hub/brains/<brain>/push` for small snapshots; presign/upload/commit for large snapshots |
24//! | `grant` | `GET` / `POST /api/hub/brains/<brain>/grants`, `DELETE /api/hub/brains/<brain>/grants/<id>` |
25//! | `propose` | `POST /api/hub/sites/<handle>/inbox` — evidence in, without trust (unauthenticated by design) |
26//! | `subscribe` | `GET /api/hub/brains/<brain>` + `/feed` for a locally verified signed head |
27//!
28//! # Configuration — no default hub, credential never in the store
29//!
30//! There is **no built-in hub endpoint**: the toolkit is neutral and a hub is
31//! whatever the user points it at. Resolution order for the hub URL:
32//!
33//! 1. the `--hub <URL>` flag,
34//! 2. the `DBMD_HUB_URL` environment variable,
35//! 3. the `hub = <URL>` line in the store-local `.dbmd/config` file
36//!    (toolkit state, not store content — the walkers already skip hidden
37//!    directories, so `.dbmd/` never syncs, indexes, or validates).
38//!
39//! The credential is the `DBMD_HUB_KEY` environment variable, full stop. It is
40//! deliberately **not** read from `.dbmd/config`: a secret inside the store
41//! tree is one commit or one push away from leaking. A store-selected hub may
42//! receive an ambient bearer or agent key only when
43//! `DBMD_HUB_CREDENTIAL_ORIGIN` binds it to that exact origin. Identity pins
44//! and monotonic feed checkpoints live in the user's global state directory,
45//! never in store-controlled `.dbmd/`.
46//!
47//! Non-HTTPS hubs are refused (the bearer key must never travel in cleartext)
48//! with a loopback exemption for local development.
49//!
50//! # v0 honesty
51//!
52//! This client binds to what a hub enforces **today**: grantees are hub
53//! principals (an email), grant scopes are store-path prefixes, pushes are
54//! whole-store snapshots, and `subscribe` reports feed-head movement. The hub
55//! signs each committed snapshot in a hash-chained feed with a per-brain
56//! Ed25519 identity. This client verifies the rotation chain, signer epochs,
57//! monotonic feed checkpoint, snapshot token, and content-addressed pack before
58//! untrusted bytes touch their destination.
59
60use std::collections::{BTreeMap, BTreeSet};
61use std::io::{Cursor, Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{SystemTime, UNIX_EPOCH};
64
65use base64::{
66    engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
67    Engine as _,
68};
69use ring::signature::{UnparsedPublicKey, ED25519};
70use serde::{Deserialize, Serialize};
71use serde_json::{json, Value};
72use sha2::{Digest, Sha256};
73
74use crate::store::Store;
75
76/// Environment variable naming the hub base URL (e.g. `https://hub.example.com`).
77pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
78
79/// Environment variable carrying the hub bearer credential. The bearer
80/// credential source — see the module docs for why it is never store-based.
81pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
82
83/// Explicit origin binding required before an ambient bearer or agent key may
84/// be sent to a hub selected by untrusted store-local configuration. The value
85/// is an origin (`https://host[:port]`), not an arbitrary URL path.
86pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
87
88/// Override for dbmd's user-owned trust/checkpoint root. Must be absolute.
89/// Intended for managed installations and hermetic tests; untrusted stores
90/// cannot select it.
91pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
92
93/// Explicit test/development escape hatch for registry homes on loopback or
94/// private networks. Production federation rejects every non-public resolved
95/// address and pins the validated DNS answer into the HTTP agent.
96pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
97
98/// Explicit development escape hatch for object-store URLs on loopback/private
99/// networks. Production hubs must return public HTTPS URLs. A local loopback hub
100/// is allowed to use local object URLs without this switch.
101pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
102
103/// Environment variable naming the PATH of a self-custodied BRAIN key file
104/// (link.md §2.4). When set, `sync --push` signs each feed entry locally and
105/// ships it through the pack flow — the hub verifies and stores the exact
106/// client bytes and can never sign for the brain. Same file format as agent
107/// keys (`dbmd key generate`).
108pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
109
110/// Environment variable naming the PATH of an agent signing key file
111/// (link.md §8 `LinkMD-Sig` proof of possession). When set, authenticated
112/// requests are signed per-request with the agent's Ed25519 key instead of
113/// carrying a bearer: the signature binds method + path + body + a ±60s
114/// window, so nothing reusable ever crosses the wire or lands in a log or an
115/// agent transcript. The file holds the base64url PKCS#8 key minted by
116/// `dbmd key generate`; the path is not a secret, the file is (mode 0600).
117pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
118
119/// The store-local config file, relative to the store root. Holds non-secret
120/// toolkit state (`hub = <URL>`); hidden, so every store walk skips it.
121pub const CONFIG_REL_PATH: &str = ".dbmd/config";
122
123/// Control-plane JSON is metadata, never the store itself. Exact snapshot
124/// bytes travel through the separately bounded pack lane.
125const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
126/// A feed page can legitimately carry a large snapshot manifest, but remains
127/// far below a full pack and is parsed with count-bounded sequence visitors.
128const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
129/// Foreign registry cards are identity metadata only.
130const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
131
132/// Direct JSON pushes stay below the serverless request-body cap. Larger
133/// snapshots switch to the bounded object-store pack lane.
134const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
135/// Ceiling for a staged change manifest. Mirrors the hub's bound so an
136/// oversized push is refused before it stages anything.
137const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
138
139/// Canonical raw ZIP32 uses a u16 entry count and stores each UTF-8 name twice.
140const MAX_PUSH_FILES: usize = u16::MAX as usize;
141const MAX_STORE_PATH_BYTES: usize = 1_024;
142const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
143/// Assets travel outside the Markdown store/pack lane and have their own
144/// protocol ceiling. Keep this distinct from `MAX_STORE_BYTES`: applying the
145/// pack bound to a signed asset leaf makes an otherwise valid checkout unable
146/// to load its own saved sync baseline after the first commit.
147const MAX_ASSET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
148/// Exact worst case for the canonical STORED profile:
149/// payload + (local 30 + central 46 + name twice) per entry + EOCD 22.
150const MAX_PACK_BYTES: u64 =
151    MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
152/// An upload reservation body carries one entry per changed blob, so a real
153/// store's first push does not fit in a single request: the hub bounds the body
154/// and the hosting platform rejects an oversized payload before the route sees
155/// it. Reservations are therefore batched. The budget is deliberately well
156/// under the hub's own ceiling, and both bounds are enforced because a single
157/// hash can carry many coordinates and a path may be long.
158/// The count matches the reservation window a hub grants per request; batching
159/// above it only fails deeper in, after the body has already been accepted.
160const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
161const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
162
163/// A legitimate brain should rotate rarely. Bound adversarial identity
164/// histories before allocating and repeatedly verifying an unbounded chain.
165const MAX_IDENTITY_ROTATIONS: usize = 1_024;
166
167/// Split blob declarations into request-sized batches, by serialized size and
168/// by count. Order is preserved so a caller can concatenate the responses.
169fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
170    let mut batches: Vec<Vec<Value>> = Vec::new();
171    let mut current: Vec<Value> = Vec::new();
172    let mut current_bytes = 0usize;
173    for declaration in declarations {
174        let declared_bytes = serde_json::to_string(&declaration)
175            .map(|text| text.len())
176            .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
177            + 1;
178        if !current.is_empty()
179            && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
180                || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
181        {
182            batches.push(std::mem::take(&mut current));
183            current_bytes = 0;
184        }
185        current_bytes += declared_bytes;
186        current.push(declaration);
187    }
188    if !current.is_empty() {
189        batches.push(current);
190    }
191    batches
192}
193/// A client never needs to replay an unbounded feed in one invocation. Large
194/// histories are mirrored incrementally; a first mirror beyond this cap needs a
195/// checkpoint/export rather than allocating attacker-controlled metadata.
196const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
197const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
198const FEED_PAGE_LIMIT: usize = 100;
199
200/// The hub's inbox cap on one `propose` submission body, mirrored client-side
201/// so an oversized body fails before the upload, not after (the same
202/// fail-before-upload contract as the push caps). Public so the CLI can
203/// pre-check a `--body-file` from file metadata without reading it.
204pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
205
206/// Bounded connect so a dead hub fails fast; a generous read window so a
207/// large export on a slow link still completes.
208const CONNECT_TIMEOUT_SECS: u64 = 10;
209const READ_TIMEOUT_SECS: u64 = 120;
210/// Hard wall-clock budget for one HTTP attempt, spanning socket writes,
211/// response headers, and the complete response body. DNS performed for pinned
212/// agents has its own interruptible deadline below.
213const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
214/// A commit of a whole store is brain-sized work the hub is allowed to spend
215/// its full route budget on, so the client has to outwait the server rather
216/// than the other way round. At the default 120 seconds a first push of a real
217/// store abandoned a commit the hub was still completing and reported a
218/// transport failure for work that had not failed. This sits above the hub's
219/// own genesis-commit ceiling so the client always learns the outcome.
220const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
221/// A commit is idempotent by mutation coordinate, so a lost response is worth
222/// asking after — and the work it lost the receipt for may still be finishing,
223/// which is why the waits are long enough to let a brain-sized commit land.
224const COMMIT_ATTEMPTS: usize = 4;
225const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
226const CONNECT_ATTEMPTS: usize = 3;
227const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
228/// Safe reads are idempotent, including authenticated GETs and the explicitly
229/// marked read-only POSTs whose proof lists do not fit in a URL. Retry the
230/// whole read when a connection dies after headers or part-way through its
231/// body; connect-only retries cannot recover that common large-export failure.
232const SAFE_READ_ATTEMPTS: usize = 4;
233const SAFE_READ_RETRY_BACKOFF_MS: [u64; SAFE_READ_ATTEMPTS - 1] = [200, 1_000, 3_000];
234
235/// Staging a real store means tens of thousands of immutable object PUTs in a
236/// row. A single refusal under sustained load must not fail the migration, so
237/// the upload lane retries further and waits longer than a connect blip does.
238const UPLOAD_ATTEMPTS: usize = 6;
239const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
240/// Retries share one wall-clock budget. Without this outer deadline, one
241/// black-holed object can consume the per-attempt timeout six times, outlive
242/// its presigned capability, and make a large migration appear hung.
243const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
244
245fn upload_retry_backoff_ms(attempt: usize) -> u64 {
246    UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
247}
248
249fn upload_deadline_error() -> LinkError {
250    LinkError::Transport {
251        hub: "the object store".to_string(),
252        message: "network error (upload deadline exceeded)".to_string(),
253    }
254}
255
256fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
257    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
258    if remaining.is_zero() {
259        return Err(upload_deadline_error());
260    }
261    Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
262}
263
264fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
265    if attempt + 1 >= UPLOAD_ATTEMPTS {
266        return false;
267    }
268    let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
269    if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
270        return false;
271    }
272    std::thread::sleep(pause);
273    true
274}
275
276/// A migration opens dozens of staging windows back to back, so it is ordinary
277/// for a shared limiter or a briefly busy hub to answer one of them with 429 or
278/// 5xx. Failing the push there throws away every byte already staged, and the
279/// waits have to reach past a whole limiter window to be worth anything.
280const RESERVATION_ATTEMPTS: usize = 7;
281const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
282    [500, 2_000, 5_000, 15_000, 30_000, 60_000];
283
284/// Answers a busy or briefly unhealthy hub gives, as opposed to a refusal that
285/// would repeat identically. A 4xx other than these states something about the
286/// request itself and is never retried.
287fn is_retryable_hub_status(status: u16) -> bool {
288    matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
289}
290
291/// Statuses worth replaying an immutable, content-addressed upload for: edge
292/// and object-store refusals that are load-shaped rather than a statement about
293/// these bytes. A checksum or precondition failure is never retried.
294fn is_retryable_upload_status(status: u16) -> bool {
295    matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
296}
297/// Cold checkouts may need every visible blob, but short-lived direct object
298/// capabilities must not create an unbounded connection storm. Keep the
299/// verified downloads inside this fixed worker count.
300const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
301/// File durability barriers are independent inside the private pull stage.
302/// Keep enough in flight to hide storage flush latency without approaching
303/// ordinary process descriptor limits.
304#[cfg(unix)]
305const V2_PULL_INSTALL_WORKERS: usize = 16;
306/// One authenticated link.md v2 bulk frame remains small enough for bounded
307/// retries and serverless streaming while amortizing tens of thousands of
308/// immutable blobs over a few hundred requests instead of one request each.
309const V2_BULK_STREAM_FILES: usize = 256;
310const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
311const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
312const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
313/// A read capability lives for five minutes. Ask only for the files the
314/// bounded worker pool can start immediately; collecting hundreds of signed
315/// asset URLs before consuming the first one deterministically expires the
316/// oldest capabilities on a company-sized brain.
317const V2_DOWNLOAD_CAPABILITY_FILES: usize = V2_BLOB_DOWNLOAD_WORKERS;
318const V2_DOWNLOAD_CAPABILITY_BYTES: u64 = 512 * 1024 * 1024;
319const V2_DOWNLOAD_CAPABILITY_ATTEMPTS: usize = 4;
320const V2_DOWNLOAD_CAPABILITY_BACKOFF_MS: [u64; V2_DOWNLOAD_CAPABILITY_ATTEMPTS - 1] =
321    [200, 1_000, 3_000];
322
323/// Everything that can go wrong on the wire or at its edges. Each variant maps
324/// onto one stable CLI error code; messages are single-line and never echo the
325/// credential.
326#[derive(Debug, thiserror::Error)]
327pub enum LinkError {
328    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
329    #[error(
330        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
331    )]
332    NoHub,
333
334    /// The verb needs a credential and none was present.
335    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
336    NoCredential,
337
338    /// The credential contains whitespace / non-ASCII (a paste artifact). The
339    /// key is deliberately not echoed.
340    #[error(
341        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
342    )]
343    BadKey,
344
345    /// A store selected the destination while an ambient credential was
346    /// present, but the operator did not bind that credential to the same
347    /// origin. This is a hard refusal, not an anonymous fallback: silently
348    /// dropping a credential can turn an intended private operation into a
349    /// confusing public one.
350    #[error(
351        "refusing to send an ambient credential to the hub selected by {CONFIG_REL_PATH} — set {HUB_CREDENTIAL_ORIGIN_ENV} to that exact origin, or choose the hub explicitly with --hub/{HUB_URL_ENV}"
352    )]
353    UnboundCredential,
354
355    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
356    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
357    /// echoed.
358    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
359    BadAgentKey {
360        /// What failed, without any key material.
361        message: String,
362    },
363
364    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
365    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
366    UnsafeHub {
367        /// The offending hub URL.
368        hub: String,
369    },
370
371    /// TCP/TLS-level failure: the hub never answered.
372    #[error("hub unreachable at {hub}: {message}")]
373    Transport {
374        /// The hub base URL.
375        hub: String,
376        /// The transport-layer error text.
377        message: String,
378    },
379
380    /// The hub answered with an HTTP error status.
381    #[error("{what} failed (HTTP {status}): {message}")]
382    Http {
383        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
384        what: &'static str,
385        /// The HTTP status code.
386        status: u16,
387        /// The hub's own `error` string when it sent one, else a placeholder.
388        message: String,
389        /// The hub's machine `code` field when it sent one.
390        code: Option<String>,
391        /// Permission-filtered structured refusal details, when supplied.
392        details: Option<Value>,
393    },
394
395    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
396    /// URL — refused here rather than deserializing into nothing downstream.
397    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
398    NotJson {
399        /// What the client was doing.
400        what: &'static str,
401        /// The (2xx) status that carried the non-JSON body.
402        status: u16,
403    },
404
405    /// The hub response exceeded the selected endpoint's byte cap.
406    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
407    ResponseTooLarge {
408        /// The endpoint-specific cap applied before JSON parsing.
409        limit_bytes: u64,
410    },
411
412    /// A malformed `@brain/id` address.
413    #[error("invalid address `{given}`: {reason}")]
414    BadAddress {
415        /// The raw address as typed.
416        given: String,
417        /// Why it did not parse.
418        reason: String,
419    },
420
421    /// A grant id whose shape cannot travel as a URL path segment.
422    #[error(
423        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
424    )]
425    BadGrantId {
426        /// The raw id as typed.
427        given: String,
428    },
429
430    /// An exported file path that would escape or pollute the destination
431    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
432    /// hub is not trusted with local path layout.
433    #[error("refusing unsafe path from the hub: `{path}`")]
434    UnsafePath {
435        /// The offending path as received.
436        path: String,
437    },
438
439    /// The store exceeds the hub's bounded whole-snapshot caps.
440    #[error(
441        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
442        MAX_STORE_BYTES / (1024 * 1024),
443        MAX_PACK_BYTES / (1024 * 1024)
444    )]
445    PushTooLarge {
446        /// Which cap was hit, human-readable.
447        detail: String,
448    },
449
450    /// The propose body exceeds the hub's inbox cap.
451    #[error(
452        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
453        MAX_PROPOSE_BYTES / 1024
454    )]
455    ProposeTooLarge {
456        /// The offending body size in bytes.
457        bytes: u64,
458    },
459
460    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
461    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
462    NotUtf8 {
463        /// The store-relative path of the offending file.
464        path: String,
465    },
466
467    /// A downloaded pack failed validation before any local write.
468    #[error("invalid store pack: {message}")]
469    InvalidPack {
470        /// Hash, ZIP, path, count, or expansion failure.
471        message: String,
472    },
473
474    /// A signed feed entry, hash chain, or advertised feed head did not verify.
475    #[error("invalid signed feed: {message}")]
476    InvalidFeed {
477        /// The failed integrity condition, without untrusted secret material.
478        message: String,
479    },
480
481    /// A mutable alias moved to another canonical brain. Ordinary sync fails
482    /// closed; only the exact old/new ids can authorize replacing that one
483    /// alias binding while preserving both canonical trust histories.
484    #[error(
485        "brain alias `{alias}` was pinned to `{from}` but now resolves to `{to}` — review both ids, then run `dbmd sync {alias} rebind --from {from} --to {to}`"
486    )]
487    AliasRebindRequired {
488        alias: String,
489        from: String,
490        to: String,
491    },
492
493    /// Local and remote both changed one or more coordinates since the last
494    /// verified sync baseline. No side was overwritten.
495    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
496    Conflict {
497        /// Bounded, portable paths safe to show to the agent/operator.
498        paths: Vec<String>,
499    },
500
501    /// A readable v2 conflict was preserved as a private, exact-head bundle.
502    /// The bundle is local control state; none of its bytes ride with the
503    /// brain, and resolving it always creates a fresh explicit mutation.
504    #[error(
505        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
506    )]
507    ConflictBundle {
508        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
509        bundle: String,
510        /// Bounded, readable conflict paths.
511        paths: Vec<String>,
512    },
513
514    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
515    /// explicit adoption boundary, never an accidental side effect of editing
516    /// or removing a local policy file.
517    #[error(
518        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
519    )]
520    LocalPolicyTransition {
521        /// Bounded local-only paths. They are never sent in telemetry.
522        paths: Vec<String>,
523    },
524
525    /// The exact mutation crosses a permissioned bulk-impact boundary. The
526    /// hub has not committed it; `preview` is the bounded, permission-filtered
527    /// receipt an agent must inspect before explicitly confirming the same
528    /// request.
529    #[error(
530        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
531    )]
532    BulkPreviewRequired {
533        /// Stable structured preview returned by the hub.
534        preview: Value,
535    },
536
537    /// A scoped checkout's generated store marker was edited or removed.
538    /// It is local projection metadata and is never accepted as brain data.
539    #[error(
540        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
541    )]
542    ScopedProjectionModified,
543
544    /// The effective permission slice changed after this checkout was pinned.
545    /// Reusing the directory could conceal removals or accidentally adopt files
546    /// revealed by a wider grant, so a new checkout is required.
547    #[error(
548        "the checkout's permission scope changed — clone into a new directory to accept the new view"
549    )]
550    ScopedViewChanged,
551
552    /// A v2 identity/head was previously accepted for this ref, but the hub
553    /// now hides it. Never reinterpret that as a v1 downgrade.
554    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
555    BrainUnavailable,
556
557    /// The verified remote head advanced while a pull or post-commit barrier
558    /// was in flight. The old baseline is deliberately retained.
559    #[error(
560        "the remote brain advanced during sync — retry to converge from the new verified head"
561    )]
562    RemoteAdvancedDuringSync,
563
564    /// This build cannot provide the no-follow, directory-handle-relative
565    /// filesystem semantics required for trust/key/snapshot state.
566    #[error(
567        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
568    )]
569    UnsupportedPlatform {
570        /// The operation that requires hardened local filesystem primitives.
571        operation: &'static str,
572    },
573
574    /// Local filesystem failure while materializing a pull or reading a push.
575    #[error(transparent)]
576    Io(#[from] std::io::Error),
577
578    /// A store-level failure (walking the local store for a push).
579    #[error(transparent)]
580    Store(#[from] crate::StoreError),
581}
582
583/// Result alias for link.md client operations.
584pub type LinkResult<T> = std::result::Result<T, LinkError>;
585
586/// Exact approval token returned by a permissioned v2 bulk preview.
587#[derive(Debug, Clone, PartialEq, Eq)]
588pub struct V2BulkConfirmation {
589    /// Opaque preview receipt id.
590    pub id: String,
591    /// Digest binding the receipt to principal, head, control revision, and
592    /// the exact strict mutation.
593    pub digest: String,
594}
595
596impl V2BulkConfirmation {
597    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
598    /// outside the protocol's exact lowercase shapes before network I/O.
599    pub fn parse(value: &str) -> LinkResult<Self> {
600        let (id, digest) = value
601            .split_once(':')
602            .ok_or_else(|| LinkError::InvalidPack {
603                message: "bulk confirmation must be <id>:<digest>".to_string(),
604            })?;
605        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
606            return Err(LinkError::InvalidPack {
607                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
608                    .to_string(),
609            });
610        }
611        Ok(Self {
612            id: id.to_string(),
613            digest: digest.to_string(),
614        })
615    }
616}
617
618/// Security-sensitive link.md state and destination writes require a hardened
619/// descriptor/handle-relative filesystem implementation. Unix uses
620/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
621/// refusal, exclusive share modes, and write-through replacement.
622fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
623    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
624    {
625        let _ = operation;
626        Ok(())
627    }
628    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
629    {
630        Err(LinkError::UnsupportedPlatform { operation })
631    }
632}
633
634// ─────────────────────────────────────────────────────────────────────────────
635// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
636// ─────────────────────────────────────────────────────────────────────────────
637
638/// What the part after `@brain/` names.
639#[derive(Debug, Clone, PartialEq, Eq)]
640pub enum AddressTarget {
641    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
642    Id(String),
643    /// A store-relative `.md` path — a client-side convenience the hub's
644    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
645    /// shape; unambiguous because a ULID is never a path.
646    Path(String),
647}
648
649/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
650/// and the per-verb entry gates so the two surfaces never drift.
651const BAD_BRAIN_REASON: &str =
652    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
653
654/// Why an address target failed its shape check — shared by [`Address::parse`]
655/// and the [`resolve`] entry gate.
656const BAD_TARGET_REASON: &str =
657    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
658
659/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
660/// brain's ULID id (works for any caller, including cross-party on a public
661/// brain) or a slug (which a hub resolves only against the caller's own
662/// brains; slugs are unique per owner, not globally).
663#[derive(Debug, Clone, PartialEq, Eq)]
664pub struct Address {
665    /// The brain reference (leading `@` stripped).
666    pub brain: String,
667    /// The record target, when the address names one.
668    pub target: Option<AddressTarget>,
669}
670
671impl Address {
672    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
673    /// sigil is optional (an agent piping ids around should not have to quote
674    /// it back on). Whitespace and empty segments are malformed.
675    pub fn parse(raw: &str) -> LinkResult<Address> {
676        let bad = |reason: &str| LinkError::BadAddress {
677            given: raw.to_string(),
678            reason: reason.to_string(),
679        };
680
681        let trimmed = raw.trim();
682        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
683        if body.is_empty() {
684            return Err(bad("empty address"));
685        }
686
687        let (brain, rest) = match body.split_once('/') {
688            Some((b, r)) => (b, Some(r)),
689            None => (body, None),
690        };
691
692        if brain.is_empty() {
693            return Err(bad("missing brain reference before `/`"));
694        }
695        if !is_safe_ref(brain) {
696            return Err(bad(BAD_BRAIN_REASON));
697        }
698
699        let target = match rest {
700            None => None,
701            Some("") => return Err(bad("trailing `/` with no record id or path")),
702            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
703            Some(r) => {
704                if !safe_store_rel_path(r) || !r.ends_with(".md") {
705                    return Err(bad(BAD_TARGET_REASON));
706                }
707                Some(AddressTarget::Path(r.to_string()))
708            }
709        };
710
711        Ok(Address {
712            brain: brain.to_string(),
713            target,
714        })
715    }
716}
717
718/// A brain reference safe to embed in a URL path segment: the shapes a hub
719/// accepts (ULID id or slug), which are also exactly URL-path-clean.
720fn is_safe_ref(s: &str) -> bool {
721    !s.is_empty()
722        && s.len() <= 64
723        && s.bytes()
724            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
725}
726
727/// A published-site handle (the `propose` target). Same lexical shape as a
728/// slug.
729pub fn is_valid_handle(s: &str) -> bool {
730    is_safe_ref(s)
731}
732
733/// True when `p` is a store-relative path this client will read from or write
734/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
735/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
736/// every path an export hands us (the hub is not trusted with local layout)
737/// and to every path a push sends (mirroring the hub's own gate).
738pub fn safe_store_rel_path(p: &str) -> bool {
739    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
740        return false;
741    }
742    if !p
743        .bytes()
744        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
745    {
746        return false;
747    }
748    p.split('/')
749        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
750}
751
752/// Entry gate for every verb that embeds a caller-supplied brain reference in
753/// a URL path segment. `resolve` reaches the same check through
754/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
755/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
756/// URL-reshaping byte is refused before a request exists (the `url` crate
757/// normalizes dot segments, so an unvalidated ref would redirect the
758/// authenticated request to a different hub path).
759fn require_safe_ref(brain: &str) -> LinkResult<()> {
760    if is_safe_ref(brain) {
761        Ok(())
762    } else {
763        Err(LinkError::BadAddress {
764            given: brain.to_string(),
765            reason: BAD_BRAIN_REASON.to_string(),
766        })
767    }
768}
769
770/// Entry gate for the published-site handle `propose` embeds in its URL path.
771fn require_valid_handle(handle: &str) -> LinkResult<()> {
772    if is_valid_handle(handle) {
773        Ok(())
774    } else {
775        Err(LinkError::BadAddress {
776            given: handle.to_string(),
777            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
778        })
779    }
780}
781
782/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
783/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
784/// shape as a brain ref rather than pinning one mint scheme.
785fn require_safe_grant_id(id: &str) -> LinkResult<()> {
786    if is_safe_ref(id) {
787        Ok(())
788    } else {
789        Err(LinkError::BadGrantId {
790            given: id.to_string(),
791        })
792    }
793}
794
795// ─────────────────────────────────────────────────────────────────────────────
796// Configuration — flag > env > .dbmd/config; credential from env only
797// ─────────────────────────────────────────────────────────────────────────────
798
799/// The resolved client configuration for one invocation.
800#[derive(Debug, Clone)]
801pub struct HubConfig {
802    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
803    pub hub: String,
804    /// The bearer credential, when the environment carries one.
805    pub key: Option<String>,
806    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
807    /// over the bearer for authenticated requests (link.md §8).
808    pub agent_key: Option<AgentSigningKey>,
809    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
810    /// names one — `sync --push` then signs feed entries locally (§2.4).
811    pub brain_key: Option<AgentSigningKey>,
812    /// User-owned global toolkit state root. Identity pins and monotonic feed
813    /// checkpoints live below `<state_dir>/trust/`, never under a store.
814    pub state_dir: PathBuf,
815    /// True only when the origin came from untrusted store-local configuration.
816    /// Such origins are resolved and public-IP-pinned for every request.
817    store_selected: bool,
818}
819
820/// A loaded agent signing key: the PKCS#8 secret plus its derived public
821/// multikey. Debug never prints key material.
822#[derive(Clone)]
823pub struct AgentSigningKey {
824    pkcs8: Vec<u8>,
825    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
826    pub multikey: String,
827    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
828    pub public_key_spki: String,
829}
830
831impl std::fmt::Debug for AgentSigningKey {
832    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
833        f.debug_struct("AgentSigningKey")
834            .field("multikey", &self.multikey)
835            .field("pkcs8", &"<redacted>")
836            .finish()
837    }
838}
839
840impl HubConfig {
841    /// The credential, or the canonical "not configured" error. Verbs that
842    /// authenticate call this; `propose` never does.
843    pub fn require_key(&self) -> LinkResult<&str> {
844        self.key.as_deref().ok_or(LinkError::NoCredential)
845    }
846}
847
848/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
849/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
850/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
851/// header token (never echoed on failure).
852pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
853    let explicit_hub = flag_hub
854        .map(str::to_string)
855        .or_else(|| env_nonempty(HUB_URL_ENV));
856    let selected_by_store = explicit_hub.is_none();
857    let hub = explicit_hub
858        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
859        .ok_or(LinkError::NoHub)?;
860    let hub = hub.trim().trim_end_matches('/').to_string();
861    assert_safe_hub(&hub)?;
862    if selected_by_store {
863        let parsed =
864            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
865        // A cloned store is not an operator opt-in to local-network access.
866        // Local/private development hubs must be selected explicitly by flag or
867        // environment, never by bytes inside the store.
868        if !parsed.scheme().eq_ignore_ascii_case("https")
869            || (parsed.path() != "/" && !parsed.path().is_empty())
870        {
871            return Err(LinkError::UnsafeHub { hub });
872        }
873    }
874
875    let key = match env_nonempty(HUB_KEY_ENV) {
876        Some(raw) => Some(clean_key(&raw)?),
877        None => None,
878    };
879
880    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
881        Some(path) => Some(load_agent_key(Path::new(&path))?),
882        None => None,
883    };
884
885    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
886        Some(path) => Some(load_agent_key(Path::new(&path))?),
887        None => None,
888    };
889
890    // A cloned store controls `.dbmd/config`. It must never be able to point
891    // the process at an attacker origin and harvest the user's ambient account
892    // bearer, signed agent identity, or self-custodied brain signatures/content.
893    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
894    // in the invocation environment; a store-selected target additionally
895    // needs an exact origin binding.
896    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
897        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
898            .and_then(|value| normalized_origin(&value).ok());
899        let selected_origin = normalized_origin(&hub)?;
900        if bound.as_deref() != Some(selected_origin.as_str()) {
901            return Err(LinkError::UnboundCredential);
902        }
903    }
904
905    Ok(HubConfig {
906        hub,
907        key,
908        agent_key,
909        brain_key,
910        state_dir: toolkit_state_dir()?,
911        store_selected: selected_by_store,
912    })
913}
914
915fn toolkit_state_dir() -> LinkResult<PathBuf> {
916    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
917        let path = PathBuf::from(path);
918        if !path.is_absolute() {
919            return Err(LinkError::UnsafePath {
920                path: path.display().to_string(),
921            });
922        }
923        return Ok(path);
924    }
925    #[cfg(windows)]
926    if let Some(base) = env_nonempty("LOCALAPPDATA") {
927        let base = PathBuf::from(base);
928        if base.is_absolute() {
929            return Ok(base.join("dbmd").join("state"));
930        }
931    }
932    #[cfg(windows)]
933    {
934        Err(LinkError::Io(std::io::Error::new(
935            std::io::ErrorKind::NotFound,
936            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
937        )))
938    }
939    #[cfg(not(windows))]
940    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
941        let base = PathBuf::from(base);
942        if base.is_absolute() {
943            return Ok(base.join("dbmd"));
944        }
945    }
946    #[cfg(not(windows))]
947    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
948        LinkError::Io(std::io::Error::new(
949            std::io::ErrorKind::NotFound,
950            format!("cannot locate user state; set {STATE_DIR_ENV}"),
951        ))
952    })?);
953    #[cfg(not(windows))]
954    if !home.is_absolute() {
955        return Err(LinkError::UnsafePath {
956            path: home.display().to_string(),
957        });
958    }
959    #[cfg(target_os = "macos")]
960    {
961        Ok(home
962            .join("Library")
963            .join("Application Support")
964            .join("dbmd")
965            .join("state"))
966    }
967    #[cfg(all(not(target_os = "macos"), not(windows)))]
968    {
969        Ok(home.join(".local").join("state").join("dbmd"))
970    }
971}
972
973fn normalized_origin(value: &str) -> LinkResult<String> {
974    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
975        hub: value.to_string(),
976    })?;
977    if !(parsed.scheme().eq_ignore_ascii_case("https")
978        || parsed.scheme().eq_ignore_ascii_case("http"))
979        || !parsed.username().is_empty()
980        || parsed.password().is_some()
981        || (parsed.path() != "/" && !parsed.path().is_empty())
982        || parsed.query().is_some()
983        || parsed.fragment().is_some()
984    {
985        return Err(LinkError::UnsafeHub {
986            hub: value.to_string(),
987        });
988    }
989    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
990        hub: value.to_string(),
991    })?;
992    let host = if host.contains(':') {
993        format!("[{host}]")
994    } else {
995        host.to_ascii_lowercase()
996    };
997    let port = parsed
998        .port_or_known_default()
999        .ok_or_else(|| LinkError::UnsafeHub {
1000            hub: value.to_string(),
1001        })?;
1002    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
1003        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
1004    Ok(format!(
1005        "{}://{}{}",
1006        parsed.scheme().to_ascii_lowercase(),
1007        host,
1008        if default {
1009            String::new()
1010        } else {
1011            format!(":{port}")
1012        }
1013    ))
1014}
1015
1016// ─────────────────────────────────────────────────────────────────────────────
1017// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
1018// ─────────────────────────────────────────────────────────────────────────────
1019
1020/// The DER prefix that wraps a raw Ed25519 public key into a
1021/// SubjectPublicKeyInfo (RFC 8410).
1022const ED25519_SPKI_PREFIX: [u8; 12] = [
1023    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1024];
1025
1026fn bad_agent_key(message: &str) -> LinkError {
1027    LinkError::BadAgentKey {
1028        message: message.to_string(),
1029    }
1030}
1031
1032fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1033    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
1034    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
1035    // deriving the public half itself.
1036    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1037        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1038        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1039}
1040
1041/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
1042fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1043    use ring::signature::KeyPair as _;
1044    let mut spki = Vec::with_capacity(44);
1045    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1046    spki.extend_from_slice(pair.public_key().as_ref());
1047    (
1048        URL_SAFE_NO_PAD.encode(&spki),
1049        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1050    )
1051}
1052
1053/// Load and validate a signing-key file (agent or brain — same format):
1054/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
1055/// old key explicitly.
1056pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1057    load_agent_key(path)
1058}
1059
1060/// Load and validate the agent key file: one base64url line of PKCS#8.
1061fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1062    #[cfg(unix)]
1063    let file = {
1064        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1065        use std::os::unix::ffi::OsStrExt as _;
1066        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1067            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1068        let leaf = path
1069            .file_name()
1070            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1071        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1072        let fd = unsafe {
1073            libc::openat(
1074                parent.as_raw_fd(),
1075                leaf.as_ptr(),
1076                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1077            )
1078        };
1079        if fd < 0 {
1080            return Err(bad_agent_key(
1081                "the key path must be an existing regular file without symlink ancestors",
1082            ));
1083        }
1084        unsafe { std::fs::File::from_raw_fd(fd) }
1085    };
1086    #[cfg(not(unix))]
1087    let file = std::fs::File::open(path)
1088        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1089    let metadata = file
1090        .metadata()
1091        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1092    if !metadata.is_file() {
1093        return Err(bad_agent_key("the key path must be a regular file"));
1094    }
1095    #[cfg(unix)]
1096    {
1097        use std::os::unix::fs::PermissionsExt as _;
1098        if metadata.permissions().mode() & 0o077 != 0 {
1099            return Err(bad_agent_key(
1100                "the key file is accessible to group/other; set mode 0600",
1101            ));
1102        }
1103    }
1104    let mut text = String::new();
1105    file.take(1024 * 1024 + 1)
1106        .read_to_string(&mut text)
1107        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1108    if text.len() > 1024 * 1024 {
1109        return Err(bad_agent_key("the key file exceeds the size limit"));
1110    }
1111    let pkcs8 = URL_SAFE_NO_PAD
1112        .decode(text.trim())
1113        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1114    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1115    Ok(AgentSigningKey {
1116        pkcs8,
1117        multikey,
1118        public_key_spki,
1119    })
1120}
1121
1122/// Durably create a new secret file without an exists→write race and without
1123/// ever exposing a default-mode (commonly 0644) key between write and chmod.
1124/// `create_new` also refuses a planted symlink. The file and its parent
1125/// directory are synced before the caller can publish the corresponding
1126/// public identity remotely.
1127fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1128    #[cfg(unix)]
1129    let (mut file, parent, leaf) = {
1130        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1131        use std::os::unix::ffi::OsStrExt as _;
1132        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1133        let leaf_name = path
1134            .file_name()
1135            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1136        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1137        let fd = unsafe {
1138            libc::openat(
1139                parent.as_raw_fd(),
1140                leaf.as_ptr(),
1141                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1142                0o600,
1143            )
1144        };
1145        if fd < 0 {
1146            let error = std::io::Error::last_os_error();
1147            if error.kind() == std::io::ErrorKind::AlreadyExists {
1148                return Err(bad_agent_key(
1149                    "the output file already exists — refusing to overwrite a key",
1150                ));
1151            }
1152            return Err(error.into());
1153        }
1154        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1155    };
1156    #[cfg(not(unix))]
1157    let mut file = std::fs::OpenOptions::new()
1158        .write(true)
1159        .create_new(true)
1160        .open(path)
1161        .map_err(|error| {
1162            if error.kind() == std::io::ErrorKind::AlreadyExists {
1163                bad_agent_key("the output file already exists — refusing to overwrite a key")
1164            } else {
1165                LinkError::Io(error)
1166            }
1167        })?;
1168    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1169        drop(file);
1170        #[cfg(unix)]
1171        let _ =
1172            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1173        #[cfg(not(unix))]
1174        let _ = std::fs::remove_file(path);
1175        return Err(LinkError::Io(error));
1176    }
1177    drop(file);
1178    #[cfg(unix)]
1179    parent.sync_all()?;
1180    Ok(())
1181}
1182
1183/// What `dbmd key generate` returns: the public identity to register plus
1184/// where the secret landed.
1185#[derive(Debug, Serialize)]
1186pub struct GeneratedAgentKey {
1187    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1188    pub multikey: String,
1189    /// base64url SPKI DER — what a hub's register endpoint takes.
1190    #[serde(rename = "publicKeySpki")]
1191    pub public_key_spki: String,
1192    /// Where the PKCS#8 secret was written (mode 0600).
1193    #[serde(rename = "keyFile")]
1194    pub key_file: String,
1195}
1196
1197/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1198/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1199/// identity is returned. The private key never enters a store and never
1200/// travels — requests carry per-request signatures instead (link.md §8).
1201pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1202    require_hardened_filesystem("key generation")?;
1203    let rng = ring::rand::SystemRandom::new();
1204    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1205        .map_err(|_| bad_agent_key("key generation failed"))?;
1206    let pair = agent_keypair(pkcs8.as_ref())?;
1207    let (spki_b64u, multikey) = public_identity_for(&pair);
1208
1209    write_secret_new(
1210        out,
1211        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1212    )?;
1213
1214    Ok(GeneratedAgentKey {
1215        multikey,
1216        public_key_spki: spki_b64u,
1217        key_file: out.display().to_string(),
1218    })
1219}
1220
1221/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1222/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1223/// (sha256hex(body) | "-")`.
1224///
1225/// The origin is derived from the already-validated hub URL, never from
1226/// attacker-controlled request metadata. Binding it closes the v1 replay
1227/// class where a proof captured by one hub could be replayed to another hub
1228/// serving the same path inside the timestamp window.
1229fn linkmd_sig_header(
1230    key: &AgentSigningKey,
1231    origin: &str,
1232    method: &str,
1233    path: &str,
1234    body: Option<&str>,
1235) -> LinkResult<String> {
1236    let ts = std::time::SystemTime::now()
1237        .duration_since(std::time::UNIX_EPOCH)
1238        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1239        .as_secs();
1240    let body_hash = match body {
1241        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1242        None => "-".to_string(),
1243    };
1244    let canonical = format!(
1245        "v2\n{}\n{}\n{}\n{}\n{}",
1246        origin,
1247        method.to_uppercase(),
1248        path,
1249        ts,
1250        body_hash
1251    );
1252    let pair = agent_keypair(&key.pkcs8)?;
1253    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1254    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1255    Ok(format!(
1256        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1257    ))
1258}
1259
1260// ─────────────────────────────────────────────────────────────────────────────
1261// Self-custody feed entries — the client signs what the hub only verifies
1262// ─────────────────────────────────────────────────────────────────────────────
1263
1264/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1265/// exactly this order).
1266#[derive(Serialize)]
1267struct WireFeedFile {
1268    path: String,
1269    sha256: String,
1270    bytes: u64,
1271}
1272
1273/// The unsigned entry in the normative §5.1 field order — serde serializes
1274/// struct fields in declaration order, which IS the wire contract.
1275#[derive(Serialize)]
1276struct UnsignedWireEntry<'a> {
1277    v: u8,
1278    seq: u64,
1279    ts: String,
1280    brain: &'a str,
1281    public_key: &'a str,
1282    kind: &'a str,
1283    op: &'a str,
1284    pack_sha256: &'a str,
1285    files: &'a [WireFeedFile],
1286    removed: &'a [String],
1287    prev_entry_hash: Option<&'a str>,
1288}
1289
1290/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1291/// brain key: serialize the unsigned entry compactly in the normative order,
1292/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1293/// returned string is the exact serialization the hub stores verbatim (plus
1294/// one trailing newline) and every independent reader re-derives.
1295fn self_custody_entry(
1296    key: &AgentSigningKey,
1297    seq: u64,
1298    ts: String,
1299    pack_sha256: &str,
1300    files: &[WireFeedFile],
1301    prev_entry_hash: Option<&str>,
1302) -> LinkResult<String> {
1303    let removed: [String; 0] = [];
1304    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1305        v: 1,
1306        seq,
1307        ts,
1308        brain: &key.multikey,
1309        public_key: &key.public_key_spki,
1310        kind: "push",
1311        op: "snapshot",
1312        pack_sha256,
1313        files,
1314        removed: &removed,
1315        prev_entry_hash,
1316    })
1317    .expect("serialize feed entry");
1318    let pair = agent_keypair(&key.pkcs8)?;
1319    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1320    Ok(format!(
1321        "{},\"sig\":\"{}\"}}",
1322        &unsigned[..unsigned.len() - 1],
1323        sig
1324    ))
1325}
1326
1327/// An env var, treated as absent when unset or empty (an empty
1328/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1329fn env_nonempty(name: &str) -> Option<String> {
1330    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1331}
1332
1333/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1334/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1335/// ignored (forward-compatible). A missing or unreadable file is simply "not
1336/// configured here".
1337fn config_file_hub(path: &Path) -> Option<String> {
1338    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1339    #[cfg(unix)]
1340    let file = {
1341        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1342        use std::os::unix::ffi::OsStrExt as _;
1343        let parent =
1344            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1345        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1346        let fd = unsafe {
1347            libc::openat(
1348                parent.as_raw_fd(),
1349                leaf.as_ptr(),
1350                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1351            )
1352        };
1353        if fd < 0 {
1354            return None;
1355        }
1356        unsafe { std::fs::File::from_raw_fd(fd) }
1357    };
1358    #[cfg(not(unix))]
1359    let file = std::fs::File::open(path).ok()?;
1360    let metadata = file.metadata().ok()?;
1361    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1362        return None;
1363    }
1364    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1365    file.take(MAX_CONFIG_BYTES + 1)
1366        .read_to_end(&mut bytes)
1367        .ok()?;
1368    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1369        return None;
1370    }
1371    let text = String::from_utf8(bytes).ok()?;
1372    for line in text.lines() {
1373        let line = line.trim();
1374        if line.is_empty() || line.starts_with('#') {
1375            continue;
1376        }
1377        if let Some((k, v)) = line.split_once('=') {
1378            if k.trim() == "hub" {
1379                let v = v.trim();
1380                if !v.is_empty() {
1381                    return Some(v.to_string());
1382                }
1383            }
1384        }
1385    }
1386    None
1387}
1388
1389/// The bearer key must never travel in cleartext; only loopback hosts may
1390/// skip TLS (local development against a hub on localhost).
1391fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1392    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1393        hub: hub.to_string(),
1394    })?;
1395    if !(parsed.scheme().eq_ignore_ascii_case("https")
1396        || parsed.scheme().eq_ignore_ascii_case("http"))
1397        || !parsed.username().is_empty()
1398        || parsed.password().is_some()
1399        || (parsed.path() != "/" && !parsed.path().is_empty())
1400        || parsed.query().is_some()
1401        || parsed.fragment().is_some()
1402    {
1403        return Err(LinkError::UnsafeHub {
1404            hub: hub.to_string(),
1405        });
1406    }
1407    let loopback = match parsed.host() {
1408        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1409        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1410        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1411        None => false,
1412    };
1413    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1414        Ok(())
1415    } else {
1416        Err(LinkError::UnsafeHub {
1417            hub: hub.to_string(),
1418        })
1419    }
1420}
1421
1422/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1423/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1424/// value tends to echo the whole header line, credential included, so the
1425/// gate sits here instead.
1426fn clean_key(raw: &str) -> LinkResult<String> {
1427    let k = raw.trim();
1428    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1429        return Err(LinkError::BadKey);
1430    }
1431    Ok(k.to_string())
1432}
1433
1434// ─────────────────────────────────────────────────────────────────────────────
1435// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1436// ─────────────────────────────────────────────────────────────────────────────
1437
1438/// One hub response: the status plus the parsed JSON body when there was one.
1439#[derive(Debug)]
1440pub struct HubResponse {
1441    /// The HTTP status code.
1442    pub status: u16,
1443    /// The parsed JSON body, `None` when the body was empty or not JSON.
1444    pub body: Option<Value>,
1445}
1446
1447struct RawHubResponse {
1448    status: u16,
1449    body: Vec<u8>,
1450}
1451
1452/// Whether a request carries the bearer credential.
1453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1454enum Auth {
1455    /// Send `authorization: Bearer <key>`; error without a key.
1456    Required,
1457    /// Send no credential — the propose door is unauthenticated by design.
1458    None,
1459    /// Send the configured credential when one exists, otherwise nothing —
1460    /// brain-addressed propose works anonymously on public brains, and an
1461    /// authenticated caller earns a bigger actor-class budget.
1462    Optional,
1463}
1464
1465fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1466    ureq::AgentBuilder::new()
1467        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1468        // Never follow a redirect while a bearer, a store pack, or a signed
1469        // response is in flight. Callers see the 3xx as a non-success instead
1470        // of letting an origin steer sensitive material elsewhere.
1471        .redirects(0)
1472        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1473        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1474        .timeout_write(overall)
1475        .timeout(overall)
1476}
1477
1478fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1479    hub_agent_with_timeout(
1480        cfg,
1481        std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1482    )
1483}
1484
1485fn hub_agent_with_timeout(
1486    cfg: &HubConfig,
1487    overall: std::time::Duration,
1488) -> LinkResult<ureq::Agent> {
1489    if !cfg.store_selected {
1490        return Ok(agent_builder_with_timeout(overall).build());
1491    }
1492    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1493        hub: cfg.hub.clone(),
1494    })?;
1495    pinned_public_agent_pooled(
1496        &parsed,
1497        false,
1498        "store-selected hub",
1499        AgentShape {
1500            overall,
1501            ..AgentShape::default()
1502        },
1503    )
1504}
1505
1506/// Perform one hub request. `path` is the binding path (starts with `/`);
1507/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1508/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1509/// the [`HubResponse`] for [`ensure_ok`] to shape.
1510fn request_raw(
1511    cfg: &HubConfig,
1512    method: &str,
1513    path: &str,
1514    body: Option<&Value>,
1515    auth: Auth,
1516    max_response_bytes: u64,
1517) -> LinkResult<RawHubResponse> {
1518    let http = hub_agent(cfg)?;
1519    request_raw_with_agent(
1520        cfg,
1521        &http,
1522        method,
1523        path,
1524        body,
1525        RawRequestOptions {
1526            auth,
1527            max_response_bytes,
1528            request_id: None,
1529            retry_transport: false,
1530        },
1531    )
1532}
1533
1534/// A body-bearing request that reads immutable state without changing hub
1535/// authority. Some proof lists are too large for a URL, so link.md transports
1536/// them with POST even though repeating the request is side-effect free.
1537fn request_raw_retryable_read(
1538    cfg: &HubConfig,
1539    method: &str,
1540    path: &str,
1541    body: Option<&Value>,
1542    auth: Auth,
1543    max_response_bytes: u64,
1544) -> LinkResult<RawHubResponse> {
1545    let http = hub_agent(cfg)?;
1546    request_raw_with_agent(
1547        cfg,
1548        &http,
1549        method,
1550        path,
1551        body,
1552        RawRequestOptions {
1553            auth,
1554            max_response_bytes,
1555            request_id: None,
1556            retry_transport: true,
1557        },
1558    )
1559}
1560
1561struct RawRequestOptions<'a> {
1562    auth: Auth,
1563    max_response_bytes: u64,
1564    request_id: Option<&'a str>,
1565    retry_transport: bool,
1566}
1567
1568fn request_raw_with_agent(
1569    cfg: &HubConfig,
1570    http: &ureq::Agent,
1571    method: &str,
1572    path: &str,
1573    body: Option<&Value>,
1574    options: RawRequestOptions<'_>,
1575) -> LinkResult<RawHubResponse> {
1576    let url = format!("{}{}", cfg.hub, path);
1577    let encoded_body = body.map(Value::to_string);
1578    let origin = normalized_origin(&cfg.hub)?;
1579    let safe_read = (method == "GET" && encoded_body.is_none()) || options.retry_transport;
1580    let mut read_attempt = 0;
1581    loop {
1582        // An agent signing key outranks the bearer: possession proofs put
1583        // nothing reusable on the wire, so when both are configured the
1584        // stronger one wins. Regenerate its timestamp for a whole-request GET
1585        // retry; a long stalled read must not age the replacement signature
1586        // outside the hub's acceptance window. This applies only to GETs and
1587        // the explicitly marked read-only POST bindings above.
1588        let credential = match options.auth {
1589            Auth::Required => Some(match &cfg.agent_key {
1590                Some(key) => {
1591                    linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?
1592                }
1593                None => format!("Bearer {}", cfg.require_key()?),
1594            }),
1595            Auth::Optional => match &cfg.agent_key {
1596                Some(key) => Some(linkmd_sig_header(
1597                    key,
1598                    &origin,
1599                    method,
1600                    path,
1601                    encoded_body.as_deref(),
1602                )?),
1603                None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1604            },
1605            Auth::None => None,
1606        };
1607        let result = with_connect_retries(|| {
1608            let mut req = http.request(method, &url);
1609            if let Some(value) = &credential {
1610                req = req.set("authorization", value);
1611            }
1612            if let Some(value) = options.request_id {
1613                req = req.set("x-request-id", value);
1614            }
1615            match &encoded_body {
1616                Some(value) => req
1617                    .set("content-type", "application/json")
1618                    .send_string(value)
1619                    .map_err(Box::new),
1620                None => req.call().map_err(Box::new),
1621            }
1622        });
1623        let resp = match result {
1624            Ok(resp) => resp,
1625            Err(error) => match *error {
1626                ureq::Error::Status(_, resp) => resp,
1627                ureq::Error::Transport(error) => {
1628                    if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS {
1629                        std::thread::sleep(std::time::Duration::from_millis(
1630                            SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1631                        ));
1632                        read_attempt += 1;
1633                        continue;
1634                    }
1635                    return Err(LinkError::Transport {
1636                        hub: cfg.hub.clone(),
1637                        message: error.to_string(),
1638                    });
1639                }
1640            },
1641        };
1642
1643        let status = resp.status();
1644        let buf = match read_response_body(resp, options.max_response_bytes + 1, &cfg.hub) {
1645            Ok(buf) => buf,
1646            Err(LinkError::Transport { .. })
1647                if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS =>
1648            {
1649                std::thread::sleep(std::time::Duration::from_millis(
1650                    SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1651                ));
1652                read_attempt += 1;
1653                continue;
1654            }
1655            Err(error) => return Err(error),
1656        };
1657        if buf.len() as u64 > options.max_response_bytes {
1658            return Err(LinkError::ResponseTooLarge {
1659                limit_bytes: options.max_response_bytes,
1660            });
1661        }
1662        return Ok(RawHubResponse { status, body: buf });
1663    }
1664}
1665
1666fn request_capped(
1667    cfg: &HubConfig,
1668    method: &str,
1669    path: &str,
1670    body: Option<&Value>,
1671    auth: Auth,
1672    max_response_bytes: u64,
1673) -> LinkResult<HubResponse> {
1674    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1675    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1676    Ok(HubResponse {
1677        status: raw.status,
1678        body: parsed,
1679    })
1680}
1681
1682/// One request the hub is allowed to take brain-sized time over. Used for the
1683/// commit lane, where abandoning the attempt early does not cancel the work —
1684/// it only loses the receipt for it.
1685///
1686/// Which is why a lost connection is asked again rather than reported. The
1687/// mutation coordinate is derived from the observed base and the exact
1688/// operation set precisely so a repeat is the SAME effect: the hub answers a
1689/// mutation it already applied with that commit's receipt instead of a second
1690/// commit. Measured on a 32,338-file store: the commit landed durably and the
1691/// push reported a transport failure, because nobody went back for the
1692/// receipt.
1693fn request_patient(
1694    cfg: &HubConfig,
1695    method: &str,
1696    path: &str,
1697    body: Option<&Value>,
1698    auth: Auth,
1699) -> LinkResult<HubResponse> {
1700    let http = hub_agent_with_timeout(
1701        cfg,
1702        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1703    )?;
1704    let mut attempt = 0;
1705    loop {
1706        let sent = request_raw_with_agent(
1707            cfg,
1708            &http,
1709            method,
1710            path,
1711            body,
1712            RawRequestOptions {
1713                auth,
1714                max_response_bytes: MAX_RESPONSE_BYTES,
1715                request_id: None,
1716                retry_transport: false,
1717            },
1718        );
1719        match sent {
1720            Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1721                std::thread::sleep(std::time::Duration::from_millis(
1722                    COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1723                ));
1724                attempt += 1;
1725            }
1726            Err(error) => return Err(error),
1727            Ok(raw) => {
1728                return Ok(HubResponse {
1729                    status: raw.status,
1730                    body: serde_json::from_slice(&raw.body).ok(),
1731                })
1732            }
1733        }
1734    }
1735}
1736
1737fn request(
1738    cfg: &HubConfig,
1739    method: &str,
1740    path: &str,
1741    body: Option<&Value>,
1742    auth: Auth,
1743) -> LinkResult<HubResponse> {
1744    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1745}
1746
1747/// Repeat one challenge-response mutation with the exact request identity the
1748/// hub bound into its actor claim. A new request id would create a new actor
1749/// hash and therefore a different canonical commit candidate, making the
1750/// otherwise correct self-custody signature unusable.
1751fn request_with_request_id(
1752    cfg: &HubConfig,
1753    method: &str,
1754    path: &str,
1755    body: Option<&Value>,
1756    auth: Auth,
1757    request_id: &str,
1758) -> LinkResult<HubResponse> {
1759    if request_id.is_empty()
1760        || request_id.len() > 128
1761        || !request_id
1762            .bytes()
1763            .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1764    {
1765        return Err(invalid_feed("hub returned an unsafe request id"));
1766    }
1767    // Both callers are commit responses, so this shares the commit lane's
1768    // patience: the hub is finishing the same brain-sized work either way.
1769    let http = hub_agent_with_timeout(
1770        cfg,
1771        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1772    )?;
1773    let raw = request_raw_with_agent(
1774        cfg,
1775        &http,
1776        method,
1777        path,
1778        body,
1779        RawRequestOptions {
1780            auth,
1781            max_response_bytes: MAX_RESPONSE_BYTES,
1782            request_id: Some(request_id),
1783            retry_transport: false,
1784        },
1785    )?;
1786    Ok(HubResponse {
1787        status: raw.status,
1788        body: serde_json::from_slice(&raw.body).ok(),
1789    })
1790}
1791
1792fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1793    if (200..300).contains(&r.status) {
1794        return Ok(r.body);
1795    }
1796    ensure_ok(
1797        HubResponse {
1798            status: r.status,
1799            body: serde_json::from_slice(&r.body).ok(),
1800        },
1801        what,
1802    )
1803    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1804}
1805
1806/// These failures happen before any HTTP request reaches the hub, so retrying
1807/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1808/// bytes may have crossed the wire, the caller must rely on the verb's own
1809/// idempotency contract instead of guessing.
1810fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1811    matches!(
1812        kind,
1813        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1814    )
1815}
1816
1817fn with_connect_retries(
1818    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1819) -> Result<ureq::Response, Box<ureq::Error>> {
1820    let mut attempt = 0;
1821    loop {
1822        match send() {
1823            Err(error)
1824                if matches!(
1825                    error.as_ref(),
1826                    ureq::Error::Transport(transport)
1827                        if is_pre_request_transport(transport.kind())
1828                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1829            {
1830                std::thread::sleep(std::time::Duration::from_millis(
1831                    CONNECT_RETRY_BACKOFF_MS[attempt],
1832                ));
1833                attempt += 1;
1834            }
1835            result => return result,
1836        }
1837    }
1838}
1839
1840fn hub_is_loopback(hub: &str) -> bool {
1841    url::Url::parse(hub).ok().is_some_and(|parsed| {
1842        parsed.host().is_some_and(|host| match host {
1843            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1844            url::Host::Ipv4(ip) => ip.is_loopback(),
1845            url::Host::Ipv6(ip) => ip.is_loopback(),
1846        })
1847    })
1848}
1849
1850/// Every safety property a presigned object-store URL must carry, checked
1851/// before anything is sent to it. Split out from agent construction so a batch
1852/// can share one connection pool without ever skipping a per-URL check.
1853fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1854    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1855        message: "the hub returned an invalid object-store URL".to_string(),
1856    })?;
1857    let allow_private = hub_is_loopback(&cfg.hub)
1858        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1859    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1860        || !parsed.username().is_empty()
1861        || parsed.password().is_some()
1862        || parsed.fragment().is_some()
1863    {
1864        return Err(LinkError::InvalidPack {
1865            message: "the hub returned an unsafe object-store URL".to_string(),
1866        });
1867    }
1868    Ok((parsed, allow_private))
1869}
1870
1871fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1872    let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1873    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1874        LinkError::InvalidPack {
1875            message: "the hub returned an object-store URL with an unsafe network target"
1876                .to_string(),
1877        }
1878    })
1879}
1880
1881/// One pinned agent for a whole staging batch.
1882///
1883/// Every object in a batch is presigned against the same object-store
1884/// authority, so one agent can serve them all — and must: building one per
1885/// object costs a DNS resolution and a fresh TLS handshake per object, which is
1886/// the difference between a real store staging in minutes and in an hour. The
1887/// shared authority is verified rather than assumed, and a batch that ever
1888/// spans two of them falls back to a per-object agent.
1889fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1890    let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1891    let authority = (
1892        first.host_str()?.to_string(),
1893        first.port_or_known_default()?,
1894    );
1895    for raw in &urls[1..] {
1896        let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1897        if (parsed.host_str()?, parsed.port_or_known_default()?)
1898            != (authority.0.as_str(), authority.1)
1899        {
1900            return None;
1901        }
1902    }
1903    pinned_public_agent_pooled(
1904        &first,
1905        allow_private,
1906        "object-store URL",
1907        AgentShape {
1908            idle_per_host: V2_UPLOAD_CONCURRENCY,
1909            ..AgentShape::default()
1910        },
1911    )
1912    .ok()
1913}
1914
1915/// Turn an object-store transport failure into a useful but credential-safe
1916/// diagnostic. `ureq::Transport`'s Display implementation includes the full
1917/// request URL; for a presigned URL that means the short-lived signature and
1918/// every signed query coordinate. The error kind is enough to distinguish a
1919/// network failure without copying bearer material into terminals or logs.
1920fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1921    LinkError::Transport {
1922        hub: "the object store".to_string(),
1923        message: format!("network error ({:?})", error.kind()),
1924    }
1925}
1926
1927fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1928    let http = presigned_agent(cfg, raw)?;
1929    let deadline = std::time::Instant::now()
1930        .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1931        .ok_or_else(upload_deadline_error)?;
1932    let mut attempt = 0;
1933    let result = loop {
1934        // The reservation states content-length itself. This client sends the
1935        // body from memory, so it never needs to add one, and adding one under
1936        // a different capitalization would leave both on the wire.
1937        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1938        if let Some(map) = headers.as_object() {
1939            for (name, value) in map {
1940                if let Some(value) = value.as_str() {
1941                    req = req.set(name, value);
1942                }
1943            }
1944        }
1945        match req.send_bytes(bytes) {
1946            // An upload is idempotent here: fixed content address, exact
1947            // length and checksum, write-once precondition. A dropped
1948            // connection or a single overloaded refusal is worth replaying
1949            // rather than failing a whole migration on. 412 already means
1950            // "someone else stored these exact bytes".
1951            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1952                attempt += 1;
1953            }
1954            Err(ureq::Error::Status(status, _))
1955                if status != 412
1956                    && is_retryable_upload_status(status)
1957                    && wait_for_upload_retry(deadline, attempt) =>
1958            {
1959                attempt += 1;
1960            }
1961            result => break result,
1962        }
1963    };
1964    match result {
1965        Ok(resp) if (200..300).contains(&resp.status()) => {
1966            drain_presigned_response(resp);
1967            Ok(())
1968        }
1969        Ok(resp) => Err(presigned_upload_refusal(resp)),
1970        Err(error) => match error {
1971            // Immutable uploads use If-None-Match. A concurrent writer may
1972            // win the same content address; the commit/confirm endpoint reads
1973            // and hashes that object before accepting it, so 412 safely means
1974            // "continue to verification", never "trust the upload".
1975            ureq::Error::Status(412, _) => Ok(()),
1976            ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
1977            ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
1978        },
1979    }
1980}
1981
1982/// Read a response body, typed as the transport failure it is.
1983///
1984/// A connection that fails while the body is still arriving is the same class
1985/// of problem as one that fails before it arrives at all — but a bare `?` on
1986/// the read turned it into an anonymous io error that no retry path recognised
1987/// and no message attributed to a peer. On a migration making tens of
1988/// thousands of requests, one stalled body ended the whole push and reported
1989/// only "timed out reading response".
1990fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
1991    let mut buf = Vec::new();
1992    response
1993        .into_reader()
1994        .take(limit)
1995        .read_to_end(&mut buf)
1996        .map_err(|error| LinkError::Transport {
1997            hub: peer.to_string(),
1998            message: error.to_string(),
1999        })?;
2000    Ok(buf)
2001}
2002
2003/// Draining a response is what returns its connection to the pool: the client
2004/// cannot recycle a socket whose body may still have bytes pending, so a body
2005/// that is dropped instead of read costs the next object a fresh handshake.
2006/// An object-store PUT answers with almost nothing, so this is nearly free.
2007fn drain_presigned_response(response: ureq::Response) {
2008    let mut reader = response.into_reader().take(64 * 1024);
2009    let _ = std::io::copy(&mut reader, &mut std::io::sink());
2010}
2011
2012/// Carry the object store's own reason for a refusal. An unexplained status is
2013/// unactionable; the body states checksum mismatch, precondition, or expiry.
2014fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
2015    let status = response.status();
2016    let detail = response
2017        .into_string()
2018        .ok()
2019        .map(|body| body.chars().take(400).collect::<String>())
2020        .filter(|body| !body.trim().is_empty());
2021    LinkError::Http {
2022        what: "pack upload",
2023        status,
2024        message: match detail {
2025            Some(body) => format!(
2026                "object store rejected the upload: {}",
2027                body.replace('\n', " ")
2028            ),
2029            None => "object store rejected the upload".to_string(),
2030        },
2031        code: None,
2032        details: None,
2033    }
2034}
2035
2036fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
2037    max_bytes.checked_add(1)
2038}
2039
2040fn presigned_download_read_limit() -> u64 {
2041    one_past_bounded_limit(MAX_PACK_BYTES)
2042        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
2043}
2044
2045fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
2046    let http = presigned_agent(cfg, raw)?;
2047    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
2048        Ok(resp) => resp,
2049        Err(error) => match *error {
2050            ureq::Error::Status(_, resp) => {
2051                return Err(LinkError::Http {
2052                    what: "pack download",
2053                    status: resp.status(),
2054                    message: "object store rejected the download".to_string(),
2055                    code: None,
2056                    details: None,
2057                });
2058            }
2059            ureq::Error::Transport(err) => {
2060                return Err(LinkError::Transport {
2061                    hub: "the object store".to_string(),
2062                    message: err.to_string(),
2063                });
2064            }
2065        },
2066    };
2067    if !(200..300).contains(&resp.status()) {
2068        return Err(LinkError::Http {
2069            what: "pack download",
2070            status: resp.status(),
2071            message: "object store rejected the download".to_string(),
2072            code: None,
2073            details: None,
2074        });
2075    }
2076    let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2077    if bytes.len() as u64 > MAX_PACK_BYTES {
2078        return Err(LinkError::InvalidPack {
2079            message: "download exceeds the compressed-size limit".to_string(),
2080        });
2081    }
2082    Ok(bytes)
2083}
2084
2085/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
2086/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
2087/// answer.
2088fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2089    if !(200..300).contains(&r.status) {
2090        let message = r
2091            .body
2092            .as_ref()
2093            .and_then(|b| b.get("error"))
2094            .and_then(Value::as_str)
2095            .unwrap_or("unknown error")
2096            .to_string();
2097        let code = r
2098            .body
2099            .as_ref()
2100            .and_then(|b| b.get("code"))
2101            .and_then(Value::as_str)
2102            .map(str::to_string);
2103        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2104        return Err(LinkError::Http {
2105            what,
2106            status: r.status,
2107            message,
2108            code,
2109            details,
2110        });
2111    }
2112    r.body.ok_or(LinkError::NotJson {
2113        what,
2114        status: r.status,
2115    })
2116}
2117
2118// ─────────────────────────────────────────────────────────────────────────────
2119// resolve — handle → brain card; @brain/id → the record
2120// ─────────────────────────────────────────────────────────────────────────────
2121
2122/// Resolve an address. A bare `@brain` returns the brain card (metadata +
2123/// index stats — the v0 form of the card; keys arrive with the protocol's
2124/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
2125/// record, frontmatter + body.
2126fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2127    match ip {
2128        std::net::IpAddr::V4(ip) => {
2129            let [a, b, c, _] = ip.octets();
2130            !(a == 0
2131                || a == 10
2132                || a == 127
2133                || (a == 100 && (64..=127).contains(&b))
2134                || (a == 169 && b == 254)
2135                || (a == 172 && (16..=31).contains(&b))
2136                || (a == 192 && b == 0 && c == 0)
2137                || (a == 192 && b == 0 && c == 2)
2138                || (a == 192 && b == 88 && c == 99)
2139                || (a == 192 && b == 168)
2140                || (a == 198 && (b == 18 || b == 19))
2141                || (a == 198 && b == 51 && c == 100)
2142                || (a == 203 && b == 0 && c == 113)
2143                || a >= 224)
2144        }
2145        std::net::IpAddr::V6(ip) => {
2146            let segments = ip.segments();
2147            // Conservatively accept only global unicast 2000::/3, excluding
2148            // special-purpose/transition blocks. In particular, 6to4 embeds
2149            // an IPv4 destination and must not tunnel a validated public
2150            // connect to 127/8 or RFC1918.
2151            (segments[0] & 0xe000) == 0x2000
2152                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2153                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2154                && segments[0] != 0x2002
2155                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2156        }
2157    }
2158}
2159
2160#[derive(Clone)]
2161struct PinnedRegistryResolver {
2162    netloc: String,
2163    addresses: Vec<std::net::SocketAddr>,
2164}
2165
2166impl ureq::Resolver for PinnedRegistryResolver {
2167    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2168        if requested == self.netloc {
2169            Ok(self.addresses.clone())
2170        } else {
2171            Err(std::io::Error::new(
2172                std::io::ErrorKind::PermissionDenied,
2173                "registry request attempted to resolve an unvalidated authority",
2174            ))
2175        }
2176    }
2177}
2178
2179fn pinned_public_agent(
2180    url: &url::Url,
2181    allow_private: bool,
2182    label: &str,
2183) -> LinkResult<ureq::Agent> {
2184    pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2185}
2186
2187/// How one agent is built: `idle_per_host` sizes the connection pool (the
2188/// default of one is right for a lone request but throttles a concurrent batch
2189/// back to a single warm connection), and `overall` is the wall-clock budget
2190/// for one attempt.
2191struct AgentShape {
2192    idle_per_host: usize,
2193    overall: std::time::Duration,
2194}
2195
2196impl Default for AgentShape {
2197    fn default() -> Self {
2198        Self {
2199            idle_per_host: 1,
2200            overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2201        }
2202    }
2203}
2204
2205fn pinned_public_agent_pooled(
2206    url: &url::Url,
2207    allow_private: bool,
2208    label: &str,
2209    shape: AgentShape,
2210) -> LinkResult<ureq::Agent> {
2211    let host = url
2212        .host_str()
2213        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2214    let port = url
2215        .port_or_known_default()
2216        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2217    let addresses = resolve_addresses_with_deadline(
2218        host,
2219        port,
2220        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2221    )
2222    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2223    if addresses.is_empty() {
2224        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2225    }
2226    if !allow_private
2227        && addresses
2228            .iter()
2229            .any(|address| !is_public_registry_ip(address.ip()))
2230    {
2231        return Err(invalid_feed(format!(
2232            "{label} resolves to a non-public address"
2233        )));
2234    }
2235    let netloc = if host.contains(':') {
2236        format!("[{host}]:{port}")
2237    } else {
2238        format!("{host}:{port}")
2239    };
2240    Ok(agent_builder_with_timeout(shape.overall)
2241        .max_idle_connections_per_host(shape.idle_per_host.max(1))
2242        .resolver(PinnedRegistryResolver { netloc, addresses })
2243        .build())
2244}
2245
2246/// Resolve one authority without allowing libc DNS to hold the caller forever.
2247/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
2248/// worker and only its result channel is awaited. A late resolver result has no
2249/// side effects and is discarded after the deadline.
2250fn resolve_addresses_with_deadline(
2251    host: &str,
2252    port: u16,
2253    timeout: std::time::Duration,
2254) -> std::io::Result<Vec<std::net::SocketAddr>> {
2255    use std::net::ToSocketAddrs as _;
2256
2257    let host = host.to_string();
2258    let (send, receive) = std::sync::mpsc::sync_channel(1);
2259    std::thread::Builder::new()
2260        .name("dbmd-dns".to_string())
2261        .spawn(move || {
2262            let result = (host.as_str(), port)
2263                .to_socket_addrs()
2264                .map(|addresses| addresses.collect());
2265            let _ = send.send(result);
2266        })
2267        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2268    match receive.recv_timeout(timeout) {
2269        Ok(result) => result,
2270        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2271            std::io::ErrorKind::TimedOut,
2272            "resolution exceeded its deadline",
2273        )),
2274        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2275            "resolver stopped without returning a result",
2276        )),
2277    }
2278}
2279
2280fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2281    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2282    pinned_public_agent(url, allow_private, "registry home")
2283}
2284
2285/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
2286/// from a FOREIGN home during registry resolution. DNS is resolved once,
2287/// every answer is required to be public, and that exact answer set is pinned
2288/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
2289fn get_json_absolute(url: &str) -> LinkResult<Value> {
2290    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2291    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2292    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2293        || !parsed.username().is_empty()
2294        || parsed.password().is_some()
2295        || parsed.query().is_some()
2296        || parsed.fragment().is_some()
2297    {
2298        return Err(invalid_feed("unsafe registry home URL"));
2299    }
2300    let http = registry_agent(&parsed)?;
2301    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2302        Ok(resp) => resp,
2303        Err(error) => match *error {
2304            ureq::Error::Status(status, resp) => {
2305                let _ = resp;
2306                return Err(LinkError::Http {
2307                    what: "registry home fetch",
2308                    status,
2309                    message: "the home node rejected the card request".to_string(),
2310                    code: None,
2311                    details: None,
2312                });
2313            }
2314            ureq::Error::Transport(err) => {
2315                return Err(LinkError::Transport {
2316                    hub: url.to_string(),
2317                    message: err.to_string(),
2318                });
2319            }
2320        },
2321    };
2322    if !(200..300).contains(&resp.status()) {
2323        return Err(LinkError::Http {
2324            what: "registry home fetch",
2325            status: resp.status(),
2326            message: "the home node returned a redirect or error".to_string(),
2327            code: None,
2328            details: None,
2329        });
2330    }
2331    let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2332    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2333        return Err(LinkError::ResponseTooLarge {
2334            limit_bytes: MAX_REGISTRY_CARD_BYTES,
2335        });
2336    }
2337    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2338        message: "the home node returned invalid JSON".to_string(),
2339    })
2340}
2341
2342/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
2343/// E5): look the handle up in the hub's registry, fetch the brain card from
2344/// the returned HOME node, and PIN — the card's identity fingerprint must
2345/// equal the registry's, or resolution fails. Returns the card enriched with
2346/// the resolved `home`, or `Ok(None)` when the registry has no such handle
2347/// (so the caller can fall back to a direct lookup).
2348pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2349    require_safe_ref(handle)?;
2350    // Hold the validated trust-directory capability before any network I/O.
2351    // Every lock/load/save below remains relative to this exact inode even if
2352    // an attacker swaps an ancestor while the registry or home is answering.
2353    let trust_directory = open_trust_dir(cfg)?;
2354    let reg = request_capped(
2355        cfg,
2356        "GET",
2357        &format!("/api/hub/registry/{handle}"),
2358        None,
2359        Auth::None,
2360        MAX_REGISTRY_CARD_BYTES,
2361    )?;
2362    if reg.status == 404 {
2363        return Ok(None);
2364    }
2365    let body = ensure_ok(reg, "registry resolve")?;
2366    let home = body
2367        .get("home")
2368        .and_then(Value::as_str)
2369        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2370    let brain = body
2371        .get("brain")
2372        .and_then(Value::as_str)
2373        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2374    if !crate::ulid::is_ulid(brain) {
2375        return Err(invalid_feed(
2376            "registry entry brain is not a canonical lowercase ULID",
2377        ));
2378    }
2379    let want_fp = body
2380        .get("identity")
2381        .and_then(|i| i.get("fingerprint"))
2382        .and_then(Value::as_str)
2383        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2384
2385    let home = home.trim_end_matches('/');
2386    let origin = normalized_origin(home)?;
2387    if origin != home {
2388        return Err(invalid_feed(
2389            "registry home must be an origin without a path, query, or fragment",
2390        ));
2391    }
2392    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2393    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2394    if let Some(binding) = &alias_binding {
2395        if binding
2396            .home
2397            .as_deref()
2398            .is_some_and(|pinned_home| pinned_home != home)
2399        {
2400            return Err(invalid_feed(
2401                "registry relocated a pinned handle to a different home",
2402            ));
2403        }
2404    }
2405    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2406    if card.get("id").and_then(Value::as_str) != Some(brain) {
2407        return Err(invalid_feed(
2408            "the home node served a card for a different brain",
2409        ));
2410    }
2411    let identity: FeedIdentity = serde_json::from_value(
2412        card.get("identity")
2413            .cloned()
2414            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2415    )
2416    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2417    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2418    let got_fp = card
2419        .get("identity")
2420        .and_then(|i| i.get("fingerprint"))
2421        .and_then(Value::as_str)
2422        .unwrap_or_default();
2423    if got_fp != want_fp {
2424        return Err(invalid_feed(
2425            "the home node served an identity that does not match the registry — refusing",
2426        ));
2427    }
2428    let current = format!("ed25519:{}", identity.fingerprint);
2429    let advertised_seq = card
2430        .get("headSeq")
2431        .and_then(Value::as_u64)
2432        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2433    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2434    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2435        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2436    {
2437        return Err(invalid_feed(
2438            "the home node served an invalid feed head boundary",
2439        ));
2440    }
2441    // This is required even on first contact. Otherwise a syntactically valid
2442    // identity may claim that a rotation happened after a head the home has
2443    // never reached, then become the permanent TOFU anchor.
2444    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2445    let registry_alias = AliasBinding {
2446        v: 1,
2447        origin: normalized_origin(&cfg.hub)?,
2448        requested: handle.to_string(),
2449        brain: brain.to_string(),
2450        home: Some(home.to_string()),
2451    };
2452    save_canonical_pin_and_alias(
2453        cfg,
2454        &trust_directory,
2455        handle,
2456        brain,
2457        TrustState {
2458            v: 2,
2459            origin: normalized_origin(&cfg.hub)?,
2460            requested: brain.to_string(),
2461            brain: brain.to_string(),
2462            home: None,
2463            anchor,
2464            current,
2465            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2466            feed_hash: pinned
2467                .as_ref()
2468                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2469            rotations: identity.rotations.clone(),
2470            hub_signer: None,
2471            protocol_profile: None,
2472        },
2473        Some(&registry_alias),
2474    )?;
2475    let mut out = card;
2476    if let Value::Object(map) = &mut out {
2477        map.insert("home".to_string(), Value::String(home.to_string()));
2478        map.insert(
2479            "resolvedVia".to_string(),
2480            Value::String("registry".to_string()),
2481        );
2482    }
2483    Ok(Some(out))
2484}
2485
2486pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2487    // `Address::parse` refuses these shapes already, but `Address` has public
2488    // fields — re-assert at the wire so a hand-built address can never
2489    // reshape the request path.
2490    require_safe_ref(&addr.brain)?;
2491    if let Some(target) = &addr.target {
2492        let (given, ok) = match target {
2493            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2494            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2495        };
2496        if !ok {
2497            return Err(LinkError::BadAddress {
2498                given: given.clone(),
2499                reason: BAD_TARGET_REASON.to_string(),
2500            });
2501        }
2502    }
2503
2504    // A record is never accepted from the hub's mutable query/index response.
2505    // Under v2, an id lookup may suggest a path, but the client accepts only
2506    // bytes whose exact path proof reaches the verified content root and whose
2507    // parsed id still matches. Under v1, the signed snapshot pack remains the
2508    // binding. Both profiles therefore reject a forged query response.
2509    if let Some(target) = &addr.target {
2510        if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2511            let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2512                what: "resolve",
2513                status: 404,
2514                message: "record not found".to_string(),
2515                code: Some("NOT_FOUND".to_string()),
2516                details: None,
2517            })?;
2518            let (path, file) = match target {
2519                AddressTarget::Path(path) => {
2520                    let file =
2521                        v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2522                            LinkError::Http {
2523                                what: "resolve",
2524                                status: 404,
2525                                message: "record not found".to_string(),
2526                                code: Some("NOT_FOUND".to_string()),
2527                                details: None,
2528                            }
2529                        })?;
2530                    (path.clone(), file)
2531                }
2532                AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2533            };
2534            let mut downloaded =
2535                download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2536            let (_, bytes) = downloaded
2537                .pop()
2538                .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2539            let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2540            accept_v2_head(cfg, &head)?;
2541            return Ok(resolved);
2542        }
2543        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2544        if !remote.head.verified {
2545            return Err(invalid_feed(
2546                "a path-scoped feed cannot prove a record against the full signed snapshot",
2547            ));
2548        }
2549        if remote.head.seq == 0 {
2550            return Err(LinkError::Http {
2551                what: "resolve",
2552                status: 404,
2553                message: "record not found".to_string(),
2554                code: Some("NOT_FOUND".to_string()),
2555                details: None,
2556            });
2557        }
2558        let brain = remote.head.brain.clone();
2559        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2560        return resolve_from_verified_pack(&brain, target, pack);
2561    }
2562
2563    let path = format!("/api/hub/brains/{}", addr.brain);
2564    // Direct first: the caller's own slug and hub-hosted public handles resolve
2565    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2566    // (404) falls through to the federation registry — how a handle reaches a
2567    // brain on ANOTHER node (link.md §7.1, E5).
2568    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2569    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2570        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2571            return Ok(card);
2572        }
2573    }
2574    let mut resolved = ensure_ok(direct, "resolve")?;
2575    if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2576        let v2 = v2_verified_head(cfg, &addr.brain)?
2577            .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2578        if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2579            return Err(invalid_feed(
2580                "resolve card is not bound to the verified v2 brain",
2581            ));
2582        }
2583        let card_identity: FeedIdentity = serde_json::from_value(
2584            resolved
2585                .get("identity")
2586                .cloned()
2587                .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2588        )
2589        .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2590        if card_identity != v2_identity(&v2.identity) {
2591            return Err(invalid_feed(
2592                "resolve card identity differs from the verified v2 identity",
2593            ));
2594        }
2595        accept_v2_head(cfg, &v2)?;
2596        if let Value::Object(card) = &mut resolved {
2597            card.insert(
2598                "headSeq".to_string(),
2599                json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2600            );
2601            card.insert(
2602                "feedHash".to_string(),
2603                v2.pointer
2604                    .as_ref()
2605                    .map(|pointer| Value::String(pointer.feed_hash.clone()))
2606                    .unwrap_or(Value::Null),
2607            );
2608            card.insert(
2609                "storageProfile".to_string(),
2610                Value::String("v2".to_string()),
2611            );
2612            if let Some(pointer) = &v2.pointer {
2613                card.insert(
2614                    "updatedAt".to_string(),
2615                    Value::String(pointer.signed_at.clone()),
2616                );
2617            }
2618        }
2619        return Ok(resolved);
2620    }
2621    // A successful direct response is accepted only after the same centralized
2622    // identity/rotation/feed checkpoint verification used by sync and
2623    // subscribe. No verb gets a weaker ad-hoc pinning path.
2624    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2625    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2626        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2627        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2628    {
2629        return Err(invalid_feed(
2630            "resolve card is not bound to the exact verified feed checkpoint",
2631        ));
2632    }
2633    let card_identity: FeedIdentity = serde_json::from_value(
2634        resolved
2635            .get("identity")
2636            .cloned()
2637            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2638    )
2639    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2640    if remote.identity.as_ref() != Some(&card_identity) {
2641        return Err(invalid_feed(
2642            "resolve card identity differs from the verified feed identity",
2643        ));
2644    }
2645    Ok(resolved)
2646}
2647
2648/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2649/// mutable query/index result is intentionally not consulted: a signed pack
2650/// digest is the only cryptographic binding between a feed checkpoint and
2651/// record bytes in wire profile v1.
2652fn resolve_from_verified_pack(
2653    brain: &str,
2654    target: &AddressTarget,
2655    pack: Vec<u8>,
2656) -> LinkResult<Value> {
2657    let entries = parse_store_pack(pack)?;
2658    let mut matched: Option<(String, Vec<u8>)> = None;
2659
2660    for (path, bytes) in entries {
2661        let is_candidate = match target {
2662            AddressTarget::Path(want) => &path == want,
2663            AddressTarget::Id(_) => {
2664                path.ends_with(".md")
2665                    && (path.starts_with("records/") || path.starts_with("sources/"))
2666            }
2667        };
2668        if !is_candidate {
2669            continue;
2670        }
2671        let text = std::str::from_utf8(&bytes)
2672            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2673        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2674            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2675        if let AddressTarget::Id(want) = target {
2676            let frontmatter =
2677                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2678                    .map_err(|_| {
2679                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2680                    })?;
2681            if frontmatter.id.as_deref() != Some(want) {
2682                continue;
2683            }
2684        }
2685        if matched.is_some() {
2686            return Err(invalid_feed(
2687                "signed snapshot contains more than one record for the requested target",
2688            ));
2689        }
2690        matched = Some((path, bytes));
2691    }
2692
2693    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2694        what: "resolve",
2695        status: 404,
2696        message: "record not found".to_string(),
2697        code: Some("NOT_FOUND".to_string()),
2698        details: None,
2699    })?;
2700    resolve_from_verified_record_bytes(brain, target, path, bytes)
2701}
2702
2703fn resolve_from_verified_record_bytes(
2704    brain: &str,
2705    target: &AddressTarget,
2706    path: String,
2707    bytes: Vec<u8>,
2708) -> LinkResult<Value> {
2709    match target {
2710        AddressTarget::Path(expected) if expected != &path => {
2711            return Err(invalid_feed(
2712                "verified record path differs from the requested path",
2713            ));
2714        }
2715        AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2716            return Err(invalid_feed(
2717                "verified id resolved outside records or sources",
2718            ));
2719        }
2720        _ => {}
2721    }
2722    let text = std::str::from_utf8(&bytes)
2723        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2724    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2725        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2726    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2727        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2728    let Value::Object(fields) = frontmatter else {
2729        return Err(invalid_feed(format!(
2730            "signed snapshot record `{path}` frontmatter is not a mapping"
2731        )));
2732    };
2733    if let AddressTarget::Id(expected) = target {
2734        if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2735            return Err(invalid_feed(
2736                "verified record id differs from the requested id",
2737            ));
2738        }
2739    }
2740    let mut document = serde_json::Map::new();
2741    document.insert("path".to_string(), Value::String(path));
2742    for (key, value) in fields {
2743        document.insert(key, value);
2744    }
2745    document.insert("body".to_string(), Value::String(parsed.body));
2746    document.insert(
2747        "contentSha".to_string(),
2748        Value::String(content_sha256(&bytes)),
2749    );
2750    Ok(json!({
2751        "brain": brain,
2752        "document": Value::Object(document),
2753    }))
2754}
2755
2756// ─────────────────────────────────────────────────────────────────────────────
2757// sync — pull the granted slice as files; push the local store as a snapshot
2758// ─────────────────────────────────────────────────────────────────────────────
2759
2760/// What a pull materialized.
2761#[derive(Debug, Clone, serde::Serialize)]
2762pub struct PullReport {
2763    /// The brain id the hub reported.
2764    pub brain: String,
2765    /// The brain's slug.
2766    pub slug: String,
2767    /// The hub's feed head at export time.
2768    #[serde(rename = "headSeq")]
2769    pub head_seq: u64,
2770    /// How many files were written.
2771    pub files: usize,
2772    /// Where they were written (as given or derived from the slug).
2773    pub dest: String,
2774    /// Local content files that the export did not carry — present so a
2775    /// caller sees divergence; nothing is ever deleted locally.
2776    #[serde(rename = "extraLocal")]
2777    pub extra_local: Vec<String>,
2778    /// Exact convergence result from the post-install barrier.
2779    #[serde(rename = "syncStatus")]
2780    pub sync_status: String,
2781}
2782
2783struct V2PulledSnapshot {
2784    report: PullReport,
2785    head: V2VerifiedHead,
2786    files: std::collections::BTreeMap<String, V2BaselineFile>,
2787    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2788    local: V2LocalView,
2789    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2790}
2791
2792fn download_verified_snapshot_pack(
2793    cfg: &HubConfig,
2794    brain: &str,
2795    remote: &VerifiedRemote,
2796) -> LinkResult<Vec<u8>> {
2797    let feed_hash = remote
2798        .head
2799        .feed_hash
2800        .as_deref()
2801        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2802    let signed_head = remote
2803        .head_entry
2804        .as_ref()
2805        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2806    let expected = &signed_head.entry.pack_sha256;
2807    if !is_sha256(expected) {
2808        return Err(invalid_feed(
2809            "signed head carries an invalid snapshot pack digest",
2810        ));
2811    }
2812    let path = format!(
2813        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2814        remote.head.seq
2815    );
2816    let body = ensure_ok(
2817        request(cfg, "GET", &path, None, Auth::Required)?,
2818        "sync pull",
2819    )?;
2820    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2821        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2822        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2823        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2824    {
2825        return Err(invalid_feed(
2826            "export response is not bound to the exact verified snapshot",
2827        ));
2828    }
2829    let url = body
2830        .get("url")
2831        .and_then(Value::as_str)
2832        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2833    let bytes = get_presigned(cfg, url)?;
2834    if content_sha256(&bytes) != *expected {
2835        return Err(LinkError::InvalidPack {
2836            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2837        });
2838    }
2839    let entries = parse_store_pack(bytes.clone())?;
2840    if signed_head.entry.kind == "push" {
2841        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2842    }
2843    Ok(bytes)
2844}
2845
2846#[derive(Debug, Clone, Deserialize, Serialize)]
2847struct V2PointerBody {
2848    v: u8,
2849    brain: String,
2850    seq: u64,
2851    commit_hash: String,
2852    feed_hash: String,
2853    content_root: Option<String>,
2854    asset_root: Option<String>,
2855    materializer: String,
2856    signer_epoch: u64,
2857    control_revision: String,
2858    backup_preparation: String,
2859    prior_pointer_hash: Option<String>,
2860    signed_at: String,
2861}
2862
2863#[derive(Debug, Clone, Deserialize)]
2864struct V2SignedPointer {
2865    pointer: V2PointerBody,
2866    hub_public_key: String,
2867    hub_fingerprint: String,
2868    sig: String,
2869}
2870
2871#[derive(Debug, Clone, Deserialize)]
2872struct V2HeadIdentity {
2873    #[serde(default)]
2874    custody: String,
2875    fingerprint: String,
2876    public_key_spki: String,
2877    #[serde(default)]
2878    previous: Vec<V2PreviousIdentity>,
2879    #[serde(default)]
2880    rotations: Vec<String>,
2881}
2882
2883#[derive(Debug, Clone, Deserialize)]
2884struct V2PreviousIdentity {
2885    fingerprint: String,
2886    public_key_spki: String,
2887}
2888
2889#[derive(Debug, Deserialize)]
2890struct V2HeadResponse {
2891    v: u8,
2892    brain_id: String,
2893    profile: String,
2894    view: Option<V2HeadView>,
2895    pointer: Option<V2SignedPointer>,
2896    identity: Option<V2HeadIdentity>,
2897}
2898
2899#[derive(Debug, Clone, Deserialize)]
2900struct V2HeadView {
2901    kind: String,
2902    #[serde(default)]
2903    id: Option<String>,
2904    control_revision: String,
2905}
2906
2907#[derive(Debug, Clone)]
2908struct V2VerifiedHead {
2909    requested: String,
2910    brain_id: String,
2911    view_kind: String,
2912    /// Stable identity of the exact current-content path projection.
2913    view_revision: String,
2914    /// Use-time authority revision independently bound into writes/signatures.
2915    control_revision: String,
2916    identity: V2HeadIdentity,
2917    pointer: Option<V2PointerBody>,
2918    trust: TrustState,
2919    alias: Option<AliasBinding>,
2920}
2921
2922fn verify_v2_spki_signature(
2923    public_key: &str,
2924    message: &[u8],
2925    signature: &str,
2926) -> LinkResult<Vec<u8>> {
2927    let der = URL_SAFE_NO_PAD
2928        .decode(public_key)
2929        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2930    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2931        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2932    }
2933    let sig = URL_SAFE_NO_PAD
2934        .decode(signature)
2935        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2936    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2937        .verify(message, &sig)
2938        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2939    Ok(der)
2940}
2941
2942fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2943    if pointer.pointer.v != 2
2944        || pointer.pointer.brain != expected_brain
2945        || pointer.pointer.seq == 0
2946        || !is_sha256(&pointer.pointer.commit_hash)
2947        || !is_sha256(&pointer.pointer.feed_hash)
2948        || pointer
2949            .pointer
2950            .content_root
2951            .as_deref()
2952            .is_some_and(|hash| !is_sha256(hash))
2953        || !is_sha256(&pointer.pointer.backup_preparation)
2954    {
2955        return Err(invalid_feed("v2 pointer fields are invalid"));
2956    }
2957    let value = serde_json::to_value(&pointer.pointer)
2958        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2959    let message = crate::linkmd_v2::canonical_bytes(&value)
2960        .map_err(|error| invalid_feed(error.to_string()))?;
2961    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2962    let fingerprint = format!("{:x}", Sha256::digest(&der));
2963    if fingerprint != pointer.hub_fingerprint {
2964        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2965    }
2966    Ok(format!(
2967        "{}:{}",
2968        pointer.hub_fingerprint, pointer.hub_public_key
2969    ))
2970}
2971
2972fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2973    FeedIdentity {
2974        fingerprint: identity.fingerprint.clone(),
2975        public_key_spki: identity.public_key_spki.clone(),
2976        previous: identity
2977            .previous
2978            .iter()
2979            .map(|previous| PreviousIdentity {
2980                fingerprint: previous.fingerprint.clone(),
2981                public_key_spki: previous.public_key_spki.clone(),
2982            })
2983            .collect(),
2984        rotations: identity.rotations.clone(),
2985    }
2986}
2987
2988fn verified_v2_commit_object(
2989    raw: &[u8],
2990    identity: &V2HeadIdentity,
2991) -> LinkResult<serde_json::Map<String, Value>> {
2992    let mut value: Value =
2993        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2994    let canonical = crate::linkmd_v2::canonical_bytes(&value)
2995        .map_err(|error| invalid_feed(error.to_string()))?;
2996    if canonical != raw {
2997        return Err(invalid_feed("v2 commit is not canonical JSON"));
2998    }
2999    let object = value
3000        .as_object_mut()
3001        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
3002    let sig = object
3003        .remove("sig")
3004        .and_then(|value| value.as_str().map(str::to_string))
3005        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
3006    const FIELDS: [&str; 18] = [
3007        "actor_ref",
3008        "asset_root",
3009        "brain",
3010        "changes_sha256",
3011        "control_revision",
3012        "materializer",
3013        "op",
3014        "parent_asset_root",
3015        "parent_commit",
3016        "parent_root",
3017        "prev_entry_hash",
3018        "public_key",
3019        "seq",
3020        "signer_epoch",
3021        "state_root",
3022        "ts",
3023        "v",
3024        "v1_bridge",
3025    ];
3026    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
3027        return Err(invalid_feed("v2 commit has a non-normative field set"));
3028    }
3029    let seq = object
3030        .get("seq")
3031        .and_then(Value::as_u64)
3032        .filter(|seq| *seq > 0)
3033        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
3034    let signer_epoch = object
3035        .get("signer_epoch")
3036        .and_then(Value::as_u64)
3037        .filter(|epoch| *epoch > 0)
3038        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
3039    let hash_or_null = |field: &str| {
3040        object
3041            .get(field)
3042            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
3043    };
3044    if object.get("v").and_then(Value::as_u64) != Some(2)
3045        || object.get("op").and_then(Value::as_str) != Some("changeset")
3046        || !object
3047            .get("changes_sha256")
3048            .and_then(Value::as_str)
3049            .is_some_and(is_sha256)
3050        || !object
3051            .get("actor_ref")
3052            .and_then(Value::as_str)
3053            .is_some_and(is_sha256)
3054        || !object
3055            .get("control_revision")
3056            .and_then(Value::as_str)
3057            .is_some_and(is_sha256)
3058        || !object
3059            .get("state_root")
3060            .and_then(Value::as_str)
3061            .is_some_and(is_sha256)
3062        || !hash_or_null("parent_commit")
3063        || !hash_or_null("parent_root")
3064        || !hash_or_null("parent_asset_root")
3065        || !hash_or_null("asset_root")
3066        || !hash_or_null("prev_entry_hash")
3067        || !object
3068            .get("materializer")
3069            .and_then(Value::as_str)
3070            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
3071        || !object
3072            .get("ts")
3073            .and_then(Value::as_str)
3074            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3075    {
3076        return Err(invalid_feed("v2 commit fields are invalid"));
3077    }
3078    if (seq == 1
3079        && [
3080            "parent_commit",
3081            "parent_root",
3082            "parent_asset_root",
3083            "prev_entry_hash",
3084        ]
3085        .iter()
3086        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3087        || (seq > 1
3088            && ["parent_commit", "parent_root", "prev_entry_hash"]
3089                .iter()
3090                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3091    {
3092        return Err(invalid_feed("v2 commit parent shape is invalid"));
3093    }
3094    match object.get("v1_bridge") {
3095        Some(Value::Null) => {}
3096        Some(Value::Object(bridge))
3097            if seq == 1
3098                && bridge.len() == 3
3099                && bridge
3100                    .get("head_seq")
3101                    .and_then(Value::as_u64)
3102                    .is_some_and(|v| v > 0)
3103                && bridge
3104                    .get("feed_hash")
3105                    .and_then(Value::as_str)
3106                    .is_some_and(is_sha256)
3107                && bridge
3108                    .get("pack_sha256")
3109                    .and_then(Value::as_str)
3110                    .is_some_and(is_sha256) => {}
3111        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3112    }
3113    let public_key = object
3114        .get("public_key")
3115        .and_then(Value::as_str)
3116        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3117    let der = URL_SAFE_NO_PAD
3118        .decode(public_key)
3119        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3120    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3121    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3122        return Err(invalid_feed("v2 commit brain identity mismatch"));
3123    }
3124    // Verify the old-key-signed chain before trusting any prior public key.
3125    verify_identity_chain(&v2_identity(identity), None)?;
3126    // Old commits remain verifiable after brain-key rotation. `previous` is
3127    // newest-first, while signer epochs and rotation statements are oldest-first.
3128    let mut chain: Vec<(&str, &str)> = identity
3129        .previous
3130        .iter()
3131        .rev()
3132        .map(|previous| {
3133            (
3134                previous.fingerprint.as_str(),
3135                previous.public_key_spki.as_str(),
3136            )
3137        })
3138        .collect();
3139    chain.push((&identity.fingerprint, &identity.public_key_spki));
3140    let signer_index = chain.iter().position(|(fingerprint, spki)| {
3141        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3142    });
3143    let Some(signer_index) = signer_index else {
3144        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3145    };
3146    if signer_epoch != signer_index as u64 + 1 {
3147        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3148    }
3149    let lower_boundary = if signer_index == 0 {
3150        None
3151    } else {
3152        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3153            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3154        Some(prior.prior_head_seq)
3155    };
3156    let upper_boundary = if signer_index == identity.rotations.len() {
3157        None
3158    } else {
3159        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3160            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3161        Some(next.prior_head_seq)
3162    };
3163    if lower_boundary.is_some_and(|boundary| seq <= boundary)
3164        || upper_boundary.is_some_and(|boundary| seq > boundary)
3165    {
3166        return Err(invalid_feed(
3167            "v2 commit signer is outside its authenticated rotation epoch",
3168        ));
3169    }
3170    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3171        .map_err(|error| invalid_feed(error.to_string()))?;
3172    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3173    Ok(object.clone())
3174}
3175
3176#[derive(Debug, Deserialize)]
3177struct V2FeedWireEntry {
3178    seq: u64,
3179    commit_hash: String,
3180    feed_hash: String,
3181    bytes_base64: String,
3182}
3183
3184#[derive(Debug, Deserialize)]
3185struct V2FeedPage {
3186    v: u8,
3187    head_seq: u64,
3188    head_commit_hash: String,
3189    head_feed_hash: String,
3190    entries: Vec<V2FeedWireEntry>,
3191    next_after: u64,
3192    complete: bool,
3193}
3194
3195fn replay_v2_feed(
3196    cfg: &HubConfig,
3197    brain: &str,
3198    pointer: &V2PointerBody,
3199    identity: &V2HeadIdentity,
3200    start_after: u64,
3201    start_feed: Option<String>,
3202) -> LinkResult<()> {
3203    let mut after = start_after;
3204    let mut prior_feed = start_feed;
3205    let mut final_object = None;
3206    let mut replayed_entries = 0_u64;
3207    let mut replayed_bytes = 0_u64;
3208    while after < pointer.seq {
3209        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3210        let value = ensure_ok(
3211            request_capped(
3212                cfg,
3213                "GET",
3214                &path,
3215                None,
3216                Auth::Required,
3217                MAX_FEED_REPLAY_BYTES,
3218            )?,
3219            "v2 feed replay",
3220        )?;
3221        let page: V2FeedPage = serde_json::from_value(value)
3222            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3223        if page.v != 2
3224            || page.head_seq != pointer.seq
3225            || page.head_commit_hash != pointer.commit_hash
3226            || page.head_feed_hash != pointer.feed_hash
3227            || page.entries.is_empty()
3228            || page.entries.len() > FEED_PAGE_LIMIT
3229        {
3230            return Err(invalid_feed("v2 feed page differs from the signed head"));
3231        }
3232        for entry in page.entries {
3233            if entry.seq != after + 1
3234                || !is_sha256(&entry.commit_hash)
3235                || !is_sha256(&entry.feed_hash)
3236            {
3237                return Err(invalid_feed("v2 feed sequence is not contiguous"));
3238            }
3239            let raw = base64::engine::general_purpose::STANDARD
3240                .decode(&entry.bytes_base64)
3241                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3242            replayed_entries = replayed_entries
3243                .checked_add(1)
3244                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3245            replayed_bytes = replayed_bytes
3246                .checked_add(raw.len() as u64)
3247                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3248            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3249            {
3250                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3251            }
3252            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3253                .map_err(|error| invalid_feed(error.to_string()))?
3254                != entry.commit_hash
3255                || content_sha256(&raw) != entry.feed_hash
3256            {
3257                return Err(invalid_feed("v2 feed entry address mismatch"));
3258            }
3259            let object = verified_v2_commit_object(&raw, identity)?;
3260            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3261                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3262            {
3263                return Err(invalid_feed(
3264                    "v2 feed entry does not extend its predecessor",
3265                ));
3266            }
3267            after = entry.seq;
3268            prior_feed = Some(entry.feed_hash);
3269            final_object = Some((entry.commit_hash, object));
3270        }
3271        if page.next_after != after || (page.complete != (after == pointer.seq)) {
3272            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3273        }
3274    }
3275    let (final_hash, object) =
3276        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3277    if final_hash != pointer.commit_hash
3278        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3279        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3280        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3281        || object.get("control_revision").and_then(Value::as_str)
3282            != Some(pointer.control_revision.as_str())
3283        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3284    {
3285        return Err(invalid_feed(
3286            "v2 replay did not converge on the signed pointer",
3287        ));
3288    }
3289    Ok(())
3290}
3291
3292fn verify_v1_to_v2_bridge(
3293    cfg: &HubConfig,
3294    brain: &str,
3295    pointer: &V2PointerBody,
3296    identity: &V2HeadIdentity,
3297    checkpoint: &TrustState,
3298) -> LinkResult<()> {
3299    let value = ensure_ok(
3300        request_capped(
3301            cfg,
3302            "GET",
3303            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3304            None,
3305            Auth::Required,
3306            MAX_FEED_RESPONSE_BYTES,
3307        )?,
3308        "v2 genesis bridge",
3309    )?;
3310    let page: V2FeedPage = serde_json::from_value(value)
3311        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3312    if page.v != 2
3313        || page.head_seq != pointer.seq
3314        || page.head_commit_hash != pointer.commit_hash
3315        || page.head_feed_hash != pointer.feed_hash
3316        || page.entries.len() != 1
3317        || page.entries[0].seq != 1
3318        || !is_sha256(&page.entries[0].commit_hash)
3319        || !is_sha256(&page.entries[0].feed_hash)
3320    {
3321        return Err(invalid_feed(
3322            "v2 genesis bridge page differs from the signed head",
3323        ));
3324    }
3325    let first = &page.entries[0];
3326    let raw = STANDARD
3327        .decode(&first.bytes_base64)
3328        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3329    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3330        .map_err(|error| invalid_feed(error.to_string()))?
3331        != first.commit_hash
3332        || content_sha256(&raw) != first.feed_hash
3333    {
3334        return Err(invalid_feed("v2 genesis bridge address mismatch"));
3335    }
3336    let object = verified_v2_commit_object(&raw, identity)?;
3337    if checkpoint.head_seq == 0 {
3338        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3339            return Err(invalid_feed(
3340                "empty v1 checkpoint did not transition through an empty v2 genesis",
3341            ));
3342        }
3343        return Ok(());
3344    }
3345    let bridge = object
3346        .get("v1_bridge")
3347        .and_then(Value::as_object)
3348        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3349    let checkpoint_feed = checkpoint
3350        .feed_hash
3351        .as_deref()
3352        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3353    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3354        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3355    {
3356        return Err(invalid_feed(
3357            "v2 genesis bridge differs from the pinned v1 checkpoint",
3358        ));
3359    }
3360    let legacy_raw = ensure_raw_ok(
3361        request_raw(
3362            cfg,
3363            "GET",
3364            &format!(
3365                "/api/hub/brains/{brain}/feed?after={}&limit=1",
3366                checkpoint.head_seq - 1
3367            ),
3368            None,
3369            Auth::Required,
3370            MAX_FEED_RESPONSE_BYTES,
3371        )?,
3372        "v1 bridge boundary",
3373    )?;
3374    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3375        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3376    let legacy_identity = legacy
3377        .identity
3378        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3379    let item = legacy
3380        .entries
3381        .first()
3382        .filter(|_| legacy.entries.len() == 1)
3383        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3384    if legacy.scope_limited
3385        || legacy.head_seq != checkpoint.head_seq
3386        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3387        || item.entry.seq != checkpoint.head_seq
3388        || item.hash != checkpoint_feed
3389        || legacy_identity != v2_identity(identity)
3390        || bridge.get("pack_sha256").and_then(Value::as_str)
3391            != Some(item.entry.pack_sha256.as_str())
3392    {
3393        return Err(invalid_feed(
3394            "v1 bridge boundary differs from its signed legacy head",
3395        ));
3396    }
3397    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3398    if anchor != checkpoint.anchor {
3399        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3400    }
3401    verify_feed_item(item, &legacy_identity)?;
3402    verify_rotation_feed_boundaries(
3403        &legacy_identity,
3404        Some(checkpoint),
3405        std::slice::from_ref(item),
3406        checkpoint.head_seq,
3407    )?;
3408    Ok(())
3409}
3410
3411fn verify_v2_commit(
3412    cfg: &HubConfig,
3413    brain: &str,
3414    pointer: &V2PointerBody,
3415    identity: &V2HeadIdentity,
3416    pinned: Option<&TrustState>,
3417) -> LinkResult<()> {
3418    let path = format!(
3419        "/api/hub/brains/{brain}/v2/commit?commit={}",
3420        pointer.commit_hash
3421    );
3422    let raw = ensure_raw_ok(
3423        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3424        "v2 commit",
3425    )?;
3426    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3427        .map_err(|error| invalid_feed(error.to_string()))?
3428        != pointer.commit_hash
3429        || content_sha256(&raw) != pointer.feed_hash
3430    {
3431        return Err(invalid_feed("v2 commit address differs from the pointer"));
3432    }
3433    let object = verified_v2_commit_object(&raw, identity)?;
3434    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3435        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3436        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3437        || object.get("control_revision").and_then(Value::as_str)
3438            != Some(pointer.control_revision.as_str())
3439        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3440    {
3441        return Err(invalid_feed("v2 commit fields differ from the pointer"));
3442    }
3443    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3444        if pointer.seq == checkpoint.head_seq + 1
3445            && object.get("prev_entry_hash").and_then(Value::as_str)
3446                != checkpoint.feed_hash.as_deref()
3447        {
3448            return Err(invalid_feed(
3449                "v2 commit does not extend the pinned feed hash",
3450            ));
3451        }
3452        if pointer.seq > checkpoint.head_seq + 1 {
3453            return replay_v2_feed(
3454                cfg,
3455                brain,
3456                pointer,
3457                identity,
3458                checkpoint.head_seq,
3459                checkpoint.feed_hash.clone(),
3460            );
3461        }
3462    } else {
3463        if let Some(checkpoint) = pinned {
3464            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3465        }
3466        if pointer.seq > 1 {
3467            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3468        }
3469    }
3470    Ok(())
3471}
3472
3473fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3474    require_hardened_filesystem("verified link.md v2 state")?;
3475    require_safe_ref(brain)?;
3476    // Open and pin the private trust directory before a credential can leave
3477    // the process. A hostile checkout must not turn an unsafe state root into
3478    // a network oracle or receive a bearer before checkpoint safety is known.
3479    let trust_directory = open_trust_dir(cfg)?;
3480    let path = format!("/api/hub/brains/{brain}/v2/head");
3481    let response = request(cfg, "GET", &path, None, Auth::Required)?;
3482    if response.status == 404 {
3483        if has_accepted_v2_ref(cfg, brain)? {
3484            return Err(LinkError::BrainUnavailable);
3485        }
3486        return Ok(None);
3487    }
3488    let body = ensure_ok(response, "v2 head")?;
3489    let head: V2HeadResponse = serde_json::from_value(body)
3490        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3491    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3492        return Err(invalid_feed("v2 head has no canonical brain id"));
3493    }
3494    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3495        return Err(invalid_feed("v2 head resolved a different brain id"));
3496    }
3497    if head.profile == "v1" {
3498        return Ok(None);
3499    }
3500    if head.profile != "v2" && head.profile != "v2-empty" {
3501        return Err(invalid_feed("v2 head advertised an unknown profile"));
3502    }
3503    let view = head
3504        .view
3505        .as_ref()
3506        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3507    if !matches!(view.kind.as_str(), "full" | "scoped")
3508        || !is_sha256(&view.control_revision)
3509        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3510    {
3511        return Err(invalid_feed("v2 head has an invalid permission view"));
3512    }
3513    let view_kind = view.kind.clone();
3514    // `id` is the stable materialized namespace. Older v2 hubs did not expose
3515    // it, so retain the fail-closed control-revision fallback for compatibility.
3516    let view_revision = view
3517        .id
3518        .clone()
3519        .unwrap_or_else(|| view.control_revision.clone());
3520    let control_revision = view.control_revision.clone();
3521    let identity = head
3522        .identity
3523        .as_ref()
3524        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3525    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3526    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3527    let feed_identity = v2_identity(identity);
3528    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3529    let (seq, feed_hash, hub_signer) = match &head.pointer {
3530        None => {
3531            if head.profile != "v2-empty" {
3532                return Err(invalid_feed("initialized v2 head has no pointer"));
3533            }
3534            (
3535                0,
3536                None,
3537                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3538            )
3539        }
3540        Some(signed) => {
3541            let signer = verify_v2_pointer(signed, &head.brain_id)?;
3542            if pinned
3543                .as_ref()
3544                .and_then(|state| state.hub_signer.as_ref())
3545                .is_some_and(|known| known != &signer)
3546            {
3547                return Err(invalid_feed(
3548                    "v2 hub pointer signer changed without a trust transition",
3549                ));
3550            }
3551            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3552                if signed.pointer.seq < checkpoint.head_seq
3553                    || (signed.pointer.seq == checkpoint.head_seq
3554                        && checkpoint.feed_hash.as_deref()
3555                            != Some(signed.pointer.feed_hash.as_str()))
3556                {
3557                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3558                }
3559            }
3560            verify_v2_commit(
3561                cfg,
3562                &head.brain_id,
3563                &signed.pointer,
3564                identity,
3565                pinned.as_ref(),
3566            )?;
3567            (
3568                signed.pointer.seq,
3569                Some(signed.pointer.feed_hash.clone()),
3570                Some(signer),
3571            )
3572        }
3573    };
3574    let trust = TrustState {
3575        v: 2,
3576        origin: normalized_origin(&cfg.hub)?,
3577        requested: head.brain_id.clone(),
3578        brain: head.brain_id.clone(),
3579        home: None,
3580        anchor,
3581        current: format!("ed25519:{}", identity.fingerprint),
3582        head_seq: seq,
3583        feed_hash,
3584        rotations: identity.rotations.clone(),
3585        hub_signer,
3586        protocol_profile: Some("link-v2".to_string()),
3587    };
3588    Ok(Some(V2VerifiedHead {
3589        requested: brain.to_string(),
3590        brain_id: head.brain_id,
3591        view_kind,
3592        view_revision,
3593        control_revision,
3594        identity: identity.clone(),
3595        pointer: head.pointer.map(|signed| signed.pointer),
3596        trust,
3597        alias: alias_binding,
3598    }))
3599}
3600
3601fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3602    let directory = open_trust_dir(cfg)?;
3603    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3604    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3605    if let Some(current) = current {
3606        let common_invalid = head.trust.anchor != current.anchor
3607            || !head.trust.rotations.starts_with(&current.rotations);
3608        let profile_invalid = if accepted_as_v2(&current) {
3609            head.trust.head_seq < current.head_seq
3610                || (head.trust.head_seq == current.head_seq
3611                    && head.trust.feed_hash != current.feed_hash)
3612                || current
3613                    .hub_signer
3614                    .as_ref()
3615                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3616        } else {
3617            head.trust.protocol_profile.as_deref() != Some("link-v2")
3618                || head.trust.hub_signer.is_none()
3619        };
3620        if common_invalid || profile_invalid {
3621            return Err(invalid_feed(
3622                "v2 head cannot advance the currently accepted trust checkpoint",
3623            ));
3624        }
3625    }
3626    save_canonical_pin_and_alias(
3627        cfg,
3628        &directory,
3629        &head.requested,
3630        &head.brain_id,
3631        head.trust.clone(),
3632        alias.as_ref().or(head.alias.as_ref()),
3633    )
3634}
3635
3636#[derive(Debug, Clone, Deserialize, Serialize)]
3637struct V2BaselineFile {
3638    sha256: String,
3639    bytes: u64,
3640    #[serde(skip)]
3641    proof: Option<Vec<V2ProofStep>>,
3642}
3643
3644#[derive(Debug, Clone, Deserialize, Serialize)]
3645struct V2SyncBaseline {
3646    v: u8,
3647    origin: String,
3648    brain: String,
3649    #[serde(default)]
3650    checkout_id: Option<String>,
3651    #[serde(default)]
3652    head_seq: Option<u64>,
3653    commit_hash: Option<String>,
3654    content_root: Option<String>,
3655    #[serde(default)]
3656    asset_root: Option<String>,
3657    #[serde(default)]
3658    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3659    #[serde(default)]
3660    view_kind: Option<String>,
3661    #[serde(default)]
3662    view_revision: Option<String>,
3663    /// The authority revision observed with this materialized view. Older
3664    /// baselines omit it and deliberately take one full verified refresh
3665    /// before they become eligible for unchanged-head reuse.
3666    #[serde(default)]
3667    control_revision: Option<String>,
3668    #[serde(default)]
3669    projection_sha256: Option<String>,
3670    files: std::collections::BTreeMap<String, V2BaselineFile>,
3671    #[serde(default)]
3672    local_policy_digest: Option<String>,
3673    #[serde(default)]
3674    local_eligibility: std::collections::BTreeMap<String, bool>,
3675    #[serde(default)]
3676    remote_copy_remains: std::collections::BTreeMap<String, String>,
3677}
3678
3679struct V2LocalView {
3680    riding: std::collections::BTreeMap<String, (String, u64)>,
3681    eligibility: std::collections::BTreeMap<String, bool>,
3682    policy: crate::linkmd_sync_policy::SyncPolicy,
3683    withheld_links: Vec<V2WithheldLink>,
3684}
3685
3686#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3687struct V2WithheldLink {
3688    source: String,
3689    target: String,
3690}
3691
3692#[derive(Debug, Clone, Deserialize, Serialize)]
3693struct V2ProofStep {
3694    directory_root: String,
3695    component: String,
3696    proof: crate::linkmd_v2::HamtProof,
3697}
3698
3699#[derive(Debug, Deserialize)]
3700struct V2ManifestFile {
3701    path: String,
3702    sha256: String,
3703    bytes: u64,
3704    proof: Vec<V2ProofStep>,
3705}
3706
3707#[derive(Debug, Deserialize)]
3708struct V2ManifestPage {
3709    v: u8,
3710    commit: String,
3711    content_root: Option<String>,
3712    files: Vec<V2ManifestFile>,
3713    next_cursor: Option<String>,
3714}
3715
3716#[derive(Debug, Clone, Deserialize, Serialize)]
3717struct V2BaselineAsset {
3718    blob_sha256: String,
3719    bytes: u64,
3720    media_type: String,
3721    wrappers: Vec<String>,
3722    required: bool,
3723    disposition: String,
3724    leaf_hash: String,
3725}
3726
3727#[derive(Debug, Deserialize)]
3728struct V2AssetManifestItem {
3729    path: String,
3730    blob_sha256: String,
3731    bytes: u64,
3732    media_type: String,
3733    wrappers: Vec<String>,
3734    required: bool,
3735    disposition: String,
3736    leaf_hash: String,
3737    proof: crate::linkmd_v2::HamtProof,
3738}
3739
3740#[derive(Debug, Deserialize)]
3741struct V2AssetManifestPage {
3742    v: u8,
3743    commit: String,
3744    asset_root: Option<String>,
3745    assets: Vec<V2AssetManifestItem>,
3746    next_cursor: Option<String>,
3747}
3748
3749#[derive(Debug, Deserialize)]
3750struct V2SigningCandidate {
3751    seq: u64,
3752    content_root: Option<String>,
3753    asset_root: Option<String>,
3754    signing_bytes_base64: String,
3755    changes_base64: String,
3756    actor_claim_base64: String,
3757}
3758
3759#[derive(Debug, Deserialize)]
3760struct V2SigningCandidatePage {
3761    v: u8,
3762    challenge_id: String,
3763    mutation_id: String,
3764    request_hash: String,
3765    parent: V2SigningParent,
3766    candidate: V2SigningCandidate,
3767    files: Vec<V2ManifestFile>,
3768    #[serde(default)]
3769    assets: Vec<V2AssetManifestItem>,
3770    next_cursor: Option<String>,
3771    expires_at: String,
3772}
3773
3774#[derive(Debug, Deserialize)]
3775struct V2SigningParent {
3776    seq: u64,
3777    commit_hash: Option<String>,
3778}
3779
3780fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3781    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3782        .map_err(|error| invalid_feed(error.to_string()))?;
3783    let components = normalized.split('/').collect::<Vec<_>>();
3784    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3785        return Err(invalid_feed("v2 file proof has the wrong shape"));
3786    }
3787    let mut directory_root = root.to_string();
3788    for (index, step) in file.proof.iter().enumerate() {
3789        if step.directory_root != directory_root || step.component != components[index] {
3790            return Err(invalid_feed(
3791                "v2 file proof path chain differs from its manifest",
3792            ));
3793        }
3794        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3795            .map_err(|error| invalid_feed(error.to_string()))?
3796        {
3797            return Err(invalid_feed("v2 file proof failed verification"));
3798        }
3799        let entry = match &step.proof {
3800            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3801            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3802                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3803            }
3804        };
3805        if index + 1 == components.len() {
3806            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3807                || entry.child_hash != file.sha256
3808                || entry.bytes != Some(file.bytes)
3809            {
3810                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3811            }
3812        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3813            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3814        } else {
3815            directory_root = entry.child_hash.clone();
3816        }
3817    }
3818    Ok(())
3819}
3820
3821fn v2_manifest(
3822    cfg: &HubConfig,
3823    brain: &str,
3824    pointer: Option<&V2PointerBody>,
3825) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3826    let Some(pointer) = pointer else {
3827        return Ok(std::collections::BTreeMap::new());
3828    };
3829    let Some(root) = pointer.content_root.as_deref() else {
3830        return Ok(std::collections::BTreeMap::new());
3831    };
3832    let mut files = std::collections::BTreeMap::new();
3833    let mut after = String::new();
3834    loop {
3835        let encoded_after: String =
3836            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3837        let path = format!(
3838            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3839            pointer.commit_hash
3840        );
3841        let value = ensure_ok(
3842            request_capped(
3843                cfg,
3844                "GET",
3845                &path,
3846                None,
3847                Auth::Required,
3848                MAX_FEED_RESPONSE_BYTES,
3849            )?,
3850            "v2 file manifest",
3851        )?;
3852        let page: V2ManifestPage = serde_json::from_value(value)
3853            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3854        if page.v != 2
3855            || page.commit != pointer.commit_hash
3856            || page.content_root.as_deref() != Some(root)
3857            || page.files.len() > 500
3858        {
3859            return Err(invalid_feed(
3860                "v2 file manifest is not bound to the verified head",
3861            ));
3862        }
3863        for file in page.files {
3864            verify_v2_file_proof(root, &file)?;
3865            if files
3866                .insert(
3867                    file.path.clone(),
3868                    V2BaselineFile {
3869                        sha256: file.sha256,
3870                        bytes: file.bytes,
3871                        proof: Some(file.proof),
3872                    },
3873                )
3874                .is_some()
3875            {
3876                return Err(invalid_feed("v2 file manifest repeats a path"));
3877            }
3878            if files.len() > MAX_PUSH_FILES {
3879                return Err(invalid_feed(
3880                    "v2 file manifest exceeds the file-count bound",
3881                ));
3882            }
3883        }
3884        match page.next_cursor {
3885            None => break,
3886            Some(next) if next > after => after = next,
3887            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3888        }
3889    }
3890    Ok(files)
3891}
3892
3893/// Fetch one exact path proof instead of enumerating the whole permission
3894/// view. This keeps `resolve @brain/path` proportional to path depth even for
3895/// a very large brain. The returned leaf is accepted only after its complete
3896/// proof chain reaches the content root signed by `pointer`.
3897fn v2_manifest_file(
3898    cfg: &HubConfig,
3899    brain: &str,
3900    pointer: &V2PointerBody,
3901    path: &str,
3902) -> LinkResult<Option<V2BaselineFile>> {
3903    let Some(root) = pointer.content_root.as_deref() else {
3904        return Ok(None);
3905    };
3906    crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3907        path: error.to_string(),
3908    })?;
3909    let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3910    let response = request_capped(
3911        cfg,
3912        "GET",
3913        &format!(
3914            "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3915            pointer.commit_hash
3916        ),
3917        None,
3918        Auth::Required,
3919        MAX_FEED_RESPONSE_BYTES,
3920    )?;
3921    // Exact v2 file reads deliberately collapse absent and unreadable paths
3922    // into the same 404. Preserve that non-oracle as a normal `None`; callers
3923    // such as `resolve` then produce the stable, local `NOT_FOUND` contract.
3924    if response.status == 404 {
3925        return Ok(None);
3926    }
3927    let value = ensure_ok(response, "v2 exact file proof")?;
3928    let mut page: V2ManifestPage = serde_json::from_value(value)
3929        .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3930    if page.v != 2
3931        || page.commit != pointer.commit_hash
3932        || page.content_root.as_deref() != Some(root)
3933        || page.next_cursor.is_some()
3934        || page.files.len() != 1
3935        || page.files[0].path != path
3936    {
3937        return Err(invalid_feed(
3938            "v2 exact file proof is not bound to the requested signed path",
3939        ));
3940    }
3941    let file = page.files.pop().expect("exactly one file was checked");
3942    verify_v2_file_proof(root, &file)?;
3943    Ok(Some(V2BaselineFile {
3944        sha256: file.sha256,
3945        bytes: file.bytes,
3946        proof: Some(file.proof),
3947    }))
3948}
3949
3950/// Resolve an id through the hub's synchronously maintained, content-light
3951/// validation certificate, then require the same exact signed path proof as a
3952/// path lookup. The certificate is only an efficient locator: the downloaded
3953/// bytes are parsed again and their id is checked by the caller.
3954fn v2_manifest_file_by_id(
3955    cfg: &HubConfig,
3956    brain: &str,
3957    pointer: &V2PointerBody,
3958    id: &str,
3959) -> LinkResult<(String, V2BaselineFile)> {
3960    let root = pointer
3961        .content_root
3962        .as_deref()
3963        .ok_or_else(|| LinkError::Http {
3964            what: "resolve",
3965            status: 404,
3966            message: "record not found".to_string(),
3967            code: Some("NOT_FOUND".to_string()),
3968            details: None,
3969        })?;
3970    if !crate::ulid::is_ulid(id) {
3971        return Err(LinkError::BadAddress {
3972            given: id.to_string(),
3973            reason: BAD_TARGET_REASON.to_string(),
3974        });
3975    }
3976    let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3977    let value = ensure_ok(
3978        request_capped(
3979            cfg,
3980            "GET",
3981            &format!(
3982                "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3983                pointer.commit_hash
3984            ),
3985            None,
3986            Auth::Required,
3987            MAX_FEED_RESPONSE_BYTES,
3988        )?,
3989        "v2 exact id proof",
3990    )?;
3991    let mut page: V2ManifestPage = serde_json::from_value(value)
3992        .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3993    if page.v != 2
3994        || page.commit != pointer.commit_hash
3995        || page.content_root.as_deref() != Some(root)
3996        || page.next_cursor.is_some()
3997        || page.files.len() != 1
3998    {
3999        return Err(invalid_feed(
4000            "v2 exact id proof is not bound to one signed path",
4001        ));
4002    }
4003    let file = page.files.pop().expect("exactly one file was checked");
4004    if !safe_store_rel_path(&file.path)
4005        || !file.path.ends_with(".md")
4006        || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4007    {
4008        return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4009    }
4010    verify_v2_file_proof(root, &file)?;
4011    Ok((
4012        file.path,
4013        V2BaselineFile {
4014            sha256: file.sha256,
4015            bytes: file.bytes,
4016            proof: Some(file.proof),
4017        },
4018    ))
4019}
4020
4021fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4022    crate::linkmd_v2::normalize_path(&item.path)
4023        .map_err(|error| invalid_feed(error.to_string()))?;
4024    if !is_sha256(&item.blob_sha256)
4025        || !is_sha256(&item.leaf_hash)
4026        || item.bytes > MAX_ASSET_BYTES
4027        || item.wrappers.is_empty()
4028        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4029        || item
4030            .wrappers
4031            .iter()
4032            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4033    {
4034        return Err(invalid_feed("v2 asset manifest item is invalid"));
4035    }
4036    let leaf = json!({
4037        "blob_sha256": item.blob_sha256,
4038        "bytes": item.bytes,
4039        "disposition": item.disposition,
4040        "media_type": item.media_type,
4041        "path": item.path,
4042        "required": item.required,
4043        "v": 2,
4044        "wrappers": item.wrappers,
4045    });
4046    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4047        .map_err(|error| invalid_feed(error.to_string()))?
4048        != item.leaf_hash
4049        || !crate::linkmd_v2::verify_proof_with_domain(
4050            root,
4051            &item.path,
4052            &item.proof,
4053            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4054        )
4055        .map_err(|error| invalid_feed(error.to_string()))?
4056    {
4057        return Err(invalid_feed("v2 asset inclusion proof failed"));
4058    }
4059    match &item.proof {
4060        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4061            if entry.name == item.path
4062                && entry.kind == crate::linkmd_v2::EntryKind::Blob
4063                && entry.child_hash == item.leaf_hash
4064                && entry.bytes == Some(item.bytes) =>
4065        {
4066            Ok(())
4067        }
4068        _ => Err(invalid_feed(
4069            "v2 asset proof leaf differs from its manifest",
4070        )),
4071    }
4072}
4073
4074fn v2_asset_manifest(
4075    cfg: &HubConfig,
4076    brain: &str,
4077    pointer: Option<&V2PointerBody>,
4078) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4079    let Some(pointer) = pointer else {
4080        return Ok(std::collections::BTreeMap::new());
4081    };
4082    let Some(root) = pointer.asset_root.as_deref() else {
4083        return Ok(std::collections::BTreeMap::new());
4084    };
4085    let mut assets = std::collections::BTreeMap::new();
4086    let mut after = String::new();
4087    loop {
4088        let encoded_after: String =
4089            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4090        let path = format!(
4091            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4092            pointer.commit_hash
4093        );
4094        let value = ensure_ok(
4095            request_capped(
4096                cfg,
4097                "GET",
4098                &path,
4099                None,
4100                Auth::Required,
4101                MAX_FEED_RESPONSE_BYTES,
4102            )?,
4103            "v2 asset manifest",
4104        )?;
4105        let page: V2AssetManifestPage = serde_json::from_value(value)
4106            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4107        if page.v != 2
4108            || page.commit != pointer.commit_hash
4109            || page.asset_root.as_deref() != Some(root)
4110            || page.assets.len() > 500
4111        {
4112            return Err(invalid_feed(
4113                "v2 asset manifest is not bound to the verified head",
4114            ));
4115        }
4116        for item in page.assets {
4117            verify_v2_asset_proof(root, &item)?;
4118            let path = item.path.clone();
4119            if assets
4120                .insert(
4121                    path,
4122                    V2BaselineAsset {
4123                        blob_sha256: item.blob_sha256,
4124                        bytes: item.bytes,
4125                        media_type: item.media_type,
4126                        wrappers: item.wrappers,
4127                        required: item.required,
4128                        disposition: item.disposition,
4129                        leaf_hash: item.leaf_hash,
4130                    },
4131                )
4132                .is_some()
4133            {
4134                return Err(invalid_feed("v2 asset manifest repeats a path"));
4135            }
4136            if assets.len() > MAX_PUSH_FILES {
4137                return Err(invalid_feed(
4138                    "v2 asset manifest exceeds the item-count bound",
4139                ));
4140            }
4141        }
4142        match page.next_cursor {
4143            None => break,
4144            Some(next) if next > after => after = next,
4145            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4146        }
4147    }
4148    Ok(assets)
4149}
4150
4151fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4152    crate::AssetRecord {
4153        path: path.to_string(),
4154        sha256: asset.blob_sha256.clone(),
4155        bytes: asset.bytes,
4156        media_type: asset.media_type.clone(),
4157        wrappers: asset.wrappers.clone(),
4158        required: asset.required,
4159    }
4160}
4161
4162fn v2_asset_resumes_hosting(
4163    remote: Option<&V2BaselineAsset>,
4164    path: &str,
4165    record: &crate::AssetRecord,
4166    disposition: &str,
4167) -> bool {
4168    remote.is_some_and(|asset| {
4169        asset.disposition == "withheld"
4170            && disposition == "hosted"
4171            && v2_asset_record(asset, path) == *record
4172    })
4173}
4174
4175fn v2_asset_inherits_withheld_absence(
4176    base: Option<&V2BaselineAsset>,
4177    base_record: Option<&crate::AssetRecord>,
4178    local_record: Option<&crate::AssetRecord>,
4179    raw_present: bool,
4180) -> bool {
4181    !raw_present
4182        && base.is_some_and(|asset| asset.disposition == "withheld")
4183        && local_record == base_record
4184}
4185
4186fn v2_asset_record_manifest_bytes(
4187    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4188) -> LinkResult<Vec<u8>> {
4189    let mut bytes = Vec::new();
4190    for (path, asset) in assets {
4191        if asset.path != *path {
4192            return Err(invalid_feed(
4193                "local asset manifest key differs from its record path",
4194            ));
4195        }
4196        serde_json::to_writer(&mut bytes, asset)
4197            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4198        bytes.push(b'\n');
4199    }
4200    Ok(bytes)
4201}
4202
4203fn v2_local_asset_records(
4204    store: &Store,
4205) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4206    let assets = crate::assets::read_manifest(store)
4207        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4208    if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4209        return Err(LinkError::InvalidPack {
4210            message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4211        });
4212    }
4213    Ok(assets
4214        .into_iter()
4215        .map(|asset| (asset.path.clone(), asset))
4216        .collect())
4217}
4218
4219fn v2_asset_records_match_remote(
4220    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4221    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4222) -> bool {
4223    local.len() == remote.len()
4224        && remote
4225            .iter()
4226            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4227}
4228
4229#[derive(Debug, Clone, PartialEq, Eq)]
4230struct V2PulledMerge<T> {
4231    records: std::collections::BTreeMap<String, T>,
4232    accept_remote: std::collections::BTreeSet<String>,
4233    conflicts: Vec<String>,
4234}
4235
4236/// Compute the local checkout that a pull may install without discarding a
4237/// local-only edit. Remote-only changes are accepted, identical concurrent
4238/// changes converge, and divergent concurrent changes are reported. The
4239/// caller can exempt kept-home coordinates from both remote installation and
4240/// conflict disclosure.
4241fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4242    base: &std::collections::BTreeMap<String, Base>,
4243    remote: &std::collections::BTreeMap<String, Remote>,
4244    local: &std::collections::BTreeMap<String, Record>,
4245    base_record: BaseRecord,
4246    remote_record: RemoteRecord,
4247    keep_local: KeepLocal,
4248) -> V2PulledMerge<Record>
4249where
4250    Record: Clone + Eq,
4251    BaseRecord: Fn(&Base, &str) -> Record,
4252    RemoteRecord: Fn(&Remote, &str) -> Record,
4253    KeepLocal: Fn(&str) -> bool,
4254{
4255    let paths = base
4256        .keys()
4257        .chain(remote.keys())
4258        .chain(local.keys())
4259        .cloned()
4260        .collect::<std::collections::BTreeSet<_>>();
4261    let mut records = local.clone();
4262    let mut accept_remote = std::collections::BTreeSet::new();
4263    let mut conflicts = Vec::new();
4264    for path in paths {
4265        if keep_local(&path) {
4266            continue;
4267        }
4268        let base_value = base.get(&path).map(|value| base_record(value, &path));
4269        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4270        let local_value = local.get(&path).cloned();
4271        if local_value != base_value && remote_value != base_value && local_value != remote_value {
4272            conflicts.push(path);
4273            continue;
4274        }
4275        if local_value == base_value || local_value == remote_value {
4276            accept_remote.insert(path.clone());
4277            match remote_value {
4278                Some(value) => {
4279                    records.insert(path, value);
4280                }
4281                None => {
4282                    records.remove(&path);
4283                }
4284            }
4285        }
4286    }
4287    V2PulledMerge {
4288        records,
4289        accept_remote,
4290        conflicts,
4291    }
4292}
4293
4294fn sign_verified_v2_candidate(
4295    cfg: &HubConfig,
4296    head: &V2VerifiedHead,
4297    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4298    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4299    mutation_id: &str,
4300    request_body: &Value,
4301    challenge_value: &Value,
4302) -> LinkResult<(String, String, String)> {
4303    if head.view_kind != "full" {
4304        return Err(invalid_feed(
4305            "a scoped self-custody writer must use the proposal workflow",
4306        ));
4307    }
4308    if head.identity.custody != "self" {
4309        return Err(invalid_feed(
4310            "a hub-custodied brain unexpectedly requested an external signature",
4311        ));
4312    }
4313    let key = cfg
4314        .brain_key
4315        .as_ref()
4316        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4317    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4318        || key.public_key_spki != head.identity.public_key_spki
4319    {
4320        return Err(bad_agent_key(
4321            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4322        ));
4323    }
4324    let challenge_id = challenge_value
4325        .get("id")
4326        .and_then(Value::as_str)
4327        .filter(|id| crate::ulid::is_ulid(id))
4328        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4329    let expected_endpoint = format!(
4330        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4331        head.brain_id
4332    );
4333    if challenge_value
4334        .get("candidate_endpoint")
4335        .and_then(Value::as_str)
4336        != Some(expected_endpoint.as_str())
4337    {
4338        return Err(invalid_feed(
4339            "self-custody challenge candidate endpoint is not origin-bound",
4340        ));
4341    }
4342
4343    let mut files = std::collections::BTreeMap::new();
4344    let mut after = String::new();
4345    type CandidateCoordinate = (
4346        String,
4347        String,
4348        String,
4349        String,
4350        Option<String>,
4351        Option<String>,
4352        u64,
4353        Option<String>,
4354    );
4355    let mut pinned: Option<CandidateCoordinate> = None;
4356    loop {
4357        let encoded_after: String =
4358            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4359        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4360        let value = ensure_ok(
4361            request_capped(
4362                cfg,
4363                "GET",
4364                &path,
4365                None,
4366                Auth::Required,
4367                MAX_FEED_RESPONSE_BYTES,
4368            )?,
4369            "v2 self-custody candidate",
4370        )?;
4371        let page: V2SigningCandidatePage = serde_json::from_value(value)
4372            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4373        if page.v != 2
4374            || page.challenge_id != challenge_id
4375            || page.mutation_id != mutation_id
4376            || page.candidate.seq != page.parent.seq + 1
4377            || page.files.len() > 500
4378            || page.expires_at.is_empty()
4379        {
4380            return Err(invalid_feed(
4381                "self-custody candidate is not bound to this mutation",
4382            ));
4383        }
4384        let coordinate = (
4385            page.request_hash.clone(),
4386            page.candidate.signing_bytes_base64.clone(),
4387            page.candidate.changes_base64.clone(),
4388            page.candidate.actor_claim_base64.clone(),
4389            page.candidate.content_root.clone(),
4390            page.candidate.asset_root.clone(),
4391            page.parent.seq,
4392            page.parent.commit_hash.clone(),
4393        );
4394        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4395            return Err(invalid_feed(
4396                "self-custody candidate changed between manifest pages",
4397            ));
4398        }
4399        pinned = Some(coordinate);
4400        let root = page
4401            .candidate
4402            .content_root
4403            .as_deref()
4404            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4405        for file in page.files {
4406            verify_v2_file_proof(root, &file)?;
4407            if files
4408                .insert(
4409                    file.path.clone(),
4410                    V2BaselineFile {
4411                        sha256: file.sha256,
4412                        bytes: file.bytes,
4413                        proof: Some(file.proof),
4414                    },
4415                )
4416                .is_some()
4417            {
4418                return Err(invalid_feed(
4419                    "self-custody candidate repeats a manifest path",
4420                ));
4421            }
4422            if files.len() > MAX_PUSH_FILES {
4423                return Err(invalid_feed(
4424                    "self-custody candidate exceeds the file-count bound",
4425                ));
4426            }
4427        }
4428        match page.next_cursor {
4429            None => break,
4430            Some(next) if next > after => after = next,
4431            Some(_) => {
4432                return Err(invalid_feed(
4433                    "self-custody candidate cursor did not advance",
4434                ))
4435            }
4436        }
4437    }
4438    if files.len() != expected.len()
4439        || files.iter().any(|(path, file)| {
4440            expected.get(path).is_none_or(|expected| {
4441                expected.sha256 != file.sha256 || expected.bytes != file.bytes
4442            })
4443        })
4444    {
4445        return Err(invalid_feed(
4446            "self-custody candidate contains an unexpected file mutation",
4447        ));
4448    }
4449    let mut assets = std::collections::BTreeMap::new();
4450    after.clear();
4451    loop {
4452        let encoded_after: String =
4453            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4454        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4455        let value = ensure_ok(
4456            request_capped(
4457                cfg,
4458                "GET",
4459                &path,
4460                None,
4461                Auth::Required,
4462                MAX_FEED_RESPONSE_BYTES,
4463            )?,
4464            "v2 self-custody asset candidate",
4465        )?;
4466        let page: V2SigningCandidatePage = serde_json::from_value(value)
4467            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4468        let coordinate = (
4469            page.request_hash.clone(),
4470            page.candidate.signing_bytes_base64.clone(),
4471            page.candidate.changes_base64.clone(),
4472            page.candidate.actor_claim_base64.clone(),
4473            page.candidate.content_root.clone(),
4474            page.candidate.asset_root.clone(),
4475            page.parent.seq,
4476            page.parent.commit_hash.clone(),
4477        );
4478        if page.v != 2
4479            || page.challenge_id != challenge_id
4480            || page.mutation_id != mutation_id
4481            || page.assets.len() > 500
4482            || pinned.as_ref() != Some(&coordinate)
4483        {
4484            return Err(invalid_feed(
4485                "self-custody asset candidate changed or is not bound",
4486            ));
4487        }
4488        let root = page.candidate.asset_root.as_deref();
4489        if !page.assets.is_empty() && root.is_none() {
4490            return Err(invalid_feed("asset candidate has no asset root"));
4491        }
4492        for item in page.assets {
4493            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4494            if assets
4495                .insert(
4496                    item.path.clone(),
4497                    V2BaselineAsset {
4498                        blob_sha256: item.blob_sha256,
4499                        bytes: item.bytes,
4500                        media_type: item.media_type,
4501                        wrappers: item.wrappers,
4502                        required: item.required,
4503                        disposition: item.disposition,
4504                        leaf_hash: item.leaf_hash,
4505                    },
4506                )
4507                .is_some()
4508            {
4509                return Err(invalid_feed("self-custody candidate repeats an asset"));
4510            }
4511        }
4512        match page.next_cursor {
4513            None => break,
4514            Some(next) if next > after => after = next,
4515            Some(_) => {
4516                return Err(invalid_feed(
4517                    "self-custody asset candidate cursor did not advance",
4518                ))
4519            }
4520        }
4521    }
4522    if assets.len() != expected_assets.len()
4523        || assets.iter().any(|(path, asset)| {
4524            expected_assets.get(path).is_none_or(|expected| {
4525                asset.blob_sha256 != expected.blob_sha256
4526                    || asset.bytes != expected.bytes
4527                    || asset.media_type != expected.media_type
4528                    || asset.wrappers != expected.wrappers
4529                    || asset.required != expected.required
4530                    || asset.disposition != expected.disposition
4531            })
4532        })
4533    {
4534        return Err(invalid_feed(
4535            "self-custody candidate contains an unexpected asset mutation",
4536        ));
4537    }
4538    let Some((
4539        request_hash,
4540        signing_b64,
4541        changes_b64,
4542        actor_b64,
4543        root,
4544        asset_root,
4545        parent_seq,
4546        parent,
4547    )) = pinned
4548    else {
4549        return Err(invalid_feed("self-custody candidate has no manifest"));
4550    };
4551    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4552    let current_commit = head
4553        .pointer
4554        .as_ref()
4555        .map(|pointer| pointer.commit_hash.clone());
4556    if parent_seq != current_seq || parent != current_commit {
4557        return Err(LinkError::RemoteAdvancedDuringSync);
4558    }
4559    let changes = STANDARD
4560        .decode(changes_b64)
4561        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4562    let mut expected_changes = json!({
4563        "mutation_id": mutation_id,
4564        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4565        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4566        "v": 2,
4567    });
4568    if let Some(withheld_links) = request_body.get("withheld_links") {
4569        expected_changes["withheld_links"] = withheld_links.clone();
4570    }
4571    if let Some(checkout_id) = request_body.get("checkout_id") {
4572        expected_changes["checkout_id"] = checkout_id.clone();
4573    }
4574    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4575        .map_err(|error| invalid_feed(error.to_string()))?;
4576    if changes != expected_changes_bytes {
4577        return Err(invalid_feed(
4578            "self-custody changeset differs from the requested mutation",
4579        ));
4580    }
4581    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4582        .map_err(|error| invalid_feed(error.to_string()))?;
4583    let request_value = json!({
4584        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4585        "brain": head.brain_id,
4586        "changes_sha256": changes_hash,
4587        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4588        "v": 2,
4589        "v1_bridge": Value::Null,
4590    });
4591    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4592        .map_err(|error| invalid_feed(error.to_string()))?;
4593    if request_hash != expected_request_hash {
4594        return Err(invalid_feed(
4595            "self-custody request hash differs from the requested mutation",
4596        ));
4597    }
4598    let actor = STANDARD
4599        .decode(actor_b64)
4600        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4601    let actor_value: Value = serde_json::from_slice(&actor)
4602        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4603    if crate::linkmd_v2::canonical_bytes(&actor_value)
4604        .map_err(|error| invalid_feed(error.to_string()))?
4605        != actor
4606    {
4607        return Err(invalid_feed("self-custody actor claim is not canonical"));
4608    }
4609    let actor_object = actor_value
4610        .as_object()
4611        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4612    let actor_claim = actor_object
4613        .get("claim")
4614        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4615    let actor_public_key = actor_object
4616        .get("public_key")
4617        .and_then(Value::as_str)
4618        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4619    let actor_fingerprint = actor_object
4620        .get("fingerprint")
4621        .and_then(Value::as_str)
4622        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4623    let actor_signature = actor_object
4624        .get("sig")
4625        .and_then(Value::as_str)
4626        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4627    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4628        .map_err(|error| invalid_feed(error.to_string()))?;
4629    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4630    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4631    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4632    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4633    let impact = actor_claim
4634        .get("result")
4635        .and_then(|result| result.get("impact"))
4636        .and_then(Value::as_object);
4637    let impact_fields = [
4638        "creates",
4639        "updates",
4640        "deletes",
4641        "withdrawals",
4642        "renames",
4643        "restores",
4644        "asset_changes",
4645        "public_expansions",
4646        "executable_activations",
4647    ];
4648    let impact_is_valid = impact.is_some_and(|impact| {
4649        impact.len() == impact_fields.len() + 1
4650            && impact.get("v").and_then(Value::as_u64) == Some(1)
4651            && impact_fields
4652                .iter()
4653                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4654    });
4655    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4656        || head
4657            .trust
4658            .hub_signer
4659            .as_ref()
4660            .is_some_and(|known| known != &expected_actor_signer)
4661        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4662        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4663        || actor_claim
4664            .get("candidate")
4665            .and_then(|candidate| candidate.get("changes_sha256"))
4666            .and_then(Value::as_str)
4667            != Some(changes_hash.as_str())
4668        || actor_claim
4669            .get("candidate")
4670            .and_then(|candidate| candidate.get("state_root"))
4671            != Some(&expected_actor_root)
4672        || actor_claim
4673            .get("candidate")
4674            .and_then(|candidate| candidate.get("asset_root"))
4675            != Some(&expected_actor_asset_root)
4676        || actor_claim
4677            .get("candidate")
4678            .and_then(|candidate| candidate.get("control_revision"))
4679            .and_then(Value::as_str)
4680            != Some(head.control_revision.as_str())
4681        || !impact_is_valid
4682    {
4683        return Err(invalid_feed(
4684            "self-custody actor claim does not bind the verified authority",
4685        ));
4686    }
4687    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4688        .map_err(|error| invalid_feed(error.to_string()))?;
4689    let signing = STANDARD
4690        .decode(signing_b64)
4691        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4692    let signing_value: Value = serde_json::from_slice(&signing)
4693        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4694    if crate::linkmd_v2::canonical_bytes(&signing_value)
4695        .map_err(|error| invalid_feed(error.to_string()))?
4696        != signing
4697    {
4698        return Err(invalid_feed("self-custody signing bytes are not canonical"));
4699    }
4700    let pointer = head.pointer.as_ref();
4701    let expected_materializer = pointer
4702        .map(|value| value.materializer.as_str())
4703        .unwrap_or("dbmd-projection-v1");
4704    let expected_parent_commit = request_body
4705        .get("base")
4706        .and_then(|base| base.get("commit_hash"))
4707        .cloned()
4708        .unwrap_or(Value::Null);
4709    let expected_parent_root = request_body
4710        .get("base")
4711        .and_then(|base| base.get("content_root"))
4712        .cloned()
4713        .unwrap_or(Value::Null);
4714    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4715    let expected_parent_asset_root = request_body
4716        .get("base")
4717        .and_then(|base| base.get("asset_root"))
4718        .cloned()
4719        .unwrap_or(Value::Null);
4720    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4721    let expected_prev_entry = pointer
4722        .map(|value| Value::String(value.feed_hash.clone()))
4723        .unwrap_or(Value::Null);
4724    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4725        .map_err(|_| invalid_feed("brain identity history is too large"))?
4726        + 1;
4727    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4728        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4729        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4730        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4731        || signing_value.get("public_key").and_then(Value::as_str)
4732            != Some(key.public_key_spki.as_str())
4733        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4734        || signing_value.get("parent_root") != Some(&expected_parent_root)
4735        || signing_value.get("state_root") != Some(&expected_state_root)
4736        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4737        || signing_value.get("asset_root") != Some(&expected_asset_root)
4738        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4739        || signing_value.get("changes_sha256").and_then(Value::as_str)
4740            != Some(changes_hash.as_str())
4741        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4742        || signing_value
4743            .get("control_revision")
4744            .and_then(Value::as_str)
4745            != Some(head.control_revision.as_str())
4746        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4747        || signing_value.get("v1_bridge") != Some(&Value::Null)
4748        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4749    {
4750        return Err(invalid_feed(
4751            "self-custody signing bytes do not bind the verified candidate",
4752        ));
4753    }
4754    let pair = agent_keypair(&key.pkcs8)?;
4755    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4756    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4757}
4758
4759fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4760    let origin = normalized_origin(&cfg.hub)?;
4761    let absolute = if checkout.is_absolute() {
4762        checkout.to_path_buf()
4763    } else {
4764        std::env::current_dir()?.join(checkout)
4765    };
4766    Ok(format!(
4767        "sync-{}.json",
4768        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4769    ))
4770}
4771
4772fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4773    if let Some(value) = existing {
4774        if !is_sha256(value) {
4775            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4776        }
4777        return Ok(value.to_string());
4778    }
4779    use ring::rand::SecureRandom as _;
4780    let mut random = [0_u8; 32];
4781    ring::rand::SystemRandom::new()
4782        .fill(&mut random)
4783        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4784    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4785}
4786
4787#[cfg(any(unix, windows))]
4788fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4789    let directory = open_trust_dir(cfg)?;
4790    let origin = normalized_origin(&cfg.hub)?;
4791    let name = format!(
4792        "operation-{}.lock",
4793        content_sha256(format!("{origin}\0{brain}").as_bytes())
4794    );
4795    lock_trust_name(&directory, &name)
4796}
4797
4798#[cfg(not(any(unix, windows)))]
4799fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4800    Err(LinkError::UnsupportedPlatform {
4801        operation: "serialized link.md v2 sync",
4802    })
4803}
4804
4805fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4806    left.brain_id == right.brain_id
4807        && left.view_kind == right.view_kind
4808        && left.view_revision == right.view_revision
4809        && left.control_revision == right.control_revision
4810        && match (&left.pointer, &right.pointer) {
4811            (None, None) => true,
4812            (Some(left), Some(right)) => {
4813                left.seq == right.seq
4814                    && left.commit_hash == right.commit_hash
4815                    && left.content_root == right.content_root
4816                    && left.asset_root == right.asset_root
4817                    && left.feed_hash == right.feed_hash
4818            }
4819            _ => false,
4820        }
4821}
4822
4823/// A private sync baseline is an already verified materialization of the
4824/// remote manifest. It may replace another paginated manifest download only
4825/// when a freshly verified signed head names the exact same content, assets,
4826/// view, and authority revision. Any remote or permission movement falls back
4827/// to proof-bearing hub reads.
4828fn v2_baseline_matches_head(head: &V2VerifiedHead, baseline: &V2SyncBaseline) -> bool {
4829    let pointer = head.pointer.as_ref();
4830    baseline.head_seq == Some(pointer.map_or(0, |value| value.seq))
4831        && baseline.commit_hash.as_deref() == pointer.map(|value| value.commit_hash.as_str())
4832        && baseline.content_root.as_deref()
4833            == pointer.and_then(|value| value.content_root.as_deref())
4834        && baseline.asset_root.as_deref() == pointer.and_then(|value| value.asset_root.as_deref())
4835        && baseline.view_kind.as_deref() == Some(head.view_kind.as_str())
4836        && baseline.view_revision.as_deref() == Some(head.view_revision.as_str())
4837        && baseline.control_revision.as_deref() == Some(head.control_revision.as_str())
4838}
4839
4840fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4841    format!(
4842        "---\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"
4843    )
4844    .into_bytes()
4845}
4846
4847fn scoped_projection_sha256(brain: &str) -> String {
4848    content_sha256(&scoped_projection_bytes(brain))
4849}
4850
4851#[derive(Deserialize)]
4852struct LocalScopedViewMarker {
4853    v: u8,
4854    kind: String,
4855    authoritative: bool,
4856    brain: String,
4857    projection_sha256: String,
4858}
4859
4860/// True only when this store carries the exact generated marker for a local
4861/// link.md scoped view. This is presentation context, never authorization;
4862/// the hub remains the only authority for reads and writes.
4863pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4864    let marker = store
4865        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4866        .ok()
4867        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4868    let Some(marker) = marker else {
4869        return false;
4870    };
4871    if marker.v != 1
4872        || marker.kind != "link.md-scoped-view"
4873        || marker.authoritative
4874        || !crate::ulid::is_ulid(&marker.brain)
4875        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4876    {
4877        return false;
4878    }
4879    store
4880        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4881        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4882}
4883
4884fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4885    let mut bytes = serde_json::to_vec_pretty(&json!({
4886        "v": 1,
4887        "kind": "link.md-scoped-view",
4888        "authoritative": false,
4889        "brain": head.brain_id,
4890        "view_revision": head.view_revision,
4891        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4892        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4893        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4894        "visible_files": files,
4895        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4896    }))
4897    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4898    bytes.push(b'\n');
4899    Ok(bytes)
4900}
4901
4902fn refresh_scoped_view_marker(
4903    store: &Store,
4904    head: &V2VerifiedHead,
4905    files: usize,
4906) -> LinkResult<()> {
4907    if head.view_kind == "scoped" {
4908        store.write_atomic(
4909            Path::new(".dbmd/view.json"),
4910            &scoped_view_metadata(head, files)?,
4911        )?;
4912    }
4913    Ok(())
4914}
4915
4916fn ensure_v2_view_compatible(
4917    head: &V2VerifiedHead,
4918    baseline: Option<&V2SyncBaseline>,
4919) -> LinkResult<()> {
4920    let Some(baseline) = baseline else {
4921        return Ok(());
4922    };
4923    match (
4924        baseline.view_kind.as_deref(),
4925        baseline.view_revision.as_deref(),
4926    ) {
4927        (None, None) if head.view_kind == "full" => Ok(()),
4928        (Some(kind), Some(revision))
4929            if kind == head.view_kind && revision == head.view_revision =>
4930        {
4931            Ok(())
4932        }
4933        _ => Err(LinkError::ScopedViewChanged),
4934    }
4935}
4936
4937fn ensure_established_v2_checkout_opened(
4938    head: &V2VerifiedHead,
4939    baseline: Option<&V2SyncBaseline>,
4940    opened: bool,
4941) -> LinkResult<()> {
4942    if baseline.is_none() || opened {
4943        return Ok(());
4944    }
4945    if head.view_kind == "scoped" {
4946        return Err(LinkError::ScopedProjectionModified);
4947    }
4948    Err(LinkError::InvalidPack {
4949        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4950    })
4951}
4952
4953fn remove_scoped_projection(
4954    head: &V2VerifiedHead,
4955    baseline: Option<&V2SyncBaseline>,
4956    view: &mut V2LocalView,
4957) -> LinkResult<()> {
4958    if head.view_kind != "scoped" {
4959        return Ok(());
4960    }
4961    let expected = scoped_projection_sha256(&head.brain_id);
4962    if baseline
4963        .and_then(|state| state.projection_sha256.as_deref())
4964        .is_some_and(|pinned| pinned != expected)
4965    {
4966        return Err(LinkError::ScopedViewChanged);
4967    }
4968    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4969        return Err(LinkError::ScopedProjectionModified);
4970    }
4971    view.riding.remove("DB.md");
4972    view.eligibility.remove("DB.md");
4973    Ok(())
4974}
4975
4976fn local_view_for_v2_push(
4977    store: &Store,
4978    head: &V2VerifiedHead,
4979    baseline: Option<&V2SyncBaseline>,
4980    carried: Option<V2LocalView>,
4981) -> LinkResult<V2LocalView> {
4982    match carried {
4983        // The pull half already verified and removed the generated scoped
4984        // projection before constructing its in-memory handoff. Requiring it
4985        // a second time would turn every scoped bidirectional write into a
4986        // false tamper refusal.
4987        Some(view) => Ok(view),
4988        None => {
4989            let mut view = v2_local_files(store)?;
4990            remove_scoped_projection(head, baseline, &mut view)?;
4991            Ok(view)
4992        }
4993    }
4994}
4995
4996fn files_for_v2_view(
4997    head: &V2VerifiedHead,
4998    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4999) -> std::collections::BTreeMap<String, V2BaselineFile> {
5000    if head.view_kind == "scoped" {
5001        // Even when a grant explicitly includes canonical DB.md, a partial
5002        // checkout uses a generated marker. Canonical validation remains a
5003        // server-side operation over the complete brain.
5004        files.remove("DB.md");
5005    }
5006    files
5007}
5008
5009fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
5010    let baseline: V2SyncBaseline =
5011        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
5012    if baseline.v != 2
5013        || baseline.origin != normalized_origin(&cfg.hub)?
5014        || baseline.brain != brain
5015        || baseline
5016            .commit_hash
5017            .as_deref()
5018            .is_some_and(|hash| !is_sha256(hash))
5019        || baseline
5020            .content_root
5021            .as_deref()
5022            .is_some_and(|hash| !is_sha256(hash))
5023        || baseline
5024            .asset_root
5025            .as_deref()
5026            .is_some_and(|hash| !is_sha256(hash))
5027        || baseline
5028            .local_policy_digest
5029            .as_deref()
5030            .is_some_and(|hash| !is_sha256(hash))
5031        || baseline
5032            .view_kind
5033            .as_deref()
5034            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5035        || baseline
5036            .view_revision
5037            .as_deref()
5038            .is_some_and(|hash| !is_sha256(hash))
5039        || baseline
5040            .control_revision
5041            .as_deref()
5042            .is_some_and(|hash| !is_sha256(hash))
5043        || baseline
5044            .projection_sha256
5045            .as_deref()
5046            .is_some_and(|hash| !is_sha256(hash))
5047        || (baseline.view_kind.as_deref() == Some("scoped")
5048            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5049        || baseline.files.len() > MAX_PUSH_FILES
5050        || baseline.assets.len() > MAX_PUSH_FILES
5051        || baseline.local_eligibility.len() > MAX_PUSH_FILES
5052        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5053        || baseline.files.iter().any(|(path, file)| {
5054            crate::linkmd_v2::normalize_path(path).is_err()
5055                || !is_sha256(&file.sha256)
5056                || file.bytes > MAX_STORE_BYTES
5057        })
5058        || baseline.assets.iter().any(|(path, asset)| {
5059            crate::linkmd_v2::normalize_path(path).is_err()
5060                || !is_sha256(&asset.blob_sha256)
5061                || !is_sha256(&asset.leaf_hash)
5062                || asset.bytes > MAX_ASSET_BYTES
5063                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5064                || asset.wrappers.is_empty()
5065                || asset
5066                    .wrappers
5067                    .iter()
5068                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5069        })
5070        || baseline
5071            .local_eligibility
5072            .keys()
5073            .chain(baseline.remote_copy_remains.keys())
5074            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5075        || baseline
5076            .remote_copy_remains
5077            .values()
5078            .any(|hash| !is_sha256(hash))
5079        || baseline
5080            .checkout_id
5081            .as_deref()
5082            .is_some_and(|checkout_id| !is_sha256(checkout_id))
5083    {
5084        return Err(invalid_feed("v2 sync baseline failed validation"));
5085    }
5086    Ok(baseline)
5087}
5088
5089#[cfg(unix)]
5090fn load_v2_baseline(
5091    cfg: &HubConfig,
5092    brain: &str,
5093    checkout: &Path,
5094) -> LinkResult<Option<V2SyncBaseline>> {
5095    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5096    let directory = open_trust_dir(cfg)?;
5097    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5098    let _lock = lock_trust_name(&directory, &name_string)?;
5099    let name = c_name(name_string.as_bytes(), &name_string)?;
5100    let fd = unsafe {
5101        libc::openat(
5102            directory.as_raw_fd(),
5103            name.as_ptr(),
5104            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5105        )
5106    };
5107    if fd < 0 {
5108        let error = std::io::Error::last_os_error();
5109        return if error.kind() == std::io::ErrorKind::NotFound {
5110            Ok(None)
5111        } else {
5112            Err(LinkError::UnsafePath { path: name_string })
5113        };
5114    }
5115    let file = unsafe { std::fs::File::from_raw_fd(fd) };
5116    let mut bytes = Vec::new();
5117    file.take(MAX_FEED_RESPONSE_BYTES + 1)
5118        .read_to_end(&mut bytes)?;
5119    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
5120        return Err(invalid_feed("v2 sync baseline is oversized"));
5121    }
5122    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5123}
5124
5125#[cfg(windows)]
5126fn load_v2_baseline(
5127    cfg: &HubConfig,
5128    brain: &str,
5129    checkout: &Path,
5130) -> LinkResult<Option<V2SyncBaseline>> {
5131    let directory = open_trust_dir(cfg)?;
5132    let name = v2_baseline_name(cfg, brain, checkout)?;
5133    let _lock = lock_trust_name(&directory, &name)?;
5134    let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5135    match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5136        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5137        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5138        Err(_) => Err(LinkError::UnsafePath { path: name }),
5139    }
5140}
5141
5142#[cfg(not(any(unix, windows)))]
5143fn load_v2_baseline(
5144    _cfg: &HubConfig,
5145    _brain: &str,
5146    _checkout: &Path,
5147) -> LinkResult<Option<V2SyncBaseline>> {
5148    Err(LinkError::UnsupportedPlatform {
5149        operation: "verified link.md v2 baseline",
5150    })
5151}
5152
5153#[cfg(unix)]
5154fn save_v2_baseline(
5155    cfg: &HubConfig,
5156    brain: &str,
5157    checkout: &Path,
5158    baseline: &V2SyncBaseline,
5159) -> LinkResult<()> {
5160    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5161    let directory = open_trust_dir(cfg)?;
5162    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5163    let _lock = lock_trust_name(&directory, &name_string)?;
5164    let name = c_name(name_string.as_bytes(), &name_string)?;
5165    let mut bytes = serde_json::to_vec(baseline)
5166        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5167    bytes.push(b'\n');
5168    let temp_string = format!(
5169        ".{name_string}.tmp.{}-{}",
5170        std::process::id(),
5171        std::time::SystemTime::now()
5172            .duration_since(std::time::UNIX_EPOCH)
5173            .unwrap_or_default()
5174            .as_nanos()
5175    );
5176    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5177    let fd = unsafe {
5178        libc::openat(
5179            directory.as_raw_fd(),
5180            temp.as_ptr(),
5181            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5182            0o600,
5183        )
5184    };
5185    if fd < 0 {
5186        return Err(std::io::Error::last_os_error().into());
5187    }
5188    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5189    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5190        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5191        return Err(error.into());
5192    }
5193    drop(file);
5194    if unsafe {
5195        libc::renameat(
5196            directory.as_raw_fd(),
5197            temp.as_ptr(),
5198            directory.as_raw_fd(),
5199            name.as_ptr(),
5200        )
5201    } != 0
5202    {
5203        let error = std::io::Error::last_os_error();
5204        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5205        return Err(error.into());
5206    }
5207    directory.sync_all()?;
5208    Ok(())
5209}
5210
5211#[cfg(windows)]
5212fn save_v2_baseline(
5213    cfg: &HubConfig,
5214    brain: &str,
5215    checkout: &Path,
5216    baseline: &V2SyncBaseline,
5217) -> LinkResult<()> {
5218    let directory = open_trust_dir(cfg)?;
5219    let name = v2_baseline_name(cfg, brain, checkout)?;
5220    let _lock = lock_trust_name(&directory, &name)?;
5221    let mut bytes = serde_json::to_vec(baseline)
5222        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5223    bytes.push(b'\n');
5224    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5225    Ok(())
5226}
5227
5228#[cfg(not(any(unix, windows)))]
5229fn save_v2_baseline(
5230    _cfg: &HubConfig,
5231    _brain: &str,
5232    _checkout: &Path,
5233    _baseline: &V2SyncBaseline,
5234) -> LinkResult<()> {
5235    Err(LinkError::UnsupportedPlatform {
5236        operation: "verified link.md v2 baseline",
5237    })
5238}
5239
5240fn v2_baseline_from_head(
5241    cfg: &HubConfig,
5242    head: &V2VerifiedHead,
5243    files: std::collections::BTreeMap<String, V2BaselineFile>,
5244    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5245    local: Option<&V2LocalView>,
5246    checkout_id: Option<&str>,
5247) -> LinkResult<V2SyncBaseline> {
5248    let mut local_eligibility = local
5249        .map(|view| view.eligibility.clone())
5250        .unwrap_or_default();
5251    if let Some(view) = local {
5252        for path in files.keys() {
5253            local_eligibility
5254                .entry(path.clone())
5255                .or_insert_with(|| !view.policy.keeps_home(path));
5256        }
5257    }
5258    let remote_copy_remains = local_eligibility
5259        .iter()
5260        .filter(|(_, riding)| !**riding)
5261        .filter_map(|(path, _)| {
5262            files
5263                .get(path)
5264                .map(|file| (path.clone(), file.sha256.clone()))
5265        })
5266        .collect();
5267    Ok(V2SyncBaseline {
5268        v: 2,
5269        origin: normalized_origin(&cfg.hub)?,
5270        brain: head.brain_id.clone(),
5271        checkout_id: Some(v2_checkout_id(checkout_id)?),
5272        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5273        commit_hash: head
5274            .pointer
5275            .as_ref()
5276            .map(|pointer| pointer.commit_hash.clone()),
5277        content_root: head
5278            .pointer
5279            .as_ref()
5280            .and_then(|pointer| pointer.content_root.clone()),
5281        asset_root: head
5282            .pointer
5283            .as_ref()
5284            .and_then(|pointer| pointer.asset_root.clone()),
5285        assets,
5286        view_kind: Some(head.view_kind.clone()),
5287        view_revision: Some(head.view_revision.clone()),
5288        control_revision: Some(head.control_revision.clone()),
5289        projection_sha256: (head.view_kind == "scoped")
5290            .then(|| scoped_projection_sha256(&head.brain_id)),
5291        files,
5292        local_policy_digest: local.map(|view| view.policy.digest.clone()),
5293        local_eligibility,
5294        remote_copy_remains,
5295    })
5296}
5297
5298fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5299    let policy = crate::linkmd_sync_policy::load(store)
5300        .map_err(|message| LinkError::InvalidPack { message })?;
5301    let asset_paths = crate::assets::read_manifest(store)
5302        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5303        .into_iter()
5304        .map(|asset| asset.path)
5305        .collect::<std::collections::BTreeSet<_>>();
5306    let mut result = std::collections::BTreeMap::new();
5307    let mut eligibility = std::collections::BTreeMap::new();
5308    let mut riding_links = Vec::<(String, Vec<String>)>::new();
5309    let mut total = 0_u64;
5310    let mut paths = vec![PathBuf::from("DB.md")];
5311    paths.extend(store.walk()?);
5312    for relative in paths {
5313        let path = relative.to_string_lossy().replace('\\', "/");
5314        // v2 catalogs and asset inventory are materialized/signed separately.
5315        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5316            continue;
5317        }
5318        if asset_paths.contains(&path) {
5319            continue;
5320        }
5321        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5322            path: error.to_string(),
5323        })?;
5324        let riding = !policy.keeps_home(&path);
5325        eligibility.insert(path.clone(), riding);
5326        if !riding {
5327            continue;
5328        }
5329        let remaining = MAX_STORE_BYTES.saturating_sub(total);
5330        let bytes = store.read_bounded(&relative, remaining)?;
5331        total = total
5332            .checked_add(bytes.len() as u64)
5333            .ok_or_else(|| LinkError::PushTooLarge {
5334                detail: "v2 local byte count overflow".to_string(),
5335            })?;
5336        if total > MAX_STORE_BYTES {
5337            return Err(LinkError::PushTooLarge {
5338                detail: format!("{total} uncompressed bytes"),
5339            });
5340        }
5341        if std::str::from_utf8(&bytes).is_err() {
5342            return Err(LinkError::NotUtf8 { path });
5343        }
5344        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5345        riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5346        result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5347    }
5348    let kept_home = eligibility
5349        .iter()
5350        .filter(|(_, riding)| !**riding)
5351        .map(|(path, _)| path.clone())
5352        .collect::<std::collections::BTreeSet<_>>();
5353    let mut withheld_links = riding_links
5354        .into_iter()
5355        .flat_map(|(source, targets)| {
5356            let kept_home = &kept_home;
5357            let policy = &policy;
5358            targets.into_iter().filter_map(move |target| {
5359                let target = format!("{target}.md");
5360                // A kept-home target need not be a file on THIS machine. An
5361                // export omits kept-home content by construction, so a store
5362                // restored from one links to names it does not carry — and
5363                // without this the owner's own export could never be pushed
5364                // back, which is the opposite of owning your data. The policy
5365                // is the owner's statement about where a path lives, and it
5366                // stands whether or not this checkout holds a copy. It
5367                // discloses nothing either way: the name is already written in
5368                // the content that links to it.
5369                (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5370                    V2WithheldLink {
5371                        source: source.clone(),
5372                        target,
5373                    },
5374                )
5375            })
5376        })
5377        .collect::<Vec<_>>();
5378    withheld_links.sort();
5379    withheld_links.dedup();
5380    Ok(V2LocalView {
5381        riding: result,
5382        eligibility,
5383        policy,
5384        withheld_links,
5385    })
5386}
5387
5388#[derive(Debug, Clone, Deserialize)]
5389struct V2DownloadItem {
5390    path: String,
5391    sha256: String,
5392    bytes: u64,
5393    url: String,
5394    method: String,
5395}
5396
5397#[derive(Debug, Deserialize)]
5398struct V2DownloadWindow {
5399    v: u8,
5400    commit: String,
5401    downloads: Vec<V2DownloadItem>,
5402}
5403
5404#[derive(Debug, Deserialize)]
5405struct V2BulkStreamHeader {
5406    v: u8,
5407    path: String,
5408    sha256: String,
5409    bytes: u64,
5410}
5411
5412fn parse_v2_bulk_stream(
5413    bytes: &[u8],
5414    expected: &[(&String, &V2BaselineFile)],
5415) -> LinkResult<Vec<(String, Vec<u8>)>> {
5416    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5417        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5418    }
5419    let mut cursor = V2_BULK_STREAM_MAGIC.len();
5420    let mut result = Vec::with_capacity(expected.len());
5421    for (expected_path, expected_file) in expected {
5422        let length_bytes = bytes
5423            .get(cursor..cursor + 4)
5424            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5425        cursor += 4;
5426        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5427        if header_len == 0 || header_len > 4 * 1024 {
5428            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5429        }
5430        let header_bytes = bytes
5431            .get(cursor..cursor + header_len)
5432            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5433        cursor += header_len;
5434        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5435            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5436        if header.v != 2
5437            || &header.path != *expected_path
5438            || header.sha256 != expected_file.sha256
5439            || header.bytes != expected_file.bytes
5440            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5441        {
5442            return Err(invalid_feed(
5443                "v2 bulk stream frame differs from its proven manifest entry",
5444            ));
5445        }
5446        let body_len = usize::try_from(header.bytes)
5447            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5448        let body = bytes
5449            .get(cursor..cursor + body_len)
5450            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5451        cursor += body_len;
5452        if content_sha256(body) != header.sha256 {
5453            return Err(invalid_feed(
5454                "v2 bulk stream file differs from its proven manifest entry",
5455            ));
5456        }
5457        result.push((header.path, body.to_vec()));
5458    }
5459    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5460        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5461    }
5462    cursor += 4;
5463    if cursor != bytes.len() {
5464        return Err(invalid_feed("v2 bulk stream carries trailing data"));
5465    }
5466    Ok(result)
5467}
5468
5469fn download_v2_bulk_stream(
5470    cfg: &HubConfig,
5471    brain: &str,
5472    pointer: &V2PointerBody,
5473    pending: &[(&String, &V2BaselineFile)],
5474) -> LinkResult<Vec<(String, Vec<u8>)>> {
5475    let claims = pending
5476        .iter()
5477        .map(|(path, file)| {
5478            Ok(json!({
5479                "path": path,
5480                "sha256": file.sha256,
5481                "bytes": file.bytes,
5482                "proof": file.proof.as_ref().ok_or_else(|| {
5483                    invalid_feed("v2 manifest omitted a bulk-stream proof")
5484                })?,
5485            }))
5486        })
5487        .collect::<LinkResult<Vec<_>>>()?;
5488    let raw = request_raw_retryable_read(
5489        cfg,
5490        "POST",
5491        &format!("/api/hub/brains/{brain}/v2/stream"),
5492        Some(&json!({
5493            "commit": pointer.commit_hash,
5494            "files": claims,
5495        })),
5496        Auth::Required,
5497        V2_BULK_STREAM_RESPONSE_BYTES,
5498    )?;
5499    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5500    parse_v2_bulk_stream(&body, pending)
5501}
5502
5503fn request_capped_retryable_read(
5504    cfg: &HubConfig,
5505    method: &str,
5506    path: &str,
5507    body: Option<&Value>,
5508    auth: Auth,
5509    max_response_bytes: u64,
5510) -> LinkResult<HubResponse> {
5511    let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5512    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5513    Ok(HubResponse {
5514        status: raw.status,
5515        body: parsed,
5516    })
5517}
5518
5519fn prepare_v2_downloads(
5520    cfg: &HubConfig,
5521    brain: &str,
5522    pointer: &V2PointerBody,
5523    pending: &[(&String, &V2BaselineFile)],
5524) -> LinkResult<Vec<V2DownloadItem>> {
5525    let mut result = Vec::with_capacity(pending.len());
5526    for chunk in pending.chunks(128) {
5527        let claims = chunk
5528            .iter()
5529            .map(|(path, file)| {
5530                Ok(json!({
5531                    "path": path,
5532                    "sha256": file.sha256,
5533                    "bytes": file.bytes,
5534                    "proof": file.proof.as_ref().ok_or_else(|| {
5535                        invalid_feed("v2 manifest omitted a download proof")
5536                    })?,
5537                }))
5538            })
5539            .collect::<LinkResult<Vec<_>>>()?;
5540        let value = ensure_ok(
5541            request_capped_retryable_read(
5542                cfg,
5543                "POST",
5544                &format!("/api/hub/brains/{brain}/v2/downloads"),
5545                Some(&json!({
5546                    "commit": pointer.commit_hash,
5547                    "files": claims,
5548                })),
5549                Auth::Required,
5550                MAX_FEED_RESPONSE_BYTES,
5551            )?,
5552            "prepare v2 blob downloads",
5553        )?;
5554        let window: V2DownloadWindow = serde_json::from_value(value)
5555            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5556        if window.v != 2
5557            || window.commit != pointer.commit_hash
5558            || window.downloads.len() != chunk.len()
5559        {
5560            return Err(invalid_feed(
5561                "v2 download window is not bound to the requested files",
5562            ));
5563        }
5564        let mut by_path = window
5565            .downloads
5566            .into_iter()
5567            .map(|item| (item.path.clone(), item))
5568            .collect::<std::collections::BTreeMap<_, _>>();
5569        if by_path.len() != chunk.len() {
5570            return Err(invalid_feed("v2 download window repeats a path"));
5571        }
5572        for (path, file) in chunk {
5573            let item = by_path
5574                .remove(*path)
5575                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5576            if item.method != "GET"
5577                || item.sha256 != file.sha256
5578                || item.bytes != file.bytes
5579                || item.url.is_empty()
5580            {
5581                return Err(invalid_feed(
5582                    "v2 download capability differs from its proven file",
5583                ));
5584            }
5585            result.push(item);
5586        }
5587    }
5588    Ok(result)
5589}
5590
5591fn prepare_v2_asset_downloads(
5592    cfg: &HubConfig,
5593    brain: &str,
5594    pointer: &V2PointerBody,
5595    pending: &[(&String, &V2BaselineAsset)],
5596) -> LinkResult<Vec<V2DownloadItem>> {
5597    let mut result = Vec::with_capacity(pending.len());
5598    for chunk in pending.chunks(128) {
5599        let claims = chunk
5600            .iter()
5601            .map(|(path, asset)| {
5602                json!({
5603                    "path": path,
5604                    "sha256": asset.blob_sha256,
5605                    "bytes": asset.bytes,
5606                    "leaf_hash": asset.leaf_hash,
5607                })
5608            })
5609            .collect::<Vec<_>>();
5610        let value = ensure_ok(
5611            request_capped_retryable_read(
5612                cfg,
5613                "POST",
5614                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5615                Some(&json!({
5616                    "commit": pointer.commit_hash,
5617                    "assets": claims,
5618                })),
5619                Auth::Required,
5620                MAX_FEED_RESPONSE_BYTES,
5621            )?,
5622            "prepare v2 asset downloads",
5623        )?;
5624        let window: V2DownloadWindow = serde_json::from_value(value)
5625            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5626        if window.v != 2
5627            || window.commit != pointer.commit_hash
5628            || window.downloads.len() != chunk.len()
5629        {
5630            return Err(invalid_feed(
5631                "v2 asset download window is not bound to the requested assets",
5632            ));
5633        }
5634        let mut by_path = window
5635            .downloads
5636            .into_iter()
5637            .map(|item| (item.path.clone(), item))
5638            .collect::<std::collections::BTreeMap<_, _>>();
5639        if by_path.len() != chunk.len() {
5640            return Err(invalid_feed("v2 asset download window repeats a path"));
5641        }
5642        for (path, asset) in chunk {
5643            let item = by_path
5644                .remove(*path)
5645                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5646            if item.method != "GET"
5647                || item.sha256 != asset.blob_sha256
5648                || item.bytes != asset.bytes
5649                || item.url.is_empty()
5650            {
5651                return Err(invalid_feed(
5652                    "v2 asset download capability differs from its signed leaf",
5653                ));
5654            }
5655            result.push(item);
5656        }
5657    }
5658    Ok(result)
5659}
5660
5661#[cfg(any(unix, windows))]
5662fn stage_v2_asset_download_window(
5663    cfg: &HubConfig,
5664    brain: &str,
5665    pointer: &V2PointerBody,
5666    cache_dir: &Path,
5667    pending: &[(&String, &V2BaselineAsset)],
5668) -> LinkResult<Vec<V2StagedFile>> {
5669    if pending.is_empty() {
5670        return Ok(Vec::new());
5671    }
5672    if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5673        return Err(invalid_feed("v2 asset capability window is oversized"));
5674    }
5675
5676    let mut last_error = None;
5677    for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5678        .iter()
5679        .copied()
5680        .map(Some)
5681        .chain(std::iter::once(None))
5682    {
5683        // This POST rechecks the exact head and current path authority before
5684        // issuing fresh, immutable GET capabilities. Successful prior workers
5685        // are already durable in the content-addressed cache, so retrying the
5686        // complete small window neither redownloads them nor widens access.
5687        let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5688        let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5689        for item in downloads {
5690            match unique.get(&item.sha256) {
5691                Some(prior) if prior.bytes != item.bytes => {
5692                    return Err(invalid_feed(
5693                        "one v2 asset hash has conflicting byte lengths",
5694                    ));
5695                }
5696                Some(_) => {}
5697                None => {
5698                    unique.insert(item.sha256.clone(), item);
5699                }
5700            }
5701        }
5702        let downloads = unique.into_values().collect::<Vec<_>>();
5703        let next = std::sync::atomic::AtomicUsize::new(0);
5704        let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5705        let mut results = std::iter::repeat_with(|| None)
5706            .take(downloads.len())
5707            .collect::<Vec<Option<LinkResult<PathBuf>>>>();
5708        std::thread::scope(|scope| {
5709            let (sender, receiver) = std::sync::mpsc::channel();
5710            for _ in 0..worker_count {
5711                let sender = sender.clone();
5712                let downloads = &downloads;
5713                let next = &next;
5714                scope.spawn(move || loop {
5715                    let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5716                    let Some(item) = downloads.get(index) else {
5717                        break;
5718                    };
5719                    let result = download_presigned_to_cache(
5720                        cfg,
5721                        &item.url,
5722                        cache_dir,
5723                        &item.sha256,
5724                        item.bytes,
5725                    );
5726                    if sender.send((index, result)).is_err() {
5727                        break;
5728                    }
5729                });
5730            }
5731            drop(sender);
5732            for (index, result) in receiver {
5733                results[index] = Some(result);
5734            }
5735        });
5736
5737        let mut failed = None;
5738        for result in results {
5739            match result {
5740                Some(Ok(_)) => {}
5741                Some(Err(error)) if failed.is_none() => failed = Some(error),
5742                Some(Err(_)) => {}
5743                None if failed.is_none() => {
5744                    failed = Some(LinkError::Transport {
5745                        hub: cfg.hub.clone(),
5746                        message: "a bounded v2 asset worker stopped before reporting its result"
5747                            .to_string(),
5748                    });
5749                }
5750                None => {}
5751            }
5752        }
5753        if let Some(error) = failed {
5754            last_error = Some(error);
5755            if let Some(milliseconds) = retry_delay {
5756                std::thread::sleep(std::time::Duration::from_millis(milliseconds));
5757                continue;
5758            }
5759            break;
5760        }
5761
5762        return pending
5763            .iter()
5764            .map(|(path, asset)| {
5765                let source = cache_dir.join(&asset.blob_sha256);
5766                if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
5767                    return Err(invalid_feed(
5768                        "v2 asset download cache omitted a proven blob",
5769                    ));
5770                }
5771                Ok(V2StagedFile {
5772                    path: (*path).clone(),
5773                    source,
5774                    sha256: asset.blob_sha256.clone(),
5775                    bytes: asset.bytes,
5776                })
5777            })
5778            .collect();
5779    }
5780    Err(last_error
5781        .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
5782}
5783
5784fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5785    let bytes = get_presigned(cfg, &item.url)?;
5786    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5787        return Err(invalid_feed("v2 blob differs from its proven path entry"));
5788    }
5789    Ok(bytes)
5790}
5791
5792#[derive(Debug, Clone)]
5793struct V2StagedFile {
5794    path: String,
5795    source: PathBuf,
5796    sha256: String,
5797    bytes: u64,
5798}
5799
5800#[cfg(unix)]
5801fn v2_download_cache_dir(
5802    cfg: &HubConfig,
5803    brain: &str,
5804    pointer: &V2PointerBody,
5805) -> LinkResult<PathBuf> {
5806    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5807}
5808
5809#[cfg(unix)]
5810fn v2_download_cache_dir_for(
5811    cfg: &HubConfig,
5812    brain: &str,
5813    transaction: &str,
5814) -> LinkResult<PathBuf> {
5815    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5816        return Err(invalid_feed("v2 download cache address is invalid"));
5817    }
5818    let path = cfg
5819        .state_dir
5820        .join("downloads")
5821        .join(brain)
5822        .join(transaction);
5823    let directory = open_or_create_dir_nofollow(&path)?;
5824    use std::os::fd::AsRawFd as _;
5825    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5826        return Err(std::io::Error::last_os_error().into());
5827    }
5828    directory.sync_all()?;
5829    Ok(path)
5830}
5831
5832#[cfg(windows)]
5833fn v2_download_cache_dir(
5834    cfg: &HubConfig,
5835    brain: &str,
5836    pointer: &V2PointerBody,
5837) -> LinkResult<PathBuf> {
5838    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5839}
5840
5841#[cfg(windows)]
5842fn v2_download_cache_dir_for(
5843    cfg: &HubConfig,
5844    brain: &str,
5845    transaction: &str,
5846) -> LinkResult<PathBuf> {
5847    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5848        return Err(invalid_feed("v2 download cache address is invalid"));
5849    }
5850    let path = cfg
5851        .state_dir
5852        .join("downloads")
5853        .join(brain)
5854        .join(transaction);
5855    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5856    crate::fsx::open_directory_nofollow(&path)?;
5857    Ok(path)
5858}
5859
5860#[cfg(unix)]
5861fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5862    use std::os::fd::AsRawFd as _;
5863    let parent = cfg.state_dir.join("downloads").join(brain);
5864    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5865        return;
5866    };
5867    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5868        return;
5869    };
5870    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5871    let _ = directory.sync_all();
5872}
5873
5874#[cfg(windows)]
5875fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5876    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5877        return;
5878    }
5879    let parent = cfg.state_dir.join("downloads").join(brain);
5880    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5881        return;
5882    };
5883    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5884}
5885
5886#[cfg(not(any(unix, windows)))]
5887fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5888
5889#[cfg(not(any(unix, windows)))]
5890fn v2_download_cache_dir_for(
5891    _cfg: &HubConfig,
5892    _brain: &str,
5893    _transaction: &str,
5894) -> LinkResult<PathBuf> {
5895    Err(LinkError::UnsupportedPlatform {
5896        operation: "resumable v2 download staging",
5897    })
5898}
5899
5900#[cfg(any(unix, windows))]
5901fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5902    let file = match crate::fsx::open_regular_nofollow(path) {
5903        Ok(file) => file,
5904        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5905        Err(error) => return Err(error.into()),
5906    };
5907    if file.metadata()?.len() != bytes {
5908        return Ok(false);
5909    }
5910    Ok(content_sha256_reader(file)? == sha256)
5911}
5912
5913#[cfg(any(unix, windows))]
5914fn cache_v2_blob_bytes(
5915    cache_dir: &Path,
5916    sha256: &str,
5917    expected_bytes: u64,
5918    bytes: &[u8],
5919) -> LinkResult<PathBuf> {
5920    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5921        return Err(invalid_feed("v2 cached blob differs from its declaration"));
5922    }
5923    let path = cache_dir.join(sha256);
5924    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5925        crate::fsx::write_atomic(&path, bytes)?;
5926    }
5927    Ok(path)
5928}
5929
5930#[cfg(not(any(unix, windows)))]
5931fn cache_v2_blob_bytes(
5932    _cache_dir: &Path,
5933    _sha256: &str,
5934    _expected_bytes: u64,
5935    _bytes: &[u8],
5936) -> LinkResult<PathBuf> {
5937    Err(LinkError::UnsupportedPlatform {
5938        operation: "resumable v2 download staging",
5939    })
5940}
5941
5942#[cfg(unix)]
5943fn download_presigned_to_cache(
5944    cfg: &HubConfig,
5945    url: &str,
5946    cache_dir: &Path,
5947    sha256: &str,
5948    expected_bytes: u64,
5949) -> LinkResult<PathBuf> {
5950    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5951
5952    let target = cache_dir.join(sha256);
5953    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5954        return Ok(target);
5955    }
5956    let directory = open_existing_dir_nofollow(cache_dir)?;
5957    let mut nonce = [0_u8; 16];
5958    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5959        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5960    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5961    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5962    let fd = unsafe {
5963        libc::openat(
5964            directory.as_raw_fd(),
5965            temp.as_ptr(),
5966            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5967            0o600,
5968        )
5969    };
5970    if fd < 0 {
5971        return Err(std::io::Error::last_os_error().into());
5972    }
5973    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5974    let response = match presigned_agent(cfg, url)?.get(url).call() {
5975        Ok(response) => response,
5976        Err(ureq::Error::Status(_, response)) => {
5977            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5978            return Err(LinkError::Http {
5979                what: "v2 direct download",
5980                status: response.status(),
5981                message: "object store rejected the download".to_string(),
5982                code: None,
5983                details: None,
5984            });
5985        }
5986        Err(ureq::Error::Transport(error)) => {
5987            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5988            return Err(LinkError::Transport {
5989                hub: cfg.hub.clone(),
5990                message: error.to_string(),
5991            });
5992        }
5993    };
5994    let mut reader = response
5995        .into_reader()
5996        .take(expected_bytes.saturating_add(1));
5997    let mut digest = Sha256::new();
5998    let mut total = 0_u64;
5999    let mut buffer = [0_u8; 64 * 1024];
6000    // Reading the socket and writing the file are different failures wearing
6001    // the same io::Error: a stalled download is the peer's problem and a full
6002    // disk is this machine's, and an operator who is told only "timed out"
6003    // cannot tell which one to act on.
6004    let write_result = (|| -> LinkResult<()> {
6005        loop {
6006            let read = reader
6007                .read(&mut buffer)
6008                .map_err(|error| LinkError::Transport {
6009                    hub: cfg.hub.clone(),
6010                    message: error.to_string(),
6011                })?;
6012            if read == 0 {
6013                break;
6014            }
6015            total = total.saturating_add(read as u64);
6016            digest.update(&buffer[..read]);
6017            output.write_all(&buffer[..read])?;
6018        }
6019        output.sync_all().map_err(LinkError::from)
6020    })();
6021    if let Err(error) = write_result {
6022        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6023        return Err(error);
6024    }
6025    drop(output);
6026    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6027        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6028        return Err(invalid_feed(
6029            "v2 direct download failed integrity verification",
6030        ));
6031    }
6032    let target_name = c_name(sha256.as_bytes(), sha256)?;
6033    // Replace only a previously inspected cache leaf. Cache bytes are private
6034    // and content-addressed; final install re-verifies them again.
6035    if unsafe {
6036        libc::renameat(
6037            directory.as_raw_fd(),
6038            temp.as_ptr(),
6039            directory.as_raw_fd(),
6040            target_name.as_ptr(),
6041        )
6042    } != 0
6043    {
6044        let error = std::io::Error::last_os_error();
6045        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6046        return Err(error.into());
6047    }
6048    directory.sync_all()?;
6049    Ok(target)
6050}
6051
6052#[cfg(windows)]
6053fn download_presigned_to_cache(
6054    cfg: &HubConfig,
6055    url: &str,
6056    cache_dir: &Path,
6057    sha256: &str,
6058    expected_bytes: u64,
6059) -> LinkResult<PathBuf> {
6060    use std::fs::OpenOptions;
6061
6062    let target = cache_dir.join(sha256);
6063    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6064        return Ok(target);
6065    }
6066    // Keep the checked directory handle alive while the private cache path is
6067    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
6068    // an ancestor can be swapped after validation.
6069    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6070    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6071    let mut output = OpenOptions::new()
6072        .write(true)
6073        .create_new(true)
6074        .open(&temp)?;
6075    let response = match presigned_agent(cfg, url)?.get(url).call() {
6076        Ok(response) => response,
6077        Err(ureq::Error::Status(_, response)) => {
6078            let _ = std::fs::remove_file(&temp);
6079            return Err(LinkError::Http {
6080                what: "v2 direct download",
6081                status: response.status(),
6082                message: "object store rejected the download".to_string(),
6083                code: None,
6084                details: None,
6085            });
6086        }
6087        Err(ureq::Error::Transport(error)) => {
6088            let _ = std::fs::remove_file(&temp);
6089            return Err(LinkError::Transport {
6090                hub: cfg.hub.clone(),
6091                message: error.to_string(),
6092            });
6093        }
6094    };
6095    let mut reader = response
6096        .into_reader()
6097        .take(expected_bytes.saturating_add(1));
6098    let mut digest = Sha256::new();
6099    let mut total = 0_u64;
6100    let mut buffer = [0_u8; 64 * 1024];
6101    // Same split as the cache-fill path: a stalled socket is not a disk error.
6102    let copied = (|| -> LinkResult<()> {
6103        loop {
6104            let read = reader
6105                .read(&mut buffer)
6106                .map_err(|error| LinkError::Transport {
6107                    hub: cfg.hub.clone(),
6108                    message: error.to_string(),
6109                })?;
6110            if read == 0 {
6111                break;
6112            }
6113            total = total.saturating_add(read as u64);
6114            digest.update(&buffer[..read]);
6115            output.write_all(&buffer[..read])?;
6116        }
6117        output.sync_all()?;
6118        Ok(())
6119    })();
6120    if let Err(error) = copied {
6121        let _ = std::fs::remove_file(&temp);
6122        return Err(error);
6123    }
6124    drop(output);
6125    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6126        let _ = std::fs::remove_file(&temp);
6127        return Err(invalid_feed(
6128            "v2 direct download failed integrity verification",
6129        ));
6130    }
6131    if target.exists() {
6132        std::fs::remove_file(&target)?;
6133    }
6134    if let Err(error) = std::fs::rename(&temp, &target) {
6135        let _ = std::fs::remove_file(&temp);
6136        return Err(error.into());
6137    }
6138    Ok(target)
6139}
6140
6141#[cfg(not(any(unix, windows)))]
6142fn download_presigned_to_cache(
6143    _cfg: &HubConfig,
6144    _url: &str,
6145    _cache_dir: &Path,
6146    _sha256: &str,
6147    _expected_bytes: u64,
6148) -> LinkResult<PathBuf> {
6149    Err(LinkError::UnsupportedPlatform {
6150        operation: "resumable v2 download staging",
6151    })
6152}
6153
6154fn download_v2_blobs(
6155    cfg: &HubConfig,
6156    brain: &str,
6157    pointer: &V2PointerBody,
6158    pending: Vec<(&String, &V2BaselineFile)>,
6159) -> LinkResult<Vec<(String, Vec<u8>)>> {
6160    if pending.is_empty() {
6161        return Ok(Vec::new());
6162    }
6163    let expected_order = pending
6164        .iter()
6165        .map(|(path, _)| (*path).clone())
6166        .collect::<Vec<_>>();
6167    let mut streamed = std::collections::BTreeMap::new();
6168    let mut direct = Vec::new();
6169    let mut window = Vec::new();
6170    let mut window_bytes = 0_u64;
6171    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6172                 window_bytes: &mut u64,
6173                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6174     -> LinkResult<()> {
6175        if window.is_empty() {
6176            return Ok(());
6177        }
6178        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6179            if streamed.insert(path, bytes).is_some() {
6180                return Err(invalid_feed("v2 bulk streams repeated a path"));
6181            }
6182        }
6183        window.clear();
6184        *window_bytes = 0;
6185        Ok(())
6186    };
6187    for &(path, file) in &pending {
6188        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6189            flush(&mut window, &mut window_bytes, &mut streamed)?;
6190            direct.push((path, file));
6191            continue;
6192        }
6193        if window.len() == V2_BULK_STREAM_FILES
6194            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6195        {
6196            flush(&mut window, &mut window_bytes, &mut streamed)?;
6197        }
6198        window.push((path, file));
6199        window_bytes += file.bytes;
6200    }
6201    flush(&mut window, &mut window_bytes, &mut streamed)?;
6202
6203    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6204    let next = std::sync::atomic::AtomicUsize::new(0);
6205    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6206    let mut results = std::iter::repeat_with(|| None)
6207        .take(downloads.len())
6208        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6209    std::thread::scope(|scope| {
6210        let (sender, receiver) = std::sync::mpsc::channel();
6211        for _ in 0..worker_count {
6212            let sender = sender.clone();
6213            let downloads = &downloads;
6214            let next = &next;
6215            scope.spawn(move || loop {
6216                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6217                let Some(item) = downloads.get(index) else {
6218                    break;
6219                };
6220                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6221                if sender.send((index, result)).is_err() {
6222                    break;
6223                }
6224            });
6225        }
6226        drop(sender);
6227        for (index, result) in receiver {
6228            results[index] = Some(result);
6229        }
6230    });
6231    for result in results.into_iter().map(|result| {
6232        result.ok_or_else(|| LinkError::Transport {
6233            hub: cfg.hub.clone(),
6234            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6235        })?
6236    }) {
6237        let (path, bytes) = result?;
6238        if streamed.insert(path, bytes).is_some() {
6239            return Err(invalid_feed("v2 download lanes repeated a path"));
6240        }
6241    }
6242    expected_order
6243        .into_iter()
6244        .map(|path| {
6245            streamed
6246                .remove(&path)
6247                .map(|bytes| (path, bytes))
6248                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6249        })
6250        .collect()
6251}
6252
6253/// Resume an exact-head materialization into the private global cache. Each
6254/// verified frame is durable before the next network window begins, so a
6255/// retry skips it and memory never grows with total changed bytes.
6256#[cfg(any(unix, windows))]
6257fn stage_v2_blobs(
6258    cfg: &HubConfig,
6259    brain: &str,
6260    pointer: &V2PointerBody,
6261    pending: Vec<(&String, &V2BaselineFile)>,
6262) -> LinkResult<Vec<V2StagedFile>> {
6263    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6264    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6265    let mut direct = Vec::new();
6266    let mut window = Vec::new();
6267    let mut window_bytes = 0_u64;
6268    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6269                 window_bytes: &mut u64,
6270                 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6271     -> LinkResult<()> {
6272        if window.is_empty() {
6273            return Ok(());
6274        }
6275        let missing = window
6276            .iter()
6277            .filter_map(|(path, file)| {
6278                let target = cache_dir.join(&file.sha256);
6279                match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6280                    Ok(true) => {
6281                        staged.insert(
6282                            (*path).clone(),
6283                            V2StagedFile {
6284                                path: (*path).clone(),
6285                                source: target,
6286                                sha256: file.sha256.clone(),
6287                                bytes: file.bytes,
6288                            },
6289                        );
6290                        None
6291                    }
6292                    Ok(false) => Some(Ok((*path, *file))),
6293                    Err(error) => Some(Err(error)),
6294                }
6295            })
6296            .collect::<LinkResult<Vec<_>>>()?;
6297        if !missing.is_empty() {
6298            for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6299                let file = missing
6300                    .iter()
6301                    .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6302                    .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6303                let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6304                staged.insert(
6305                    path.clone(),
6306                    V2StagedFile {
6307                        path,
6308                        source,
6309                        sha256: file.sha256.clone(),
6310                        bytes: file.bytes,
6311                    },
6312                );
6313            }
6314        }
6315        window.clear();
6316        *window_bytes = 0;
6317        Ok(())
6318    };
6319    for &(path, file) in &pending {
6320        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6321            flush(&mut window, &mut window_bytes, &mut staged)?;
6322            direct.push((path, file));
6323            continue;
6324        }
6325        if window.len() == V2_BULK_STREAM_FILES
6326            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6327        {
6328            flush(&mut window, &mut window_bytes, &mut staged)?;
6329        }
6330        window.push((path, file));
6331        window_bytes += file.bytes;
6332    }
6333    flush(&mut window, &mut window_bytes, &mut staged)?;
6334    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6335        let source =
6336            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6337        staged.insert(
6338            item.path.clone(),
6339            V2StagedFile {
6340                path: item.path,
6341                source,
6342                sha256: item.sha256,
6343                bytes: item.bytes,
6344            },
6345        );
6346    }
6347    pending
6348        .into_iter()
6349        .map(|(path, _)| {
6350            staged
6351                .remove(path)
6352                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6353        })
6354        .collect()
6355}
6356
6357#[cfg(not(any(unix, windows)))]
6358fn stage_v2_blobs(
6359    _cfg: &HubConfig,
6360    _brain: &str,
6361    _pointer: &V2PointerBody,
6362    _pending: Vec<(&String, &V2BaselineFile)>,
6363) -> LinkResult<Vec<V2StagedFile>> {
6364    Err(LinkError::UnsupportedPlatform {
6365        operation: "resumable v2 download staging",
6366    })
6367}
6368
6369const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6370const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6371const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6372
6373#[derive(Debug, Clone, Deserialize, Serialize)]
6374struct V2ConflictCoordinate {
6375    sha256: Option<String>,
6376    bytes: Option<u64>,
6377    file: Option<String>,
6378}
6379
6380#[derive(Debug, Clone, Deserialize, Serialize)]
6381struct V2ConflictFile {
6382    path: String,
6383    base: V2ConflictCoordinate,
6384    local: V2ConflictCoordinate,
6385    remote: V2ConflictCoordinate,
6386}
6387
6388#[derive(Debug, Clone, Deserialize, Serialize)]
6389struct V2ConflictPlan {
6390    v: u8,
6391    class: String,
6392    bundle: String,
6393    brain: String,
6394    origin: String,
6395    created_unix: u64,
6396    expires_unix: u64,
6397    base_seq: Option<u64>,
6398    base_commit: Option<String>,
6399    remote_seq: u64,
6400    remote_commit: Option<String>,
6401    remote_content_root: Option<String>,
6402    view_kind: String,
6403    view_revision: String,
6404    files: Vec<V2ConflictFile>,
6405}
6406
6407fn v2_take_remote_selection(
6408    files: &[V2ConflictFile],
6409    current: &std::collections::BTreeMap<String, V2BaselineFile>,
6410) -> LinkResult<(
6411    std::collections::BTreeMap<String, V2BaselineFile>,
6412    Vec<String>,
6413)> {
6414    let mut selected = std::collections::BTreeMap::new();
6415    let mut deleted = Vec::new();
6416    for file in files {
6417        match (&file.remote.sha256, file.remote.bytes) {
6418            (Some(sha256), Some(bytes)) => {
6419                let proven = current.get(&file.path).ok_or_else(|| {
6420                    invalid_feed("conflict remote coordinate disappeared from the exact head")
6421                })?;
6422                if proven.sha256 != *sha256 || proven.bytes != bytes {
6423                    return Err(invalid_feed(
6424                        "conflict remote coordinate differs from the exact head",
6425                    ));
6426                }
6427                if selected.insert(file.path.clone(), proven.clone()).is_some() {
6428                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6429                }
6430            }
6431            (None, None) => {
6432                if current.contains_key(&file.path) {
6433                    return Err(invalid_feed(
6434                        "conflict remote deletion differs from the exact head",
6435                    ));
6436                }
6437                deleted.push(file.path.clone());
6438            }
6439            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6440        }
6441    }
6442    Ok((selected, deleted))
6443}
6444
6445fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6446    PathBuf::from(".dbmd")
6447        .join("conflicts")
6448        .join(bundle)
6449        .join(suffix)
6450}
6451
6452fn read_historical_conflict_blob(
6453    cfg: &HubConfig,
6454    brain: &str,
6455    baseline: &V2SyncBaseline,
6456    path: &str,
6457    file: &V2BaselineFile,
6458) -> LinkResult<Option<Vec<u8>>> {
6459    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6460        return Ok(None);
6461    };
6462    if seq == 0 {
6463        return Ok(None);
6464    }
6465    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6466    let endpoint = format!(
6467        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6468        file.sha256
6469    );
6470    let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6471    if raw.status == 404 || raw.status == 403 {
6472        return Ok(None);
6473    }
6474    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6475    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6476        return Err(invalid_feed(
6477            "v2 conflict base failed integrity verification",
6478        ));
6479    }
6480    Ok(Some(bytes))
6481}
6482
6483/// Persist a readable content conflict without modifying either side. The
6484/// plan is written last, so an interrupted bundle build is never actionable.
6485fn create_v2_conflict_bundle(
6486    cfg: &HubConfig,
6487    store: &Store,
6488    head: &V2VerifiedHead,
6489    baseline: Option<&V2SyncBaseline>,
6490    local: &std::collections::BTreeMap<String, (String, u64)>,
6491    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6492    paths: &[String],
6493) -> LinkResult<(String, Vec<String>)> {
6494    let conflicts_root = Path::new(".dbmd/conflicts");
6495    store.create_dir_all(conflicts_root)?;
6496    let completed = store
6497        .directory_names(conflicts_root)?
6498        .into_iter()
6499        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6500        .count();
6501    if completed >= V2_CONFLICT_BUNDLE_MAX {
6502        return Err(LinkError::InvalidPack {
6503            message: format!(
6504                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6505            ),
6506        });
6507    }
6508
6509    // A conflict bundle is diagnostic state, not a second whole-brain cache.
6510    // Materialize a deterministic prefix whose remote artifacts fit one
6511    // bounded window. Resolving it exposes the next prefix on the next sync.
6512    let mut selected_paths = Vec::new();
6513    let mut selected_remote_bytes = 0_u64;
6514    for path in paths {
6515        let bytes = remote.get(path).map_or(0, |file| file.bytes);
6516        if !selected_paths.is_empty()
6517            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6518        {
6519            break;
6520        }
6521        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6522        selected_paths.push(path.clone());
6523        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6524            break;
6525        }
6526    }
6527    if selected_paths.is_empty() {
6528        return Err(invalid_feed("content conflict set is empty"));
6529    }
6530    let bundle = crate::ulid::mint();
6531    let bundle_root = v2_conflict_relative(&bundle, "");
6532    store.create_dir_all(&bundle_root.join("files"))?;
6533    let pointer = head.pointer.as_ref();
6534    let remote_bytes = match pointer {
6535        Some(pointer) => download_v2_blobs(
6536            cfg,
6537            &head.brain_id,
6538            pointer,
6539            selected_paths
6540                .iter()
6541                .filter_map(|path| {
6542                    remote
6543                        .get(path)
6544                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6545                        .map(|file| (path, file))
6546                })
6547                .collect(),
6548        )?
6549        .into_iter()
6550        .collect::<std::collections::BTreeMap<_, _>>(),
6551        None => std::collections::BTreeMap::new(),
6552    };
6553
6554    let mut files = Vec::with_capacity(selected_paths.len());
6555    for (index, path) in selected_paths.iter().enumerate() {
6556        let base_file = baseline.and_then(|state| state.files.get(path));
6557        let base_bytes = match (baseline, base_file) {
6558            (Some(state), Some(file)) => {
6559                read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6560            }
6561            _ => None,
6562        };
6563        let local_file = local.get(path);
6564        let remote_file = remote.get(path);
6565        let remote_content = remote_bytes.get(path);
6566        let prefix = format!("files/{index:04}");
6567        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6568        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6569        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6570        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6571            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6572        }
6573        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6574            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6575            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6576                return Err(LinkError::InvalidPack {
6577                    message: format!("local conflict path `{path}` changed while bundling"),
6578                });
6579            }
6580            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6581        }
6582        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6583            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6584        }
6585        files.push(V2ConflictFile {
6586            path: path.clone(),
6587            base: V2ConflictCoordinate {
6588                sha256: base_file.map(|file| file.sha256.clone()),
6589                bytes: base_file.map(|file| file.bytes),
6590                file: base_name,
6591            },
6592            local: V2ConflictCoordinate {
6593                sha256: local_file.map(|(sha256, _)| sha256.clone()),
6594                bytes: local_file.map(|(_, bytes)| *bytes),
6595                file: local_name,
6596            },
6597            remote: V2ConflictCoordinate {
6598                sha256: remote_file.map(|file| file.sha256.clone()),
6599                bytes: remote_file.map(|file| file.bytes),
6600                file: remote_name,
6601            },
6602        });
6603    }
6604    let now = SystemTime::now()
6605        .duration_since(UNIX_EPOCH)
6606        .unwrap_or_default()
6607        .as_secs();
6608    let plan = V2ConflictPlan {
6609        v: 2,
6610        class: "content_resolution_required".to_string(),
6611        bundle: bundle.clone(),
6612        brain: head.brain_id.clone(),
6613        origin: normalized_origin(&cfg.hub)?,
6614        created_unix: now,
6615        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6616        base_seq: baseline.and_then(|state| state.head_seq),
6617        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6618        remote_seq: pointer.map_or(0, |value| value.seq),
6619        remote_commit: pointer.map(|value| value.commit_hash.clone()),
6620        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6621        view_kind: head.view_kind.clone(),
6622        view_revision: head.view_revision.clone(),
6623        files,
6624    };
6625    let mut bytes = serde_json::to_vec_pretty(&plan)
6626        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6627    bytes.push(b'\n');
6628    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6629    Ok((bundle, selected_paths))
6630}
6631
6632fn v2_sync_pull_with_resolution(
6633    cfg: &HubConfig,
6634    requested_brain: &str,
6635    expected_head: V2VerifiedHead,
6636    out: Option<&Path>,
6637    take_remote: Option<&std::collections::BTreeSet<String>>,
6638) -> LinkResult<V2PulledSnapshot> {
6639    let dest = out
6640        .map(Path::to_path_buf)
6641        .unwrap_or_else(|| PathBuf::from(requested_brain));
6642    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6643    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6644    let head = v2_verified_head(cfg, requested_brain)?
6645        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6646    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6647        return Err(LinkError::RemoteAdvancedDuringSync);
6648    }
6649    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6650    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6651    let (remote, remote_assets) = match baseline
6652        .as_ref()
6653        .filter(|state| v2_baseline_matches_head(&head, state))
6654    {
6655        Some(state) => (state.files.clone(), state.assets.clone()),
6656        None => (
6657            files_for_v2_view(
6658                &head,
6659                v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6660            ),
6661            v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6662        ),
6663    };
6664    let local_store = Store::open_strict(&dest).ok();
6665    // A baseline proves this path is an established checkout. Never reinterpret
6666    // a damaged/edited store marker as an empty clone destination: doing so
6667    // would let the pull half erase the local evidence before the push half can
6668    // reject it (most importantly for the generated scoped-view DB.md).
6669    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6670    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6671    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6672        return Err(LinkError::ScopedViewChanged);
6673    }
6674    if let Some(view) = local_view.as_mut() {
6675        remove_scoped_projection(&head, baseline.as_ref(), view)?;
6676    }
6677    let empty_local = std::collections::BTreeMap::new();
6678    let local = local_view
6679        .as_ref()
6680        .map_or(&empty_local, |view| &view.riding);
6681    let kept_home = |path: &str| {
6682        local_view
6683            .as_ref()
6684            .is_some_and(|view| view.policy.keeps_home(path))
6685    };
6686    let empty_base = std::collections::BTreeMap::new();
6687    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6688    let empty_base_assets = std::collections::BTreeMap::new();
6689    let base_assets = baseline
6690        .as_ref()
6691        .map_or(&empty_base_assets, |state| &state.assets);
6692    let mut local_assets = local_store
6693        .as_ref()
6694        .map(v2_local_asset_records)
6695        .transpose()?
6696        .unwrap_or_default();
6697    let mut content_merge = merge_v2_pulled_records(
6698        base,
6699        &remote,
6700        local,
6701        |file, _| (file.sha256.clone(), file.bytes),
6702        |file, _| (file.sha256.clone(), file.bytes),
6703        kept_home,
6704    );
6705    if let Some(selected) = take_remote {
6706        for path in selected {
6707            if let Some(position) = content_merge
6708                .conflicts
6709                .iter()
6710                .position(|conflict| conflict == path)
6711            {
6712                content_merge.conflicts.remove(position);
6713                content_merge.accept_remote.insert(path.clone());
6714                match remote.get(path) {
6715                    Some(file) => {
6716                        content_merge
6717                            .records
6718                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
6719                    }
6720                    None => {
6721                        content_merge.records.remove(path);
6722                    }
6723                }
6724            } else if !content_merge.accept_remote.contains(path) {
6725                return Err(LinkError::InvalidPack {
6726                    message: format!(
6727                        "take-remote path `{path}` is no longer at its conflict coordinate"
6728                    ),
6729                });
6730            }
6731        }
6732    }
6733    if !content_merge.conflicts.is_empty() {
6734        let mut conflicts = content_merge.conflicts.clone();
6735        conflicts.truncate(100);
6736        if let Some(store) = local_store.as_ref() {
6737            let (bundle, paths) = create_v2_conflict_bundle(
6738                cfg,
6739                store,
6740                &head,
6741                baseline.as_ref(),
6742                local,
6743                &remote,
6744                &conflicts,
6745            )?;
6746            return Err(LinkError::ConflictBundle { bundle, paths });
6747        }
6748        return Err(LinkError::Conflict { paths: conflicts });
6749    }
6750    let asset_merge = merge_v2_pulled_records(
6751        base_assets,
6752        &remote_assets,
6753        &local_assets,
6754        v2_asset_record,
6755        v2_asset_record,
6756        |_| false,
6757    );
6758    if !asset_merge.conflicts.is_empty() {
6759        let mut conflicts = asset_merge.conflicts.clone();
6760        conflicts.truncate(100);
6761        return Err(LinkError::Conflict { paths: conflicts });
6762    }
6763    let pointer = head.pointer.as_ref();
6764    let cache_transaction = pointer.map_or_else(
6765        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6766        |value| value.commit_hash.clone(),
6767    );
6768    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6769    let mut changed = match pointer {
6770        Some(pointer) => stage_v2_blobs(
6771            cfg,
6772            &head.brain_id,
6773            pointer,
6774            remote
6775                .iter()
6776                .filter(|(path, file)| {
6777                    content_merge.accept_remote.contains(*path)
6778                        && local.get(*path).map(|value| value.0.as_str())
6779                            != Some(file.sha256.as_str())
6780                })
6781                .collect(),
6782        )?,
6783        None => Vec::new(),
6784    };
6785    let mut deleted = content_merge
6786        .accept_remote
6787        .iter()
6788        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6789        .cloned()
6790        .collect::<Vec<_>>();
6791    if local_assets != asset_merge.records {
6792        if asset_merge.records.is_empty() {
6793            deleted.push("assets.jsonl".to_string());
6794        } else {
6795            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6796            let sha256 = content_sha256(&bytes);
6797            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6798            changed.push(V2StagedFile {
6799                path: "assets.jsonl".to_string(),
6800                source,
6801                sha256,
6802                bytes: bytes.len() as u64,
6803            });
6804        }
6805    }
6806    if let Some(pointer) = pointer {
6807        let mut pending_assets = Vec::new();
6808        for (path, asset) in &remote_assets {
6809            if asset.disposition != "hosted"
6810                || kept_home(path)
6811                || !asset_merge.accept_remote.contains(path)
6812            {
6813                continue;
6814            }
6815            let already_current = local_store.as_ref().is_some_and(|store| {
6816                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6817                    && store
6818                        .read_bounded(Path::new(path), asset.bytes)
6819                        .ok()
6820                        .is_some_and(|bytes| {
6821                            bytes.len() as u64 == asset.bytes
6822                                && content_sha256(&bytes) == asset.blob_sha256
6823                        })
6824            });
6825            if !already_current {
6826                pending_assets.push((path, asset));
6827            }
6828        }
6829        let mut window = Vec::new();
6830        let mut window_bytes = 0_u64;
6831        let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
6832                     window_bytes: &mut u64,
6833                     changed: &mut Vec<V2StagedFile>|
6834         -> LinkResult<()> {
6835            changed.extend(stage_v2_asset_download_window(
6836                cfg,
6837                &head.brain_id,
6838                pointer,
6839                &cache_dir,
6840                window,
6841            )?);
6842            window.clear();
6843            *window_bytes = 0;
6844            Ok(())
6845        };
6846        for item @ (_, asset) in pending_assets {
6847            if !window.is_empty()
6848                && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
6849                    || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
6850            {
6851                flush(&mut window, &mut window_bytes, &mut changed)?;
6852            }
6853            window.push(item);
6854            window_bytes = window_bytes.saturating_add(asset.bytes);
6855            if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
6856                flush(&mut window, &mut window_bytes, &mut changed)?;
6857            }
6858        }
6859        flush(&mut window, &mut window_bytes, &mut changed)?;
6860    }
6861    for (path, prior) in base_assets {
6862        if remote_assets.contains_key(path)
6863            || kept_home(path)
6864            || !asset_merge.accept_remote.contains(path)
6865        {
6866            continue;
6867        }
6868        let unchanged = local_store.as_ref().is_some_and(|store| {
6869            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6870                && store
6871                    .read_bounded(Path::new(path), prior.bytes)
6872                    .ok()
6873                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6874        });
6875        if unchanged {
6876            deleted.push(path.clone());
6877        }
6878    }
6879    let extra_local = content_merge
6880        .records
6881        .keys()
6882        .filter(|path| !remote.contains_key(*path))
6883        .cloned()
6884        .collect::<Vec<_>>();
6885    if head.view_kind == "scoped" {
6886        for (path, bytes) in [
6887            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6888            (
6889                ".dbmd/view.json".to_string(),
6890                scoped_view_metadata(&head, remote.len())?,
6891            ),
6892        ] {
6893            let sha256 = content_sha256(&bytes);
6894            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6895            changed.push(V2StagedFile {
6896                path,
6897                source,
6898                sha256,
6899                bytes: bytes.len() as u64,
6900            });
6901        }
6902    }
6903    let install_changed = !changed.is_empty() || !deleted.is_empty();
6904    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6905    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6906        let installed_store =
6907            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6908                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6909            })?;
6910        let installed_local = if install_changed {
6911            let mut scanned = v2_local_files(&installed_store)?;
6912            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6913            scanned
6914        } else {
6915            local_view
6916                .take()
6917                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6918        };
6919        if installed_local.riding != content_merge.records {
6920            return Err(LinkError::InvalidPack {
6921                message: "local content changed while installing the v2 pull".to_string(),
6922            });
6923        }
6924        let installed_assets = if install_changed {
6925            v2_local_asset_records(&installed_store)?
6926        } else {
6927            std::mem::take(&mut local_assets)
6928        };
6929        if installed_assets != asset_merge.records {
6930            return Err(LinkError::InvalidPack {
6931                message: "local assets changed while installing the v2 pull".to_string(),
6932            });
6933        }
6934        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6935            installed_local.policy.keeps_home(path)
6936        })
6937            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6938        let final_head = v2_verified_head(cfg, requested_brain)?
6939            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6940        if !same_v2_head(&head, &final_head) {
6941            return Err(LinkError::RemoteAdvancedDuringSync);
6942        }
6943        accept_v2_head(cfg, &final_head)?;
6944        save_v2_baseline(
6945            cfg,
6946            &head.brain_id,
6947            &dest,
6948            &v2_baseline_from_head(
6949                cfg,
6950                &head,
6951                remote.clone(),
6952                remote_assets.clone(),
6953                Some(&installed_local),
6954                baseline
6955                    .as_ref()
6956                    .and_then(|current| current.checkout_id.as_deref()),
6957            )?,
6958        )?;
6959        complete_v2_pull(&dest)?;
6960        Ok((local_dirty, installed_local, installed_assets))
6961    })();
6962    let (local_dirty, installed_local, installed_assets) = match finalized {
6963        Ok(value) => value,
6964        Err(error) => {
6965            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6966                return Err(LinkError::InvalidPack {
6967                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
6968                });
6969            }
6970            return Err(error);
6971        }
6972    };
6973    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6974    let report = PullReport {
6975        brain: head.brain_id.clone(),
6976        slug: requested_brain.to_string(),
6977        head_seq: pointer.map_or(0, |value| value.seq),
6978        files: remote.len() + remote_assets.len(),
6979        dest: dest.to_string_lossy().into_owned(),
6980        extra_local,
6981        sync_status: if local_dirty {
6982            "local_dirty_after_install".to_string()
6983        } else {
6984            "synced".to_string()
6985        },
6986    };
6987    Ok(V2PulledSnapshot {
6988        report,
6989        head,
6990        files: remote,
6991        assets: remote_assets,
6992        local: installed_local,
6993        local_assets: installed_assets,
6994    })
6995}
6996
6997fn v2_sync_pull(
6998    cfg: &HubConfig,
6999    requested_brain: &str,
7000    head: V2VerifiedHead,
7001    out: Option<&Path>,
7002) -> LinkResult<PullReport> {
7003    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
7004}
7005
7006fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
7007    match remote {
7008        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
7009        None => json!({ "kind": "absent" }),
7010    }
7011}
7012
7013fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7014    match remote {
7015        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7016        None => json!({ "kind": "absent" }),
7017    }
7018}
7019
7020fn v2_content_withdrawal_operation(
7021    store: &Store,
7022    local_view: &V2LocalView,
7023    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7024    path: &str,
7025    reason: &str,
7026) -> LinkResult<Value> {
7027    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7028        || path == "DB.md"
7029    {
7030        return Err(LinkError::InvalidPack {
7031            message: format!("content withdrawal path `{path}` is not a record or source"),
7032        });
7033    }
7034    if !local_view.policy.keeps_home(path)
7035        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7036    {
7037        return Err(LinkError::InvalidPack {
7038            message: format!(
7039                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7040            ),
7041        });
7042    }
7043    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7044        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7045    })?;
7046    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
7047    Ok(json!({
7048        "op": "withdraw_from_hosting",
7049        "path": path,
7050        "expected": { "kind": "blob", "hash": current.sha256 },
7051        "reason": reason,
7052    }))
7053}
7054
7055fn v2_asset_withdrawal_operation(
7056    store: &Store,
7057    local_view: &V2LocalView,
7058    path: &str,
7059    local: &crate::AssetRecord,
7060    current: &V2BaselineAsset,
7061    reason: &str,
7062) -> LinkResult<Value> {
7063    if !local_view.policy.keeps_home(path)
7064        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7065    {
7066        return Err(LinkError::InvalidPack {
7067            message: format!(
7068                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7069            ),
7070        });
7071    }
7072    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7073    if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
7074        return Err(LinkError::InvalidPack {
7075            message: format!(
7076                "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
7077            ),
7078        });
7079    }
7080    Ok(json!({
7081        "op": "asset_withdraw",
7082        "path": path,
7083        "expected": v2_asset_expected(Some(current)),
7084        "reason": reason,
7085    }))
7086}
7087
7088// A source is immutable, but its exact bytes may move from an intake namespace
7089// to an archive namespace under the distinct `promote_source` capability. A
7090// plain filesystem move appears to sync as one absent old path plus one new
7091// path. Preserve that intent as a protocol rename only when the hash has one
7092// unambiguous source delete and one unambiguous absent-destination source put.
7093// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
7094fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7095    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7096    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7097    for (index, operation) in operations.iter().enumerate() {
7098        match operation.get("op").and_then(Value::as_str) {
7099            Some("delete") => {
7100                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7101                    continue;
7102                };
7103                let Some(hash) = operation
7104                    .get("expected")
7105                    .and_then(|value| value.get("hash"))
7106                    .and_then(Value::as_str)
7107                else {
7108                    continue;
7109                };
7110                if path.starts_with("sources/") {
7111                    deletes
7112                        .entry(hash.to_string())
7113                        .or_default()
7114                        .push((index, path.to_string()));
7115                }
7116            }
7117            Some("put") => {
7118                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7119                    continue;
7120                };
7121                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7122                    continue;
7123                };
7124                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7125                    continue;
7126                };
7127                let destination_absent = operation
7128                    .get("expected")
7129                    .and_then(|value| value.get("kind"))
7130                    .and_then(Value::as_str)
7131                    == Some("absent");
7132                if path.starts_with("sources/") && destination_absent {
7133                    puts.entry(hash.to_string()).or_default().push((
7134                        index,
7135                        path.to_string(),
7136                        bytes,
7137                    ));
7138                }
7139            }
7140            _ => {}
7141        }
7142    }
7143    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7144    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7145    for (hash, source) in deletes {
7146        let Some(destination) = puts.get(&hash) else {
7147            continue;
7148        };
7149        if source.len() != 1 || destination.len() != 1 {
7150            continue;
7151        }
7152        let (delete_index, from) = &source[0];
7153        let (put_index, to, bytes) = &destination[0];
7154        if from == to {
7155            continue;
7156        }
7157        rename_at.insert(
7158            *delete_index,
7159            json!({
7160                "op": "rename",
7161                "from": from,
7162                "to": to,
7163                "expected_from": { "kind": "blob", "hash": hash },
7164                "expected_to": { "kind": "absent" },
7165                "blob": hash,
7166                "bytes": bytes,
7167            }),
7168        );
7169        consumed_puts.insert(*put_index);
7170    }
7171    operations
7172        .into_iter()
7173        .enumerate()
7174        .filter_map(|(index, operation)| {
7175            if let Some(rename) = rename_at.remove(&index) {
7176                Some(rename)
7177            } else if consumed_puts.contains(&index) {
7178                None
7179            } else {
7180                Some(operation)
7181            }
7182        })
7183        .collect()
7184}
7185
7186fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7187    json!({
7188        "blob_sha256": record.sha256,
7189        "bytes": record.bytes,
7190        "media_type": record.media_type,
7191        "wrappers": record.wrappers,
7192        "required": record.required,
7193        "disposition": disposition,
7194    })
7195}
7196
7197/// Apply the exact operation subset generated by this client to an already
7198/// verified manifest. This avoids downloading the same 50k-path listing again
7199/// after an unrebased commit; a rebased response still re-reads the manifest.
7200fn apply_generated_v2_operations(
7201    operations: &[Value],
7202    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7203    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7204    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7205) -> LinkResult<bool> {
7206    let mut asset_changed = false;
7207    for operation in operations {
7208        match operation.get("op").and_then(Value::as_str) {
7209            Some("put") => {
7210                let path = operation
7211                    .get("path")
7212                    .and_then(Value::as_str)
7213                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7214                let sha256 = operation
7215                    .get("blob")
7216                    .and_then(Value::as_str)
7217                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7218                let bytes = operation
7219                    .get("bytes")
7220                    .and_then(Value::as_u64)
7221                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7222                candidate.insert(
7223                    path.to_string(),
7224                    V2BaselineFile {
7225                        sha256: sha256.to_string(),
7226                        bytes,
7227                        proof: None,
7228                    },
7229                );
7230            }
7231            Some("rename") => {
7232                let from = operation
7233                    .get("from")
7234                    .and_then(Value::as_str)
7235                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7236                let to = operation
7237                    .get("to")
7238                    .and_then(Value::as_str)
7239                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7240                let sha256 = operation
7241                    .get("blob")
7242                    .and_then(Value::as_str)
7243                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7244                let bytes = operation
7245                    .get("bytes")
7246                    .and_then(Value::as_u64)
7247                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7248                let expected_from = operation
7249                    .get("expected_from")
7250                    .and_then(|expected| expected.get("hash"))
7251                    .and_then(Value::as_str);
7252                let expected_to_absent = operation
7253                    .get("expected_to")
7254                    .and_then(|expected| expected.get("kind"))
7255                    .and_then(Value::as_str)
7256                    == Some("absent");
7257                if from == to
7258                    || !from.starts_with("sources/")
7259                    || !to.starts_with("sources/")
7260                    || expected_from != Some(sha256)
7261                    || !expected_to_absent
7262                    || candidate.contains_key(to)
7263                {
7264                    return Err(invalid_feed("generated v2 source rename is malformed"));
7265                }
7266                let source = candidate
7267                    .remove(from)
7268                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7269                if source.sha256 != sha256 || source.bytes != bytes {
7270                    return Err(invalid_feed(
7271                        "v2 rename source differs from its exact-byte claim",
7272                    ));
7273                }
7274                candidate.insert(
7275                    to.to_string(),
7276                    V2BaselineFile {
7277                        sha256: sha256.to_string(),
7278                        bytes,
7279                        proof: None,
7280                    },
7281                );
7282            }
7283            Some("delete" | "withdraw_from_hosting") => {
7284                let path = operation
7285                    .get("path")
7286                    .and_then(Value::as_str)
7287                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7288                candidate.remove(path);
7289            }
7290            Some("asset_delete") => {
7291                let path = operation
7292                    .get("path")
7293                    .and_then(Value::as_str)
7294                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7295                candidate_assets.remove(path);
7296                asset_changed = true;
7297            }
7298            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7299                let path = operation
7300                    .get("path")
7301                    .and_then(Value::as_str)
7302                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7303                let record = local_assets
7304                    .get(path)
7305                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7306                let disposition =
7307                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7308                        "withheld"
7309                    } else {
7310                        operation
7311                            .get("asset")
7312                            .and_then(|asset| asset.get("disposition"))
7313                            .and_then(Value::as_str)
7314                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7315                    };
7316                candidate_assets.insert(
7317                    path.to_string(),
7318                    V2BaselineAsset {
7319                        blob_sha256: record.sha256.clone(),
7320                        bytes: record.bytes,
7321                        media_type: record.media_type.clone(),
7322                        wrappers: record.wrappers.clone(),
7323                        required: record.required,
7324                        disposition: disposition.to_string(),
7325                        // The server assigns the signed leaf address. Callers
7326                        // that need an exact asset manifest re-read it below.
7327                        leaf_hash: String::new(),
7328                    },
7329                );
7330                asset_changed = true;
7331            }
7332            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7333        }
7334    }
7335    Ok(asset_changed)
7336}
7337
7338fn v2_riding_matches_remote(
7339    local: &std::collections::BTreeMap<String, (String, u64)>,
7340    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7341    keeps_home: impl Fn(&str) -> bool,
7342) -> bool {
7343    remote.iter().all(|(path, file)| {
7344        keeps_home(path)
7345            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7346    }) && local.iter().all(|(path, (hash, _))| {
7347        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7348    })
7349}
7350
7351fn v2_initial_content_conflicts(
7352    local: &std::collections::BTreeMap<String, (String, u64)>,
7353    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7354    resolving: bool,
7355) -> Vec<String> {
7356    if resolving {
7357        // `sync resolve` already pins the exact remote head/view and rechecks
7358        // every bundled local coordinate before it enters the push planner.
7359        // Treating this as an ordinary baseline-less push would merely mint an
7360        // identical conflict bundle forever. The operation loop below permits
7361        // only those explicit remote replacements plus safe local-only
7362        // additions, and verifies each selected local/remote pair again before
7363        // authoring the commit.
7364        return Vec::new();
7365    }
7366    remote
7367        .iter()
7368        .filter(|(path, file)| {
7369            local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7370        })
7371        .map(|(path, _)| path.clone())
7372        .collect()
7373}
7374
7375fn v2_resolution_allows_path(
7376    resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7377    path: &str,
7378    remote_present: bool,
7379) -> bool {
7380    resolution.is_none_or(|allowed| allowed.contains_key(path) || !remote_present)
7381}
7382
7383#[derive(Debug, Clone)]
7384struct V2ResolutionOverride {
7385    expected_remote: Option<String>,
7386    selected_local: Option<String>,
7387}
7388
7389#[derive(Debug, Clone)]
7390struct V2UploadSource {
7391    path: String,
7392    bytes: u64,
7393}
7394
7395struct V2SyncPushOptions<'a> {
7396    resume_local_policy: bool,
7397    bulk_confirmation: Option<&'a V2BulkConfirmation>,
7398    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7399    pulled: Option<V2PulledSnapshot>,
7400    withdrawal_paths: &'a [String],
7401    withdrawal_reason: Option<&'a str>,
7402}
7403
7404fn verify_v2_upload_source(
7405    store: &Store,
7406    path: &str,
7407    sha256: &str,
7408    expected_bytes: u64,
7409) -> LinkResult<()> {
7410    let file = store.open_regular(Path::new(path))?;
7411    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7412        return Err(LinkError::InvalidPack {
7413            message: format!("local path `{path}` changed during sync planning"),
7414        });
7415    }
7416    Ok(())
7417}
7418
7419/// One reserved object waiting to be staged, held until its whole batch can be
7420/// uploaded together.
7421struct V2PendingUpload<'a> {
7422    url: String,
7423    headers: Value,
7424    sha256: String,
7425    source: &'a V2UploadSource,
7426}
7427
7428/// How many objects a migration stages at once. Bounded so a first push does
7429/// not behave like a burst against the object store, while still being far
7430/// faster than one-at-a-time.
7431/// Workers staging one batch. With a shared pooled connection the cost of an
7432/// object is the PUT itself rather than a handshake, so a wider pool converts
7433/// directly into throughput on a migration.
7434const V2_UPLOAD_CONCURRENCY: usize = 16;
7435
7436/// Stage a reserved batch, uploading up to `V2_UPLOAD_CONCURRENCY` objects at a
7437/// time and verifying each against the store after it lands. The first failure
7438/// is returned and the remaining work is abandoned.
7439fn upload_v2_batch_concurrently(
7440    cfg: &HubConfig,
7441    store: &Store,
7442    pending: &[V2PendingUpload<'_>],
7443) -> LinkResult<()> {
7444    if pending.is_empty() {
7445        return Ok(());
7446    }
7447    let urls = pending
7448        .iter()
7449        .map(|task| task.url.as_str())
7450        .collect::<Vec<_>>();
7451    let shared = shared_staging_agent(cfg, &urls);
7452    if pending.len() == 1 {
7453        let task = &pending[0];
7454        put_presigned_source(
7455            cfg,
7456            &task.url,
7457            &task.headers,
7458            store,
7459            task.source,
7460            shared.as_ref(),
7461        )?;
7462        return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7463    }
7464    let next = std::sync::atomic::AtomicUsize::new(0);
7465    let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7466    let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7467    std::thread::scope(|scope| {
7468        for _ in 0..workers {
7469            scope.spawn(|| loop {
7470                if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7471                    return;
7472                }
7473                let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7474                let Some(task) = pending.get(index) else {
7475                    return;
7476                };
7477                let outcome = put_presigned_source(
7478                    cfg,
7479                    &task.url,
7480                    &task.headers,
7481                    store,
7482                    task.source,
7483                    shared.as_ref(),
7484                )
7485                .and_then(|()| {
7486                    verify_v2_upload_source(
7487                        store,
7488                        &task.source.path,
7489                        &task.sha256,
7490                        task.source.bytes,
7491                    )
7492                });
7493                if let Err(error) = outcome {
7494                    if let Ok(mut guard) = failure.lock() {
7495                        guard.get_or_insert(error);
7496                    }
7497                    return;
7498                }
7499            });
7500        }
7501    });
7502    match failure.into_inner() {
7503        Ok(Some(error)) => Err(error),
7504        Ok(None) => Ok(()),
7505        Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7506    }
7507}
7508
7509fn put_presigned_source(
7510    cfg: &HubConfig,
7511    raw: &str,
7512    headers: &Value,
7513    store: &Store,
7514    source: &V2UploadSource,
7515    shared: Option<&ureq::Agent>,
7516) -> LinkResult<()> {
7517    put_presigned_source_with_budget(
7518        cfg,
7519        raw,
7520        headers,
7521        store,
7522        source,
7523        shared,
7524        std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7525    )
7526}
7527
7528fn put_presigned_source_with_budget(
7529    cfg: &HubConfig,
7530    raw: &str,
7531    headers: &Value,
7532    store: &Store,
7533    source: &V2UploadSource,
7534    shared: Option<&ureq::Agent>,
7535    total_budget: std::time::Duration,
7536) -> LinkResult<()> {
7537    // The URL's own safety checks run either way; only the connection pool is
7538    // shared, so a batch never trades a check for a warm socket.
7539    let owned = match shared {
7540        Some(_) => {
7541            checked_presigned_url(cfg, raw)?;
7542            None
7543        }
7544        None => Some(presigned_agent(cfg, raw)?),
7545    };
7546    let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7547    let deadline = std::time::Instant::now()
7548        .checked_add(total_budget)
7549        .ok_or_else(upload_deadline_error)?;
7550    let mut attempt = 0;
7551    let result = loop {
7552        let file = store.open_regular(Path::new(&source.path))?;
7553        if file.metadata()?.len() != source.bytes {
7554            return Err(LinkError::InvalidPack {
7555                message: format!("local path `{}` changed before upload", source.path),
7556            });
7557        }
7558        // The reservation already states content-length, lowercased. The HTTP
7559        // client de-duplicates headers by exact name, not case, so setting
7560        // `Content-Length` here as well leaves both on the wire and an edge
7561        // rejects the request as malformed. Supply it only when absent.
7562        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7563        let mut has_content_length = false;
7564        if let Some(map) = headers.as_object() {
7565            for (name, value) in map {
7566                if let Some(value) = value.as_str() {
7567                    has_content_length |= name.eq_ignore_ascii_case("content-length");
7568                    req = req.set(name, value);
7569                }
7570            }
7571        }
7572        if !has_content_length {
7573            req = req.set("Content-Length", &source.bytes.to_string());
7574        }
7575        match req.send(file) {
7576            // Every transport failure is retryable here, not only the
7577            // pre-request kinds: staging an object is idempotent (fixed content
7578            // address, exact length and checksum, write-once precondition), the
7579            // file is reopened on each attempt, and a migration holds
7580            // connections long enough that a mid-request drop is ordinary.
7581            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7582                attempt += 1;
7583            }
7584            // A migration stages tens of thousands of immutable objects, and an
7585            // edge or object store may refuse a single one under sustained
7586            // load. The upload is content-addressed and precondition-guarded,
7587            // so replaying it is safe; failing the whole push on one refusal is
7588            // not. 412 already means "someone else stored these exact bytes".
7589            Err(ureq::Error::Status(status, _))
7590                if status != 412
7591                    && is_retryable_upload_status(status)
7592                    && wait_for_upload_retry(deadline, attempt) =>
7593            {
7594                attempt += 1;
7595            }
7596            result => break result,
7597        }
7598    };
7599    match result {
7600        Ok(response) if (200..300).contains(&response.status()) => {
7601            drain_presigned_response(response);
7602            Ok(())
7603        }
7604        Ok(response) => {
7605            // The object store states its own reason (checksum mismatch,
7606            // precondition, expiry). Carrying a bounded excerpt turns an
7607            // unactionable refusal into one an operator can diagnose; the
7608            // coordinate is the store path, never file content.
7609            let status = response.status();
7610            let detail = response
7611                .into_string()
7612                .ok()
7613                .map(|body| body.chars().take(400).collect::<String>())
7614                .filter(|body| !body.trim().is_empty());
7615            Err(LinkError::Http {
7616                what: "v2 changed-byte upload",
7617                status,
7618                message: match detail {
7619                    Some(body) => format!(
7620                        "object store rejected the upload of `{}`: {}",
7621                        source.path,
7622                        body.replace('\n', " ")
7623                    ),
7624                    None => format!("object store rejected the upload of `{}`", source.path),
7625                },
7626                code: None,
7627                details: None,
7628            })
7629        }
7630        Err(error) => match error {
7631            ureq::Error::Status(412, _) => Ok(()),
7632            ureq::Error::Status(_, response) => {
7633                let status = response.status();
7634                let detail = response
7635                    .into_string()
7636                    .ok()
7637                    .map(|body| body.chars().take(400).collect::<String>())
7638                    .filter(|body| !body.trim().is_empty());
7639                Err(LinkError::Http {
7640                    what: "v2 changed-byte upload",
7641                    status,
7642                    message: match detail {
7643                        Some(body) => format!(
7644                            "object store rejected the upload of `{}`: {}",
7645                            source.path,
7646                            body.replace('\n', " ")
7647                        ),
7648                        None => {
7649                            format!("object store rejected the upload of `{}`", source.path)
7650                        }
7651                    },
7652                    code: None,
7653                    details: None,
7654                })
7655            }
7656            ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7657        },
7658    }
7659}
7660
7661/// The request as it is hashed and signed. A staged push carries its operations
7662/// by address rather than inline, so the signed view restores them: the request
7663/// hash covers the change itself, never the transport that carried it.
7664fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7665    if body.get("operations").is_some() {
7666        return body.clone();
7667    }
7668    let mut value = body.clone();
7669    if let Some(map) = value.as_object_mut() {
7670        map.remove("staged_change");
7671        map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7672    }
7673    value
7674}
7675
7676/// Open one staging window, waiting out a busy hub rather than abandoning the
7677/// push. The call reserves capacity and mints URLs; it commits nothing, so
7678/// asking again after a refusal costs only the wait.
7679fn reserve_upload_window(
7680    cfg: &HubConfig,
7681    path: &str,
7682    body: &Value,
7683    what: &'static str,
7684) -> LinkResult<Value> {
7685    let mut attempt = 0;
7686    loop {
7687        let pause = |attempt: usize| {
7688            std::thread::sleep(std::time::Duration::from_millis(
7689                RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7690            ));
7691        };
7692        match request(cfg, "POST", path, Some(body), Auth::Required) {
7693            // A migration opens dozens of windows over tens of minutes, so one
7694            // response that never arrives is ordinary rather than final. The
7695            // call reserves capacity and mints URLs; it commits nothing, so a
7696            // dropped or timed-out attempt is worth asking again.
7697            Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7698                pause(attempt);
7699                attempt += 1;
7700            }
7701            Err(error) => return Err(error),
7702            Ok(response) => {
7703                if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7704                    pause(attempt);
7705                    attempt += 1;
7706                    continue;
7707                }
7708                return ensure_ok(response, what);
7709            }
7710        }
7711    }
7712}
7713
7714/// The exact bytes of a staged change. The hub refuses a manifest carrying any
7715/// other key, so this is the whole contract: operations, and the blob
7716/// references they need, and nothing else.
7717fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7718    let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7719        .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7720    if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7721        return Err(LinkError::PushTooLarge {
7722            detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7723        });
7724    }
7725    Ok(bytes)
7726}
7727
7728/// State one change's operations and blob references through the same
7729/// content-addressed lane its bytes already ride.
7730///
7731/// A first push of a real store names one operation and one blob reference per
7732/// file, which alone outgrows the bounded commit request. Staging that metadata
7733/// and addressing it by hash keeps the push a single atomic commit: the hub
7734/// verifies the address, splices the manifest in, and validates exactly what it
7735/// would have validated inline. The alternative — many partial commits — would
7736/// make a failed migration leave a half-populated brain readable.
7737fn stage_v2_change(
7738    cfg: &HubConfig,
7739    requested_brain: &str,
7740    operations: &[Value],
7741    blobs: Value,
7742) -> LinkResult<Value> {
7743    let bytes = v2_change_manifest(operations, blobs)?;
7744    let sha256 = content_sha256(&bytes);
7745    let reserved = reserve_upload_window(
7746        cfg,
7747        &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7748        &json!({
7749            "blobs": [{
7750                "sha256": sha256,
7751                "bytes": bytes.len(),
7752                "kind": "staged_change",
7753            }],
7754        }),
7755        "stage the v2 change",
7756    )?;
7757    let items = reserved
7758        .get("uploads")
7759        .and_then(Value::as_array)
7760        .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7761    let [item] = items.as_slice() else {
7762        return Err(invalid_feed(
7763            "v2 change staging response changed the requested set",
7764        ));
7765    };
7766    let reservation_id = item
7767        .get("reservation_id")
7768        .and_then(Value::as_str)
7769        .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7770    if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7771        || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7772        || !crate::ulid::is_ulid(reservation_id)
7773    {
7774        return Err(invalid_feed("v2 change staging item is inconsistent"));
7775    }
7776    match item.get("status").and_then(Value::as_str) {
7777        Some("upload") => put_presigned(
7778            cfg,
7779            item.get("url")
7780                .and_then(Value::as_str)
7781                .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7782            item.get("headers").unwrap_or(&Value::Null),
7783            &bytes,
7784        )?,
7785        Some("already_present") => {}
7786        _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7787    }
7788    Ok(json!({
7789        "sha256": sha256,
7790        "bytes": bytes.len(),
7791        "reservation_id": reservation_id,
7792    }))
7793}
7794
7795/// Move a commit request's operations and blob references off the wire body
7796/// when they no longer fit in it. Idempotent by construction: the mutation ID
7797/// is derived from the change, never from how the change was transported.
7798fn stage_oversized_v2_change(
7799    cfg: &HubConfig,
7800    requested_brain: &str,
7801    operations: &[Value],
7802    body: &mut Value,
7803) -> LinkResult<()> {
7804    if body.to_string().len() <= MAX_PUSH_BYTES {
7805        return Ok(());
7806    }
7807    let staged = stage_v2_change(
7808        cfg,
7809        requested_brain,
7810        operations,
7811        body.get("blobs")
7812            .cloned()
7813            .unwrap_or(Value::Array(Vec::new())),
7814    )?;
7815    let map = body
7816        .as_object_mut()
7817        .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7818    map.remove("operations");
7819    map.remove("blobs");
7820    map.insert("staged_change".to_string(), staged);
7821    Ok(())
7822}
7823
7824fn v2_sync_push(
7825    cfg: &HubConfig,
7826    requested_brain: &str,
7827    store: &Store,
7828    head: V2VerifiedHead,
7829    options: V2SyncPushOptions<'_>,
7830) -> LinkResult<Value> {
7831    let V2SyncPushOptions {
7832        resume_local_policy,
7833        bulk_confirmation,
7834        resolution,
7835        pulled,
7836        withdrawal_paths,
7837        withdrawal_reason,
7838    } = options;
7839    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7840    let head = v2_verified_head(cfg, requested_brain)?
7841        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7842    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7843    ensure_v2_view_compatible(&head, baseline.as_ref())?;
7844    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7845        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7846            Some(snapshot) => (
7847                snapshot.files,
7848                snapshot.assets,
7849                Some(snapshot.local),
7850                Some(snapshot.local_assets),
7851            ),
7852            None => match baseline
7853                .as_ref()
7854                .filter(|state| v2_baseline_matches_head(&head, state))
7855            {
7856                Some(state) => (state.files.clone(), state.assets.clone(), None, None),
7857                None => (
7858                    files_for_v2_view(
7859                        &head,
7860                        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7861                    ),
7862                    v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7863                    None,
7864                    None,
7865                ),
7866            },
7867        };
7868    if head.view_kind == "scoped" && baseline.is_none() {
7869        return Err(LinkError::ScopedViewChanged);
7870    }
7871    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7872    let local = &local_view.riding;
7873    let local_assets = match carried_local_assets {
7874        Some(assets) => assets,
7875        None => v2_local_asset_records(store)?,
7876    };
7877    if withdrawal_paths.len() > MAX_PUSH_FILES {
7878        return Err(LinkError::PushTooLarge {
7879            detail: "too many explicit withdrawal paths".to_string(),
7880        });
7881    }
7882    let withdrawal_reason = if withdrawal_paths.is_empty() {
7883        None
7884    } else {
7885        let reason = withdrawal_reason
7886            .map(str::trim)
7887            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7888            .ok_or_else(|| LinkError::InvalidPack {
7889                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7890            })?;
7891        Some(reason)
7892    };
7893    let mut withdrawals = withdrawal_paths
7894        .iter()
7895        .map(|path| {
7896            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7897                path: error.to_string(),
7898            })
7899        })
7900        .collect::<LinkResult<Vec<_>>>()?;
7901    withdrawals.sort();
7902    withdrawals.dedup();
7903    if withdrawals.len() != withdrawal_paths.len() {
7904        return Err(LinkError::InvalidPack {
7905            message: "explicit withdrawal paths must be unique".to_string(),
7906        });
7907    }
7908    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7909    let mut consumed_withdrawals = BTreeSet::new();
7910    if let Some(previous) = baseline.as_ref() {
7911        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7912            && !resume_local_policy
7913        {
7914            let mut newly_eligible = previous
7915                .local_eligibility
7916                .iter()
7917                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7918                .map(|(path, _)| path.clone())
7919                .collect::<Vec<_>>();
7920            if !newly_eligible.is_empty() {
7921                newly_eligible.truncate(100);
7922                return Err(LinkError::LocalPolicyTransition {
7923                    paths: newly_eligible,
7924                });
7925            }
7926        }
7927    }
7928    let base = match baseline.as_ref() {
7929        Some(state) => &state.files,
7930        None if remote.is_empty() => &remote,
7931        None => {
7932            let mut conflicts = v2_initial_content_conflicts(local, &remote, resolution.is_some());
7933            if !conflicts.is_empty() {
7934                conflicts.truncate(100);
7935                let (bundle, paths) =
7936                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7937                return Err(LinkError::ConflictBundle { bundle, paths });
7938            }
7939            &remote
7940        }
7941    };
7942    let all_paths = base
7943        .keys()
7944        .chain(remote.keys())
7945        .chain(local.keys())
7946        .cloned()
7947        .collect::<std::collections::BTreeSet<_>>();
7948    let mut conflicts = Vec::new();
7949    let mut operations = Vec::new();
7950    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7951    for path in all_paths {
7952        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7953        let remote_file = remote.get(&path);
7954        let remote_hash = remote_file.map(|file| file.sha256.as_str());
7955        let local_file = local.get(&path);
7956        let local_hash = local_file.map(|file| file.0.as_str());
7957        if local_hash == base_hash {
7958            continue;
7959        }
7960        if !v2_resolution_allows_path(resolution, &path, remote_file.is_some()) {
7961            continue;
7962        }
7963        if local_view.policy.keeps_home(&path) {
7964            // Kept-home is a local transfer exclusion, never an implicit
7965            // delete of the company's already-hosted coordinate.
7966            continue;
7967        }
7968        if remote_hash != base_hash && local_hash != remote_hash {
7969            let explicitly_resolved = resolution
7970                .and_then(|allowed| allowed.get(&path))
7971                .is_some_and(|selected| {
7972                    selected.expected_remote.as_deref() == remote_hash
7973                        && selected.selected_local.as_deref() == local_hash
7974                });
7975            if !explicitly_resolved {
7976                conflicts.push(path);
7977                continue;
7978            }
7979        }
7980        match local_file {
7981            Some((sha256, byte_count)) => {
7982                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7983                operations.push(json!({
7984                    "op": "put",
7985                    "path": path,
7986                    "expected": v2_expected(remote_file),
7987                    "blob": sha256,
7988                    "bytes": byte_count,
7989                }));
7990                upload_sources
7991                    .entry(sha256.clone())
7992                    .or_insert_with(|| V2UploadSource {
7993                        path: path.clone(),
7994                        bytes: *byte_count,
7995                    });
7996            }
7997            None => {
7998                let Some(current) = remote_file else {
7999                    continue;
8000                };
8001                operations.push(json!({
8002                    "op": "delete",
8003                    "path": path,
8004                    "expected": { "kind": "blob", "hash": current.sha256 },
8005                }));
8006            }
8007        }
8008    }
8009    operations = infer_exact_source_promotions(operations);
8010    for path in &withdrawals {
8011        if local_assets.contains_key(path) {
8012            continue;
8013        }
8014        operations.push(v2_content_withdrawal_operation(
8015            store,
8016            &local_view,
8017            &remote,
8018            path,
8019            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8020        )?);
8021        consumed_withdrawals.insert(path.clone());
8022    }
8023    if !conflicts.is_empty() {
8024        conflicts.truncate(100);
8025        let (bundle, paths) = create_v2_conflict_bundle(
8026            cfg,
8027            store,
8028            &head,
8029            baseline.as_ref(),
8030            local,
8031            &remote,
8032            &conflicts,
8033        )?;
8034        return Err(LinkError::ConflictBundle { bundle, paths });
8035    }
8036    let base_assets = match baseline.as_ref() {
8037        Some(state) => &state.assets,
8038        None if remote_assets.is_empty() => &remote_assets,
8039        None => {
8040            let mismatched = remote_assets.iter().any(|(path, remote)| {
8041                local_assets.get(path) != Some(&v2_asset_record(remote, path))
8042            }) || local_assets.len() != remote_assets.len();
8043            if mismatched {
8044                return Err(LinkError::Conflict {
8045                    paths: vec!["assets.jsonl".to_string()],
8046                });
8047            }
8048            &remote_assets
8049        }
8050    };
8051    let asset_paths = base_assets
8052        .keys()
8053        .chain(remote_assets.keys())
8054        .chain(local_assets.keys())
8055        .cloned()
8056        .collect::<std::collections::BTreeSet<_>>();
8057    let mut asset_policy_transitions = Vec::new();
8058    for path in asset_paths {
8059        let base_record = base_assets
8060            .get(&path)
8061            .map(|asset| v2_asset_record(asset, &path));
8062        let remote = remote_assets.get(&path);
8063        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8064        let local_record = local_assets.get(&path);
8065        if withdrawal_set.contains(&path) {
8066            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8067                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8068            })?;
8069            let current = remote.ok_or_else(|| LinkError::InvalidPack {
8070                message: format!(
8071                    "asset withdrawal path `{path}` has no readable hosted coordinate"
8072                ),
8073            })?;
8074            operations.push(v2_asset_withdrawal_operation(
8075                store,
8076                &local_view,
8077                &path,
8078                record,
8079                current,
8080                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8081            )?);
8082            consumed_withdrawals.insert(path.clone());
8083            continue;
8084        }
8085        let mut raw_present = false;
8086        let mut disposition = "withheld";
8087        let mut resumes_hosting = false;
8088        if let Some(record) = local_record {
8089            crate::linkmd_v2::normalize_path(&record.path)
8090                .map_err(|error| invalid_feed(error.to_string()))?;
8091            let kept_home = local_view.policy.keeps_home(&path);
8092            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8093            disposition = if kept_home || !raw_present {
8094                "withheld"
8095            } else {
8096                "hosted"
8097            };
8098            let inherits_withheld_absence = v2_asset_inherits_withheld_absence(
8099                base_assets.get(&path),
8100                base_record.as_ref(),
8101                local_record,
8102                raw_present,
8103            );
8104            if !raw_present && record.required && !kept_home && !inherits_withheld_absence {
8105                return Err(LinkError::InvalidPack {
8106                    message: format!("required asset {path} is missing"),
8107                });
8108            }
8109            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8110        }
8111        if local_record == base_record.as_ref() && !resumes_hosting {
8112            continue;
8113        }
8114        if remote_record != base_record && local_record != remote_record.as_ref() {
8115            conflicts.push(path);
8116            continue;
8117        }
8118        let Some(record) = local_record else {
8119            if let Some(remote) = remote {
8120                operations.push(json!({
8121                    "op": "asset_delete",
8122                    "path": path,
8123                    "expected": v2_asset_expected(Some(remote)),
8124                }));
8125            }
8126            continue;
8127        };
8128        let raw = if raw_present {
8129            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8130            Some(())
8131        } else {
8132            None
8133        };
8134        let op = if resumes_hosting {
8135            if !resume_local_policy {
8136                asset_policy_transitions.push(path);
8137                continue;
8138            }
8139            "asset_resume"
8140        } else {
8141            "asset_put"
8142        };
8143        operations.push(json!({
8144            "op": op,
8145            "path": path,
8146            "expected": v2_asset_expected(remote),
8147            "asset": v2_asset_value(record, disposition),
8148        }));
8149        if disposition == "hosted" {
8150            raw.expect("hosted asset was checked present");
8151            upload_sources
8152                .entry(record.sha256.clone())
8153                .or_insert_with(|| V2UploadSource {
8154                    path: path.clone(),
8155                    bytes: record.bytes,
8156                });
8157        }
8158    }
8159    if consumed_withdrawals != withdrawal_set {
8160        let missing = withdrawal_set
8161            .difference(&consumed_withdrawals)
8162            .next()
8163            .expect("different withdrawal sets have one member");
8164        return Err(LinkError::InvalidPack {
8165            message: format!(
8166                "withdrawal path `{missing}` is not a readable content or asset coordinate"
8167            ),
8168        });
8169    }
8170    if !conflicts.is_empty() {
8171        conflicts.truncate(100);
8172        return Err(LinkError::Conflict { paths: conflicts });
8173    }
8174    if !asset_policy_transitions.is_empty() {
8175        asset_policy_transitions.truncate(100);
8176        return Err(LinkError::LocalPolicyTransition {
8177            paths: asset_policy_transitions,
8178        });
8179    }
8180    let touched_sources = operations
8181        .iter()
8182        .filter_map(
8183            |operation| match operation.get("op").and_then(Value::as_str) {
8184                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
8185                Some("rename") => operation.get("to").and_then(Value::as_str),
8186                _ => None,
8187            },
8188        )
8189        .collect::<std::collections::BTreeSet<_>>();
8190    let withheld_links = local_view
8191        .withheld_links
8192        .iter()
8193        .filter(|link| touched_sources.contains(link.source.as_str()))
8194        .collect::<Vec<_>>();
8195    let checkout_pseudonym = v2_checkout_id(
8196        baseline
8197            .as_ref()
8198            .and_then(|current| current.checkout_id.as_deref()),
8199    )?;
8200    let checkout_id = if withheld_links.is_empty() {
8201        None
8202    } else {
8203        Some(checkout_pseudonym.clone())
8204    };
8205    if operations.is_empty() {
8206        let final_head = v2_verified_head(cfg, requested_brain)?
8207            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8208        if !same_v2_head(&head, &final_head) {
8209            return Err(LinkError::RemoteAdvancedDuringSync);
8210        }
8211        let mut final_local = v2_local_files(store)?;
8212        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8213        let final_assets = v2_local_asset_records(store)?;
8214        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8215        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8216            final_local.policy.keeps_home(path)
8217        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8218        let next = v2_baseline_from_head(
8219            cfg,
8220            &head,
8221            remote,
8222            remote_assets,
8223            Some(&final_local),
8224            Some(&checkout_pseudonym),
8225        )?;
8226        let split_count = next.remote_copy_remains.len();
8227        accept_v2_head(cfg, &final_head)?;
8228        if !local_changed && !remote_ahead {
8229            refresh_scoped_view_marker(store, &head, next.files.len())?;
8230            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8231        }
8232        return Ok(json!({
8233            "v": 2,
8234            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8235            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8236            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8237            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8238            "local_policy": {
8239                "remote_copy_remains": split_count,
8240            },
8241        }));
8242    }
8243    let includes_contract = operations
8244        .iter()
8245        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8246    let rebase = if head.pointer.is_none() || includes_contract {
8247        "strict"
8248    } else {
8249        "disjoint"
8250    };
8251    let base_value = head.pointer.as_ref().map(|pointer| {
8252        json!({
8253            "seq": pointer.seq,
8254            "commit_hash": pointer.commit_hash,
8255            "content_root": pointer.content_root,
8256            "asset_root": pointer.asset_root,
8257        })
8258    });
8259    // The mutation coordinate is stable for the exact observed base and local
8260    // operation set. A lost response can therefore be retried without a
8261    // duplicate commit/proposal even across process restarts.
8262    let entropy = format!(
8263        "{}\0{}\0{}\0{}\0{}\0{}",
8264        normalized_origin(&cfg.hub)?,
8265        head.brain_id,
8266        serde_json::to_string(&base_value).unwrap_or_default(),
8267        serde_json::to_string(&operations).unwrap_or_default(),
8268        serde_json::to_string(&withheld_links).unwrap_or_default(),
8269        checkout_id.as_deref().unwrap_or("")
8270    );
8271    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8272    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8273        total
8274            .checked_add(source.bytes)
8275            .ok_or_else(|| LinkError::PushTooLarge {
8276                detail: "v2 changed-byte total overflow".to_string(),
8277            })
8278    })?;
8279    let inline = changed_bytes <= 3 * 1024 * 1024;
8280    let inline_blobs = if inline {
8281        upload_sources
8282            .iter()
8283            .map(|(sha256, source)| {
8284                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8285                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8286                    return Err(LinkError::InvalidPack {
8287                        message: format!("local path `{}` changed before upload", source.path),
8288                    });
8289                }
8290                Ok(json!({
8291                    "sha256": sha256,
8292                    "bytes": source.bytes,
8293                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8294                }))
8295            })
8296            .collect::<LinkResult<Vec<_>>>()?
8297    } else {
8298        Vec::new()
8299    };
8300    let mut body = json!({
8301        "mutation_id": mutation_id,
8302        "base": base_value,
8303        "rebase": rebase,
8304        "reason": "dbmd sync",
8305        "operations": operations,
8306        "blobs": inline_blobs,
8307    });
8308    if !withheld_links.is_empty() {
8309        body["withheld_links"] = serde_json::to_value(&withheld_links)
8310            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8311        body["checkout_id"] =
8312            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8313    }
8314    if let Some(confirmation) = bulk_confirmation {
8315        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8316            return Err(LinkError::InvalidPack {
8317                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8318                    .to_string(),
8319            });
8320        }
8321        // A preview is bound to the exact observed head. Confirmation can
8322        // therefore never use disjoint rebase semantics, even when the normal
8323        // small-mutation path could.
8324        body["rebase"] = Value::String("strict".to_string());
8325        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8326        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8327    }
8328    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8329        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8330        for operation in &operations {
8331            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8332                return Err(invalid_feed("v2 upload operation has no kind"));
8333            };
8334            let hash = match kind {
8335                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8336                "asset_put" | "asset_resume" => operation
8337                    .get("asset")
8338                    .and_then(|asset| asset.get("blob_sha256"))
8339                    .and_then(Value::as_str),
8340                _ => None,
8341            };
8342            let Some(hash) = hash else { continue };
8343            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8344            if kind == "rename" {
8345                for field in ["from", "to"] {
8346                    coordinates.insert(
8347                        operation
8348                            .get(field)
8349                            .and_then(Value::as_str)
8350                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8351                            .to_string(),
8352                    );
8353                }
8354            } else {
8355                let path = operation
8356                    .get("path")
8357                    .and_then(Value::as_str)
8358                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8359                coordinates.insert(if kind.starts_with("asset_") {
8360                    format!("assets/{path}")
8361                } else {
8362                    path.to_string()
8363                });
8364            }
8365        }
8366        let declarations = upload_sources
8367            .iter()
8368            .map(|(sha256, source)| {
8369                json!({
8370                    "sha256": sha256,
8371                    "bytes": source.bytes,
8372                    "coordinates": coordinates_by_hash
8373                        .get(sha256)
8374                        .into_iter()
8375                        .flatten()
8376                        .collect::<Vec<_>>(),
8377                })
8378            })
8379            .collect::<Vec<_>>();
8380        let mut references = Vec::with_capacity(upload_sources.len());
8381        let mut seen = std::collections::BTreeSet::new();
8382        let mut reserved_count = 0usize;
8383        let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8384        // A reservation's presigned URL is short-lived, so each batch's bytes
8385        // ride before the next batch is reserved. Reserving every blob first
8386        // and uploading afterwards expires the earliest URLs on a real store.
8387        for batch in batch_upload_declarations(declarations) {
8388            let batch_len = batch.len();
8389            let reserved = reserve_upload_window(
8390                cfg,
8391                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8392                &json!({ "blobs": batch }),
8393                "prepare v2 changed-byte uploads",
8394            )?;
8395            let items = reserved
8396                .get("uploads")
8397                .and_then(Value::as_array)
8398                .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8399            if items.len() != batch_len {
8400                return Err(invalid_feed(
8401                    "v2 upload reservation response changed the requested set",
8402                ));
8403            }
8404            reserved_count += items.len();
8405            for item in items {
8406                let sha256 = item
8407                    .get("sha256")
8408                    .and_then(Value::as_str)
8409                    .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8410                let source = upload_sources
8411                    .get(sha256)
8412                    .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8413                let declared_bytes = item
8414                    .get("bytes")
8415                    .and_then(Value::as_u64)
8416                    .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8417                let reservation_id = item
8418                    .get("reservation_id")
8419                    .and_then(Value::as_str)
8420                    .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8421                let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8422                    invalid_feed("v2 upload reservation has no coordinate binding")
8423                })?;
8424                let returned_coordinates = item
8425                    .get("coordinates")
8426                    .and_then(Value::as_array)
8427                    .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8428                if declared_bytes != source.bytes
8429                    || !crate::ulid::is_ulid(reservation_id)
8430                    || !seen.insert(sha256.to_string())
8431                    || returned_coordinates.len() != expected_coordinates.len()
8432                    || returned_coordinates
8433                        .iter()
8434                        .zip(expected_coordinates)
8435                        .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8436                {
8437                    return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8438                }
8439                match item.get("status").and_then(Value::as_str) {
8440                    Some("upload") => {
8441                        let url = item
8442                            .get("url")
8443                            .and_then(Value::as_str)
8444                            .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8445                        pending_uploads.push(V2PendingUpload {
8446                            url: url.to_string(),
8447                            headers: item.get("headers").cloned().unwrap_or(Value::Null),
8448                            sha256: sha256.to_string(),
8449                            source,
8450                        });
8451                    }
8452                    Some("already_present") => {}
8453                    _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8454                }
8455                references.push(json!({
8456                    "sha256": sha256,
8457                    "bytes": source.bytes,
8458                    "reservation_id": reservation_id,
8459                }));
8460            }
8461            // Stage this batch's bytes concurrently. Sequential PUTs move a real
8462            // store at a few objects a second, which both takes hours and pushes
8463            // the batch past its reservation's upload window; the object store
8464            // is content-addressed and precondition-guarded, so parallel writes
8465            // stay safe.
8466            upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8467            pending_uploads.clear();
8468        }
8469        if reserved_count != upload_sources.len() {
8470            return Err(invalid_feed(
8471                "v2 upload reservation response changed the requested set",
8472            ));
8473        }
8474        body["blobs"] = Value::Array(references);
8475    }
8476    stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8477    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8478    let mut candidate_hub_signer: Option<String> = None;
8479    let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8480    let bulk_preview_required = !(200..300).contains(&response.status)
8481        && response.body.as_ref().is_some_and(|value| {
8482            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8483                || value
8484                    .get("details")
8485                    .and_then(|details| details.get("code"))
8486                    .and_then(Value::as_str)
8487                    == Some("bulk_preview_required")
8488        });
8489    if bulk_preview_required && bulk_confirmation.is_none() {
8490        body["rebase"] = Value::String("strict".to_string());
8491        body["preview_only"] = Value::Bool(true);
8492        let preview = ensure_ok(
8493            request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8494            "v2 bulk preview",
8495        )?;
8496        let preview_code = preview.get("code").and_then(Value::as_str);
8497        let required = preview.get("required").and_then(Value::as_bool);
8498        if preview.get("v").and_then(Value::as_u64) != Some(2)
8499            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8500            || !matches!(
8501                preview_code,
8502                Some("bulk_preview_created" | "bulk_preview_not_required")
8503            )
8504            || required.is_none()
8505        {
8506            return Err(invalid_feed(
8507                "bulk preview response is not bound to the requested mutation",
8508            ));
8509        }
8510        if required == Some(true) {
8511            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8512            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8513            if preview_code != Some("bulk_preview_created")
8514                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8515                || preview_digest.is_none_or(|value| !is_sha256(value))
8516                || preview.get("expires_at").and_then(Value::as_str).is_none()
8517                || !preview.get("impact").is_some_and(Value::is_object)
8518            {
8519                return Err(invalid_feed("bulk preview receipt is malformed"));
8520            }
8521            return Err(LinkError::BulkPreviewRequired { preview });
8522        }
8523        if preview_code != Some("bulk_preview_not_required") {
8524            return Err(invalid_feed("bulk preview requirement is inconsistent"));
8525        }
8526        // The rolling activity window can expire between the refusal and
8527        // preview. Retry once, still pinned strictly to the observed head.
8528        body.as_object_mut()
8529            .expect("v2 commit request is an object")
8530            .remove("preview_only");
8531        response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8532    }
8533    let mut result = ensure_ok(response, "v2 sync push")?;
8534    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8535        if let Some(object) = result.as_object_mut() {
8536            object.insert(
8537                "sync_status".to_string(),
8538                Value::String("proposal_pending".to_string()),
8539            );
8540        }
8541        return Ok(result);
8542    }
8543    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8544        let request_id = result
8545            .get("request_id")
8546            .and_then(Value::as_str)
8547            .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8548            .to_string();
8549        let challenge = result
8550            .get("signing_challenge")
8551            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8552        let mut expected_candidate = remote.clone();
8553        let mut expected_candidate_assets = remote_assets.clone();
8554        apply_generated_v2_operations(
8555            &operations,
8556            &local_assets,
8557            &mut expected_candidate,
8558            &mut expected_candidate_assets,
8559        )?;
8560        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8561            cfg,
8562            &head,
8563            &expected_candidate,
8564            &expected_candidate_assets,
8565            &mutation_id,
8566            &v2_signed_request_view(&body, &operations),
8567            challenge,
8568        )?;
8569        body["signing_challenge_id"] = Value::String(challenge_id);
8570        body["signature_base64url"] = Value::String(signature);
8571        candidate_hub_signer = Some(actor_signer);
8572        result = ensure_ok(
8573            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8574            "v2 self-custody commit",
8575        )?;
8576    }
8577    let refreshed = v2_verified_head(cfg, requested_brain)?
8578        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8579    if candidate_hub_signer
8580        .as_ref()
8581        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8582    {
8583        return Err(invalid_feed(
8584            "self-custody actor signer differs from the committed hub pointer signer",
8585        ));
8586    }
8587    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8588    if refreshed
8589        .pointer
8590        .as_ref()
8591        .map(|pointer| pointer.commit_hash.as_str())
8592        != accepted_hash
8593    {
8594        return Err(LinkError::RemoteAdvancedDuringSync);
8595    }
8596    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8597    let rebased = result
8598        .get("rebased")
8599        .and_then(Value::as_bool)
8600        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8601    let (refreshed_files, refreshed_assets) = if rebased {
8602        (
8603            files_for_v2_view(
8604                &refreshed,
8605                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8606            ),
8607            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8608        )
8609    } else {
8610        let asset_changed = apply_generated_v2_operations(
8611            &operations,
8612            &local_assets,
8613            &mut remote,
8614            &mut remote_assets,
8615        )?;
8616        let assets = if asset_changed {
8617            // Asset leaf hashes are server-generated signed coordinates; only
8618            // asset-changing commits need this usually tiny post-commit read.
8619            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8620        } else {
8621            remote_assets
8622        };
8623        (remote, assets)
8624    };
8625    let mut final_local = v2_local_files(store)?;
8626    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8627    let final_assets = v2_local_asset_records(store)?;
8628    let local_dirty = final_local.riding != local_view.riding
8629        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8630            final_local.policy.keeps_home(path)
8631        })
8632        || final_assets != local_assets
8633        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8634    let next = v2_baseline_from_head(
8635        cfg,
8636        &refreshed,
8637        refreshed_files,
8638        refreshed_assets,
8639        Some(&final_local),
8640        Some(&checkout_pseudonym),
8641    )?;
8642    let split_count = next.remote_copy_remains.len();
8643    accept_v2_head(cfg, &refreshed)?;
8644    if !local_dirty {
8645        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8646        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8647    }
8648    if let Some(object) = result.as_object_mut() {
8649        object.insert(
8650            "local_policy".to_string(),
8651            json!({ "remote_copy_remains": split_count }),
8652        );
8653        object.insert(
8654            "sync_status".to_string(),
8655            Value::String(if local_dirty {
8656                "remote_committed_local_dirty".to_string()
8657            } else {
8658                "synced".to_string()
8659            }),
8660        );
8661    }
8662    Ok(result)
8663}
8664
8665/// Negotiate v2 and send only local changes. A v1 hub retains the existing
8666/// whole-snapshot behavior until its brain advertises the new profile.
8667pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8668    sync_push_incremental_with_policy(cfg, brain, store, false)
8669}
8670
8671/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
8672/// path that became eligible because local policy was weakened or removed.
8673pub fn sync_push_incremental_with_policy(
8674    cfg: &HubConfig,
8675    brain: &str,
8676    store: &Store,
8677    resume_local_policy: bool,
8678) -> LinkResult<Value> {
8679    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8680}
8681
8682/// Incremental push with both local-policy adoption and an optional exact
8683/// permissioned bulk-preview confirmation.
8684pub fn sync_push_incremental_with_options(
8685    cfg: &HubConfig,
8686    brain: &str,
8687    store: &Store,
8688    resume_local_policy: bool,
8689    bulk_confirmation: Option<&V2BulkConfirmation>,
8690) -> LinkResult<Value> {
8691    sync_push_incremental_with_controls(
8692        cfg,
8693        brain,
8694        store,
8695        resume_local_policy,
8696        bulk_confirmation,
8697        &[],
8698        None,
8699    )
8700}
8701
8702/// Incremental push with explicit permissioned current-host withdrawal.
8703pub fn sync_push_incremental_with_controls(
8704    cfg: &HubConfig,
8705    brain: &str,
8706    store: &Store,
8707    resume_local_policy: bool,
8708    bulk_confirmation: Option<&V2BulkConfirmation>,
8709    withdrawal_paths: &[String],
8710    withdrawal_reason: Option<&str>,
8711) -> LinkResult<Value> {
8712    require_safe_ref(brain)?;
8713    if let Some(head) = v2_verified_head(cfg, brain)? {
8714        return v2_sync_push(
8715            cfg,
8716            brain,
8717            store,
8718            head,
8719            V2SyncPushOptions {
8720                resume_local_policy,
8721                bulk_confirmation,
8722                resolution: None,
8723                pulled: None,
8724                withdrawal_paths,
8725                withdrawal_reason,
8726            },
8727        );
8728    }
8729    if !withdrawal_paths.is_empty() {
8730        return Err(LinkError::InvalidPack {
8731            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8732        });
8733    }
8734    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8735}
8736
8737/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
8738/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
8739/// a damaged `DB.md` makes strict store-open fail.
8740pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8741    require_safe_ref(brain)?;
8742    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8743}
8744
8745#[cfg(windows)]
8746fn legacy_sync_push_incremental(
8747    _cfg: &HubConfig,
8748    _brain: &str,
8749    _store: &Store,
8750    _resume_local_policy: bool,
8751    _bulk_confirmation: Option<&V2BulkConfirmation>,
8752) -> LinkResult<Value> {
8753    Err(LinkError::UnsupportedPlatform {
8754        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8755    })
8756}
8757
8758#[cfg(not(windows))]
8759fn legacy_sync_push_incremental(
8760    cfg: &HubConfig,
8761    brain: &str,
8762    store: &Store,
8763    resume_local_policy: bool,
8764    bulk_confirmation: Option<&V2BulkConfirmation>,
8765) -> LinkResult<Value> {
8766    if resume_local_policy || bulk_confirmation.is_some() {
8767        return Err(LinkError::InvalidPack {
8768            message: "v2 sync options require a link.md v2 brain".to_string(),
8769        });
8770    }
8771    let files = collect_push_files(store)?;
8772    sync_push(cfg, brain, &files)
8773}
8774
8775/// One explicit resolution for a previously preserved private conflict bundle.
8776#[derive(Debug, Clone)]
8777pub enum V2ConflictChoice {
8778    KeepLocal,
8779    TakeRemote,
8780    From(PathBuf),
8781}
8782
8783fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8784    if !crate::ulid::is_ulid(bundle) {
8785        return Err(LinkError::InvalidPack {
8786            message: "conflict bundle must be a lowercase ULID".to_string(),
8787        });
8788    }
8789    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8790    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8791        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8792    if plan.v != 2
8793        || plan.class != "content_resolution_required"
8794        || plan.bundle != bundle
8795        || !crate::ulid::is_ulid(&plan.brain)
8796        || plan.files.is_empty()
8797        || plan.files.len() > 100
8798        || plan.files.iter().any(|file| {
8799            crate::linkmd_v2::normalize_path(&file.path).is_err()
8800                || [&file.base, &file.local, &file.remote]
8801                    .into_iter()
8802                    .any(|coordinate| {
8803                        coordinate
8804                            .sha256
8805                            .as_deref()
8806                            .is_some_and(|hash| !is_sha256(hash))
8807                            || coordinate.file.as_deref().is_some_and(|name| {
8808                                name.starts_with('/')
8809                                    || name
8810                                        .split('/')
8811                                        .any(|part| part.is_empty() || part == "." || part == "..")
8812                            })
8813                    })
8814        })
8815    {
8816        return Err(invalid_feed("private conflict plan failed validation"));
8817    }
8818    Ok(plan)
8819}
8820
8821/// Inspect or prune private conflict-control state. Ordinary pruning removes
8822/// only expired completed bundles and interrupted bundles with no `plan.json`;
8823/// corrupt completed plans fail closed. `all` is an explicit local discard of
8824/// every well-addressed bundle and never changes hosted brain data.
8825pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8826    require_hardened_filesystem("private conflict maintenance")?;
8827    if all && !prune {
8828        return Err(LinkError::InvalidPack {
8829            message: "discarding all conflict bundles requires prune=true".to_string(),
8830        });
8831    }
8832    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8833        message: format!("conflict checkout is not a valid db.md store: {error}"),
8834    })?;
8835    let _transaction = store.transaction()?;
8836    let root = Path::new(".dbmd/conflicts");
8837    let names = match store.directory_names(root) {
8838        Ok(names) => names,
8839        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8840        Err(error) => return Err(error.into()),
8841    };
8842    let now = SystemTime::now()
8843        .duration_since(UNIX_EPOCH)
8844        .unwrap_or_default()
8845        .as_secs();
8846    let mut bundles = Vec::new();
8847    let mut pruned = 0_u64;
8848    for name in names {
8849        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8850            continue;
8851        };
8852        let plan_path = v2_conflict_relative(bundle, "plan.json");
8853        let plan_exists = store.regular_file_exists(&plan_path)?;
8854        let expired = if plan_exists {
8855            match load_v2_conflict_plan(&store, bundle) {
8856                Ok(plan) => plan.expires_unix < now,
8857                Err(error) if all => {
8858                    let _ = error;
8859                    true
8860                }
8861                Err(error) => return Err(error),
8862            }
8863        } else {
8864            true
8865        };
8866        if prune && (all || expired) {
8867            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8868            pruned += 1;
8869            continue;
8870        }
8871        bundles.push(json!({
8872            "bundle": bundle,
8873            "complete": plan_exists,
8874            "expired": expired,
8875        }));
8876    }
8877    Ok(json!({
8878        "v": 2,
8879        "class": "private_conflict_state",
8880        "bundles": bundles.len(),
8881        "pruned": pruned,
8882        "items": bundles,
8883    }))
8884}
8885
8886/// Resolve one exact private conflict plan. Drift never lowers a precondition:
8887/// the remote head/view and every original local coordinate are rechecked
8888/// before either an explicit local install or a fresh normal commit.
8889pub fn sync_resolve_conflict(
8890    cfg: &HubConfig,
8891    checkout: &Path,
8892    bundle: &str,
8893    choice: V2ConflictChoice,
8894    bulk_confirmation: Option<&V2BulkConfirmation>,
8895) -> LinkResult<Value> {
8896    require_hardened_filesystem("conflict resolution")?;
8897    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8898        message: format!("conflict checkout is not a valid db.md store: {error}"),
8899    })?;
8900    let plan = load_v2_conflict_plan(&store, bundle)?;
8901    if plan.origin != normalized_origin(&cfg.hub)? {
8902        return Err(invalid_feed(
8903            "conflict bundle belongs to another hub origin",
8904        ));
8905    }
8906    let now = SystemTime::now()
8907        .duration_since(UNIX_EPOCH)
8908        .unwrap_or_default()
8909        .as_secs();
8910    if now > plan.expires_unix {
8911        return Err(LinkError::InvalidPack {
8912            message: "conflict bundle expired; rerun sync to obtain current coordinates"
8913                .to_string(),
8914        });
8915    }
8916    let head = v2_verified_head(cfg, &plan.brain)?
8917        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8918    let pointer = head.pointer.as_ref();
8919    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8920        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8921        || pointer.and_then(|value| value.content_root.as_deref())
8922            != plan.remote_content_root.as_deref()
8923        || head.view_kind != plan.view_kind
8924        || head.view_revision != plan.view_revision
8925    {
8926        return Err(LinkError::RemoteAdvancedDuringSync);
8927    }
8928
8929    // A resolution never overwrites a post-bundle local edit.
8930    for file in &plan.files {
8931        let actual = match store.regular_file_exists(Path::new(&file.path))? {
8932            true => Some(content_sha256(&store.read_bounded(
8933                Path::new(&file.path),
8934                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8935            )?)),
8936            false => None,
8937        };
8938        if actual.as_deref() != file.local.sha256.as_deref() {
8939            return Err(LinkError::InvalidPack {
8940                message: format!(
8941                    "local conflict path `{}` changed after the bundle was created",
8942                    file.path
8943                ),
8944            });
8945        }
8946    }
8947
8948    let from_source = match &choice {
8949        V2ConflictChoice::From(source) => Some(source.clone()),
8950        _ => None,
8951    };
8952    let result = match choice {
8953        V2ConflictChoice::TakeRemote => {
8954            if bulk_confirmation.is_some() {
8955                return Err(LinkError::InvalidPack {
8956                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8957                });
8958            }
8959            // Re-read the exact current manifest so every selected remote byte
8960            // retains its signed inclusion proof. The private bundle is useful
8961            // for inspection, but is never promoted into live state as trust.
8962            let current_remote =
8963                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8964            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
8965            let selected = plan
8966                .files
8967                .iter()
8968                .map(|file| file.path.clone())
8969                .collect::<std::collections::BTreeSet<_>>();
8970            serde_json::to_value(
8971                v2_sync_pull_with_resolution(
8972                    cfg,
8973                    &plan.brain,
8974                    head,
8975                    Some(checkout),
8976                    Some(&selected),
8977                )?
8978                .report,
8979            )
8980            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8981        }
8982        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8983            if let Some(source) = from_source.as_ref() {
8984                if plan.files.len() != 1 {
8985                    return Err(LinkError::InvalidPack {
8986                        message: "--from requires a bundle with exactly one conflict".to_string(),
8987                    });
8988                }
8989                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8990                if std::str::from_utf8(&candidate).is_err() {
8991                    return Err(LinkError::NotUtf8 {
8992                        path: source.display().to_string(),
8993                    });
8994                }
8995                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8996            }
8997            let refreshed_store =
8998                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8999                    message: format!("resolved checkout is not a valid db.md store: {error}"),
9000                })?;
9001            let mut overrides = std::collections::BTreeMap::new();
9002            for file in &plan.files {
9003                let selected_local = match refreshed_store
9004                    .regular_file_exists(Path::new(&file.path))?
9005                {
9006                    true => Some(content_sha256(
9007                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
9008                    )),
9009                    false => None,
9010                };
9011                overrides.insert(
9012                    file.path.clone(),
9013                    V2ResolutionOverride {
9014                        expected_remote: file.remote.sha256.clone(),
9015                        selected_local,
9016                    },
9017                );
9018            }
9019            v2_sync_push(
9020                cfg,
9021                &plan.brain,
9022                &refreshed_store,
9023                head,
9024                V2SyncPushOptions {
9025                    resume_local_policy: true,
9026                    bulk_confirmation,
9027                    resolution: Some(&overrides),
9028                    pulled: None,
9029                    withdrawal_paths: &[],
9030                    withdrawal_reason: None,
9031                },
9032            )?
9033        }
9034    };
9035
9036    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9037        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9038            message: format!("resolved checkout is not a valid db.md store: {error}"),
9039        })?;
9040        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9041    }
9042    Ok(json!({
9043        "v": 2,
9044        "class": "auto_converged",
9045        "bundle": bundle,
9046        "receipt": result,
9047    }))
9048}
9049
9050/// Converge one established permissioned-v2 checkout in both directions.
9051///
9052/// The pull half first performs the ordinary three-way conflict check and
9053/// crash-safe install against the accepted baseline. The push half then opens
9054/// that exact installed checkout, takes its local transaction guard, computes
9055/// a fresh three-way delta, and submits it with mandatory preconditions. A
9056/// remote movement between halves is therefore a normal rebase/conflict input,
9057/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
9058/// its whole-pack replacement semantics are never presented as granular
9059/// convergence.
9060pub fn sync_converge(
9061    cfg: &HubConfig,
9062    brain: &str,
9063    checkout: &Path,
9064    resume_local_policy: bool,
9065) -> LinkResult<Value> {
9066    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9067}
9068
9069/// Bidirectional convergence with optional exact bulk-preview confirmation.
9070pub fn sync_converge_with_options(
9071    cfg: &HubConfig,
9072    brain: &str,
9073    checkout: &Path,
9074    resume_local_policy: bool,
9075    bulk_confirmation: Option<&V2BulkConfirmation>,
9076) -> LinkResult<Value> {
9077    sync_converge_with_controls(
9078        cfg,
9079        brain,
9080        checkout,
9081        resume_local_policy,
9082        bulk_confirmation,
9083        &[],
9084        None,
9085    )
9086}
9087
9088/// Bidirectional convergence with optional exact withdrawal intents.
9089pub fn sync_converge_with_controls(
9090    cfg: &HubConfig,
9091    brain: &str,
9092    checkout: &Path,
9093    resume_local_policy: bool,
9094    bulk_confirmation: Option<&V2BulkConfirmation>,
9095    withdrawal_paths: &[String],
9096    withdrawal_reason: Option<&str>,
9097) -> LinkResult<Value> {
9098    require_hardened_filesystem("bidirectional sync")?;
9099    require_safe_ref(brain)?;
9100    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9101        message:
9102            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9103                .to_string(),
9104    })?;
9105    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9106    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9107        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9108    })?;
9109    let _transaction = store.transaction()?;
9110    let pulled_report = pulled.report.clone();
9111    let pulled_head = pulled.head.clone();
9112    let mut result = v2_sync_push(
9113        cfg,
9114        brain,
9115        &store,
9116        pulled_head,
9117        V2SyncPushOptions {
9118            resume_local_policy,
9119            bulk_confirmation,
9120            resolution: None,
9121            pulled: Some(pulled),
9122            withdrawal_paths,
9123            withdrawal_reason,
9124        },
9125    )?;
9126    if let Some(object) = result.as_object_mut() {
9127        object.insert("pulled_files".to_string(), json!(pulled_report.files));
9128        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9129        object.insert(
9130            "mode".to_string(),
9131            Value::String("bidirectional".to_string()),
9132        );
9133    }
9134    Ok(result)
9135}
9136
9137/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
9138/// exported path is safety-gated before it touches disk; files are written
9139/// atomically; nothing local is ever deleted (locals the export lacks are
9140/// *reported* in `extra_local` instead). Returns the report; rebuilding the
9141/// local index catalog afterwards is the caller's (cheap, optional) step.
9142pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9143    require_hardened_filesystem("sync pull")?;
9144    require_safe_ref(brain)?;
9145    if let Some(head) = v2_verified_head(cfg, brain)? {
9146        return v2_sync_pull(cfg, brain, head, out);
9147    }
9148    legacy_sync_pull(cfg, brain, out)
9149}
9150
9151#[cfg(windows)]
9152fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9153    Err(LinkError::UnsupportedPlatform {
9154        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9155    })
9156}
9157
9158#[cfg(not(windows))]
9159fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9160    let remote = verified_remote_head(cfg, brain, false)?;
9161    if !remote.head.verified {
9162        return Err(invalid_feed(
9163            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9164        ));
9165    }
9166    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9167    let path = format!(
9168        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9169        remote.head.seq
9170    );
9171    let body = ensure_ok(
9172        request(cfg, "GET", &path, None, Auth::Required)?,
9173        "sync pull",
9174    )?;
9175    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9176        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9177    {
9178        return Err(invalid_feed(
9179            "export response is not bound to the verified snapshot token",
9180        ));
9181    }
9182
9183    let remote_slug = body
9184        .get("slug")
9185        .and_then(Value::as_str)
9186        .filter(|slug| is_safe_slug(slug));
9187    let slug = remote_slug
9188        .or_else(|| is_safe_slug(brain).then_some(brain))
9189        .unwrap_or("brain")
9190        .to_string();
9191    let brain_id = body
9192        .get("brain")
9193        .and_then(Value::as_str)
9194        .unwrap_or(&remote.head.brain)
9195        .to_string();
9196    if brain_id != remote.head.brain {
9197        return Err(invalid_feed(
9198            "export response names a different brain than the verified head",
9199        ));
9200    }
9201    let head_seq = remote.head.seq;
9202    let dest: PathBuf = match out {
9203        Some(p) => p.to_path_buf(),
9204        None => PathBuf::from(&slug),
9205    };
9206    let entries = if head_seq == 0 {
9207        let files = body
9208            .get("files")
9209            .and_then(Value::as_array)
9210            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
9211        if !files.is_empty() || body.get("url").is_some() {
9212            return Err(invalid_feed(
9213                "empty signed feed cannot authorize non-empty exported content",
9214            ));
9215        }
9216        Vec::new()
9217    } else {
9218        let signed_head = remote
9219            .head_entry
9220            .as_ref()
9221            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
9222        let expected = &signed_head.entry.pack_sha256;
9223        if !is_sha256(expected) {
9224            return Err(invalid_feed(
9225                "signed head carries an invalid snapshot pack digest",
9226            ));
9227        }
9228        if let Some(url) = body.get("url").and_then(Value::as_str) {
9229            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
9230                return Err(invalid_feed(
9231                    "export pack digest does not match the signed head entry",
9232                ));
9233            }
9234            let bytes = get_presigned(cfg, url)?;
9235            let actual = format!("{:x}", Sha256::digest(&bytes));
9236            if actual != *expected {
9237                return Err(LinkError::InvalidPack {
9238                    message: "downloaded pack does not match the signed snapshot digest"
9239                        .to_string(),
9240                });
9241            }
9242            let entries = parse_store_pack(bytes)?;
9243            if signed_head.entry.kind == "push" {
9244                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9245            }
9246            entries
9247        } else {
9248            if signed_head.entry.kind != "push" {
9249                return Err(invalid_feed(
9250                    "delta snapshots must export the exact signed pack",
9251                ));
9252            }
9253            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
9254                invalid_feed("verified snapshot export carried neither a pack nor files")
9255            })?;
9256            let mut entries = Vec::with_capacity(files.len());
9257            for file in files {
9258                let path = file
9259                    .get("path")
9260                    .and_then(Value::as_str)
9261                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
9262                let content = file
9263                    .get("content")
9264                    .and_then(Value::as_str)
9265                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
9266                entries.push((path.to_string(), content.as_bytes().to_vec()));
9267            }
9268            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9269            entries
9270        }
9271    };
9272
9273    // Gate the complete manifest before the first filesystem mutation.
9274    let mut seen = std::collections::HashSet::new();
9275    for (path, _) in &entries {
9276        if !safe_store_rel_path(path) {
9277            return Err(LinkError::UnsafePath { path: path.clone() });
9278        }
9279        if !seen.insert(path) {
9280            return Err(LinkError::InvalidPack {
9281                message: format!("duplicate path `{path}`"),
9282            });
9283        }
9284    }
9285    // Compute divergence against the still-live tree. The staged clone keeps
9286    // these extra locals byte-for-byte while overlaying the signed snapshot.
9287    let pulled: std::collections::BTreeSet<&str> =
9288        entries.iter().map(|(p, _)| p.as_str()).collect();
9289    let mut extra_local = Vec::new();
9290    if let Ok(store) = Store::open(&dest) {
9291        if let Ok(walked) = store.walk() {
9292            for rel in walked {
9293                let rel_str = rel.to_string_lossy().replace('\\', "/");
9294                if !pulled.contains(rel_str.as_str()) {
9295                    extra_local.push(rel_str);
9296                }
9297            }
9298        }
9299    }
9300    #[cfg(unix)]
9301    install_pulled_snapshot(&dest, &entries)?;
9302
9303    Ok(PullReport {
9304        brain: brain_id,
9305        slug,
9306        head_seq,
9307        files: entries.len(),
9308        dest: dest.to_string_lossy().into_owned(),
9309        extra_local,
9310        sync_status: "synced".to_string(),
9311    })
9312}
9313
9314#[cfg(unix)]
9315fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
9316    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
9317        path: display.to_string(),
9318    })
9319}
9320
9321#[cfg(unix)]
9322fn open_dir_at(
9323    parent: std::os::fd::RawFd,
9324    name: &std::ffi::CStr,
9325    display: &str,
9326) -> LinkResult<std::fs::File> {
9327    use std::os::fd::FromRawFd as _;
9328    let fd = unsafe {
9329        libc::openat(
9330            parent,
9331            name.as_ptr(),
9332            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9333        )
9334    };
9335    if fd < 0 {
9336        return Err(LinkError::UnsafePath {
9337            path: display.to_string(),
9338        });
9339    }
9340    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9341}
9342
9343/// Open (and, where absent, create) a directory path without following a
9344/// symlink in any component. The returned directory capability remains bound
9345/// to the opened inode even if an attacker renames or replaces an ancestor.
9346#[cfg(unix)]
9347fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9348    use std::os::fd::AsRawFd as _;
9349
9350    // macOS exposes a few system-owned compatibility symlinks at the root.
9351    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
9352    // caller-controlled ancestor.
9353    #[cfg(target_os = "macos")]
9354    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9355        .into_iter()
9356        .find_map(|(alias, real)| {
9357            path.strip_prefix(alias)
9358                .ok()
9359                .map(|rest| Path::new(real).join(rest))
9360        })
9361        .unwrap_or_else(|| path.to_path_buf());
9362    #[cfg(not(target_os = "macos"))]
9363    let normalized = path.to_path_buf();
9364
9365    let start = if normalized.is_absolute() {
9366        std::fs::File::open("/")?
9367    } else {
9368        std::fs::File::open(".")?
9369    };
9370    let mut directory = start;
9371    for component in normalized.components() {
9372        use std::path::Component;
9373        let name = match component {
9374            Component::RootDir | Component::CurDir => continue,
9375            Component::Normal(name) => name,
9376            Component::ParentDir | Component::Prefix(_) => {
9377                return Err(LinkError::UnsafePath {
9378                    path: path.display().to_string(),
9379                });
9380            }
9381        };
9382        use std::os::unix::ffi::OsStrExt as _;
9383        let name = c_name(name.as_bytes(), &path.display().to_string())?;
9384        if create {
9385            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9386            if made != 0 {
9387                let error = std::io::Error::last_os_error();
9388                if error.raw_os_error() != Some(libc::EEXIST) {
9389                    return Err(error.into());
9390                }
9391            }
9392        }
9393        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9394    }
9395    Ok(directory)
9396}
9397
9398#[cfg(unix)]
9399fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9400    open_dir_path_nofollow(path, true)
9401}
9402
9403#[cfg(unix)]
9404fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9405    open_dir_path_nofollow(path, false)
9406}
9407
9408#[cfg(unix)]
9409fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9410    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9411    let result =
9412        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9413    if result == 0 {
9414        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9415    }
9416    let error = std::io::Error::last_os_error();
9417    if error.kind() == std::io::ErrorKind::NotFound {
9418        Ok(None)
9419    } else {
9420        Err(error.into())
9421    }
9422}
9423
9424#[cfg(unix)]
9425fn create_dir_exclusive_at(
9426    parent: std::os::fd::RawFd,
9427    name: &std::ffi::CStr,
9428    display: &str,
9429) -> LinkResult<std::fs::File> {
9430    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9431    if made != 0 {
9432        return Err(LinkError::UnsafePath {
9433            path: display.to_string(),
9434        });
9435    }
9436    open_dir_at(parent, name, display)
9437}
9438
9439#[cfg(unix)]
9440fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9441    use std::os::fd::AsRawFd as _;
9442
9443    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9444    if duplicate < 0 {
9445        return Err(std::io::Error::last_os_error().into());
9446    }
9447    let stream = unsafe { libc::fdopendir(duplicate) };
9448    if stream.is_null() {
9449        let error = std::io::Error::last_os_error();
9450        unsafe {
9451            libc::close(duplicate);
9452        }
9453        return Err(error.into());
9454    }
9455    let mut names = Vec::new();
9456    loop {
9457        let entry = unsafe { libc::readdir(stream) };
9458        if entry.is_null() {
9459            break;
9460        }
9461        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9462        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9463            names.push(raw.to_owned());
9464        }
9465    }
9466    if unsafe { libc::closedir(stream) } != 0 {
9467        return Err(std::io::Error::last_os_error().into());
9468    }
9469    Ok(names)
9470}
9471
9472/// Remove an entry tree relative to a held directory capability. Symlinks are
9473/// unlinked, never traversed, even when an old mirror contains hostile names.
9474#[cfg(unix)]
9475fn remove_tree_at(
9476    parent: std::os::fd::RawFd,
9477    name: &std::ffi::CStr,
9478    display: &str,
9479) -> LinkResult<()> {
9480    use std::os::fd::AsRawFd as _;
9481
9482    match entry_is_dir_at(parent, name)? {
9483        None => return Ok(()),
9484        Some(false) => {
9485            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9486                return Err(std::io::Error::last_os_error().into());
9487            }
9488        }
9489        Some(true) => {
9490            let directory = open_dir_at(parent, name, display)?;
9491            for child in directory_entry_names(&directory)? {
9492                let child_display =
9493                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9494                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9495            }
9496            drop(directory);
9497            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9498                return Err(std::io::Error::last_os_error().into());
9499            }
9500        }
9501    }
9502    Ok(())
9503}
9504
9505/// Clone a live destination into a private sibling stage without following
9506/// any symlink. Regular files are streamed between held descriptors; symlinks
9507/// are reproduced as links, never opened. Special files are refused.
9508#[cfg(unix)]
9509fn clone_tree_contents(
9510    source: &std::fs::File,
9511    destination: &std::fs::File,
9512    display: &str,
9513) -> LinkResult<()> {
9514    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9515
9516    for name in directory_entry_names(source)? {
9517        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9518        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9519        if unsafe {
9520            libc::fstatat(
9521                source.as_raw_fd(),
9522                name.as_ptr(),
9523                &mut stat,
9524                libc::AT_SYMLINK_NOFOLLOW,
9525            )
9526        } != 0
9527        {
9528            return Err(std::io::Error::last_os_error().into());
9529        }
9530        match stat.st_mode & libc::S_IFMT {
9531            libc::S_IFDIR => {
9532                if unsafe {
9533                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9534                } != 0
9535                {
9536                    return Err(std::io::Error::last_os_error().into());
9537                }
9538                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9539                let destination_child =
9540                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9541                clone_tree_contents(&source_child, &destination_child, &child_display)?;
9542                destination_child.sync_all()?;
9543            }
9544            libc::S_IFREG => {
9545                let source_fd = unsafe {
9546                    libc::openat(
9547                        source.as_raw_fd(),
9548                        name.as_ptr(),
9549                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9550                    )
9551                };
9552                if source_fd < 0 {
9553                    return Err(std::io::Error::last_os_error().into());
9554                }
9555                let destination_fd = unsafe {
9556                    libc::openat(
9557                        destination.as_raw_fd(),
9558                        name.as_ptr(),
9559                        libc::O_WRONLY
9560                            | libc::O_CREAT
9561                            | libc::O_EXCL
9562                            | libc::O_CLOEXEC
9563                            | libc::O_NOFOLLOW,
9564                        (stat.st_mode & 0o777) as libc::c_uint,
9565                    )
9566                };
9567                if destination_fd < 0 {
9568                    unsafe {
9569                        libc::close(source_fd);
9570                    }
9571                    return Err(std::io::Error::last_os_error().into());
9572                }
9573                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9574                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9575                std::io::copy(&mut input, &mut output)?;
9576                output.sync_all()?;
9577            }
9578            libc::S_IFLNK => {
9579                let mut target = vec![0_u8; 4097];
9580                let length = unsafe {
9581                    libc::readlinkat(
9582                        source.as_raw_fd(),
9583                        name.as_ptr(),
9584                        target.as_mut_ptr().cast(),
9585                        target.len(),
9586                    )
9587                };
9588                if length < 0 || length as usize >= target.len() {
9589                    return Err(LinkError::UnsafePath {
9590                        path: child_display,
9591                    });
9592                }
9593                target.truncate(length as usize);
9594                let target = c_name(&target, &child_display)?;
9595                if unsafe {
9596                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9597                } != 0
9598                {
9599                    return Err(std::io::Error::last_os_error().into());
9600                }
9601            }
9602            _ => {
9603                return Err(LinkError::UnsafePath {
9604                    path: child_display,
9605                });
9606            }
9607        }
9608    }
9609    destination.sync_all()?;
9610    Ok(())
9611}
9612
9613#[cfg(target_os = "linux")]
9614fn install_stage_at(
9615    parent: std::os::fd::RawFd,
9616    stage: &std::ffi::CStr,
9617    dest: &std::ffi::CStr,
9618    dest_exists: bool,
9619) -> LinkResult<()> {
9620    let flags = if dest_exists {
9621        libc::RENAME_EXCHANGE
9622    } else {
9623        libc::RENAME_NOREPLACE
9624    };
9625    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
9626    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
9627    // atomic exchange/no-replace boundary compiles for glibc and musl.
9628    let result = unsafe {
9629        libc::syscall(
9630            libc::SYS_renameat2,
9631            parent,
9632            stage.as_ptr(),
9633            parent,
9634            dest.as_ptr(),
9635            flags,
9636        )
9637    };
9638    if result == 0 {
9639        Ok(())
9640    } else {
9641        Err(std::io::Error::last_os_error().into())
9642    }
9643}
9644
9645#[cfg(target_os = "macos")]
9646fn install_stage_at(
9647    parent: std::os::fd::RawFd,
9648    stage: &std::ffi::CStr,
9649    dest: &std::ffi::CStr,
9650    dest_exists: bool,
9651) -> LinkResult<()> {
9652    let flags = if dest_exists {
9653        libc::RENAME_SWAP
9654    } else {
9655        libc::RENAME_EXCL
9656    };
9657    let result =
9658        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9659    if result == 0 {
9660        Ok(())
9661    } else {
9662        Err(std::io::Error::last_os_error().into())
9663    }
9664}
9665
9666#[cfg(unix)]
9667fn write_pull_entries_beneath_dir(
9668    root: &std::fs::File,
9669    entries: &[(String, Vec<u8>)],
9670) -> LinkResult<()> {
9671    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9672
9673    for (path, content) in entries {
9674        let components: Vec<&str> = path.split('/').collect();
9675        let (leaf, parents) = components
9676            .split_last()
9677            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9678        let mut directory = root.try_clone()?;
9679        for component in parents {
9680            let name = c_name(component.as_bytes(), path)?;
9681            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9682            if made != 0 {
9683                let error = std::io::Error::last_os_error();
9684                if error.raw_os_error() != Some(libc::EEXIST) {
9685                    return Err(error.into());
9686                }
9687            }
9688            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9689        }
9690
9691        let leaf_name = c_name(leaf.as_bytes(), path)?;
9692        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9693        let inspected = unsafe {
9694            libc::fstatat(
9695                directory.as_raw_fd(),
9696                leaf_name.as_ptr(),
9697                &mut existing,
9698                libc::AT_SYMLINK_NOFOLLOW,
9699            )
9700        };
9701        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9702            return Err(LinkError::UnsafePath { path: path.clone() });
9703        }
9704
9705        let nonce = std::time::SystemTime::now()
9706            .duration_since(std::time::UNIX_EPOCH)
9707            .unwrap_or_default()
9708            .as_nanos();
9709        let temp_name = format!(
9710            ".dbmd-pull-{}-{nonce}-{}",
9711            std::process::id(),
9712            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9713        );
9714        let temp = c_name(temp_name.as_bytes(), path)?;
9715        let fd = unsafe {
9716            libc::openat(
9717                directory.as_raw_fd(),
9718                temp.as_ptr(),
9719                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9720                0o600,
9721            )
9722        };
9723        if fd < 0 {
9724            return Err(std::io::Error::last_os_error().into());
9725        }
9726        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9727        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9728            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9729            return Err(error.into());
9730        }
9731        drop(file);
9732        let renamed = unsafe {
9733            libc::renameat(
9734                directory.as_raw_fd(),
9735                temp.as_ptr(),
9736                directory.as_raw_fd(),
9737                leaf_name.as_ptr(),
9738            )
9739        };
9740        if renamed != 0 {
9741            let error = std::io::Error::last_os_error();
9742            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9743            return Err(error.into());
9744        }
9745        directory.sync_all()?;
9746    }
9747    root.sync_all()?;
9748    Ok(())
9749}
9750
9751#[cfg(unix)]
9752fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9753    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9754
9755    let path = &entry.path;
9756    let components: Vec<&str> = path.split('/').collect();
9757    let (leaf, parents) = components
9758        .split_last()
9759        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9760    let mut directory = root.try_clone()?;
9761    for component in parents {
9762        let name = c_name(component.as_bytes(), path)?;
9763        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9764        if made != 0 {
9765            let error = std::io::Error::last_os_error();
9766            if error.raw_os_error() != Some(libc::EEXIST) {
9767                return Err(error.into());
9768            }
9769        }
9770        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9771    }
9772    let leaf_name = c_name(leaf.as_bytes(), path)?;
9773    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9774    if unsafe {
9775        libc::fstatat(
9776            directory.as_raw_fd(),
9777            leaf_name.as_ptr(),
9778            &mut existing,
9779            libc::AT_SYMLINK_NOFOLLOW,
9780        )
9781    } == 0
9782        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9783    {
9784        return Err(LinkError::UnsafePath { path: path.clone() });
9785    }
9786    let nonce = SystemTime::now()
9787        .duration_since(UNIX_EPOCH)
9788        .unwrap_or_default()
9789        .as_nanos();
9790    let temp_name = format!(
9791        ".dbmd-pull-{}-{nonce}-{}",
9792        std::process::id(),
9793        content_sha256(path.as_bytes())
9794    );
9795    let temp = c_name(temp_name.as_bytes(), path)?;
9796    let fd = unsafe {
9797        libc::openat(
9798            directory.as_raw_fd(),
9799            temp.as_ptr(),
9800            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9801            0o600,
9802        )
9803    };
9804    if fd < 0 {
9805        return Err(std::io::Error::last_os_error().into());
9806    }
9807    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9808    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9809    let mut digest = Sha256::new();
9810    let mut total = 0_u64;
9811    let mut buffer = [0_u8; 64 * 1024];
9812    let copied = (|| -> std::io::Result<()> {
9813        loop {
9814            let read = input.read(&mut buffer)?;
9815            if read == 0 {
9816                break;
9817            }
9818            total = total.saturating_add(read as u64);
9819            if total > entry.bytes {
9820                return Err(std::io::Error::new(
9821                    std::io::ErrorKind::InvalidData,
9822                    "staged sync source grew beyond its verified length",
9823                ));
9824            }
9825            digest.update(&buffer[..read]);
9826            output.write_all(&buffer[..read])?;
9827        }
9828        Ok(())
9829    })();
9830    if let Err(error) = copied {
9831        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9832        return Err(error.into());
9833    }
9834    drop(output);
9835    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9836        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9837        return Err(invalid_feed(
9838            "private staged sync source failed final integrity verification",
9839        ));
9840    }
9841    if unsafe {
9842        libc::renameat(
9843            directory.as_raw_fd(),
9844            temp.as_ptr(),
9845            directory.as_raw_fd(),
9846            leaf_name.as_ptr(),
9847        )
9848    } != 0
9849    {
9850        let error = std::io::Error::last_os_error();
9851        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9852        return Err(error.into());
9853    }
9854    Ok(())
9855}
9856
9857#[cfg(unix)]
9858fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9859    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9860
9861    let path = &entry.path;
9862    let components: Vec<&str> = path.split('/').collect();
9863    let (leaf, parents) = components
9864        .split_last()
9865        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9866    let mut directory = root.try_clone()?;
9867    for component in parents {
9868        directory = open_dir_at(
9869            directory.as_raw_fd(),
9870            &c_name(component.as_bytes(), path)?,
9871            path,
9872        )?;
9873    }
9874    let leaf = c_name(leaf.as_bytes(), path)?;
9875    let fd = unsafe {
9876        libc::openat(
9877            directory.as_raw_fd(),
9878            leaf.as_ptr(),
9879            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9880        )
9881    };
9882    if fd < 0 {
9883        return Err(std::io::Error::last_os_error().into());
9884    }
9885    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9886    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9887        return Err(invalid_feed(
9888            "private pull stage changed before its durability barrier",
9889        ));
9890    }
9891    file.sync_all()?;
9892    Ok(())
9893}
9894
9895#[cfg(unix)]
9896fn run_pull_source_workers(
9897    root: &std::fs::File,
9898    entries: &[V2StagedFile],
9899    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9900) -> LinkResult<()> {
9901    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9902
9903    let next = AtomicUsize::new(0);
9904    let failed = AtomicBool::new(false);
9905    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9906    let mut first_error = None;
9907    std::thread::scope(|scope| {
9908        let (sender, receiver) = std::sync::mpsc::channel();
9909        for _ in 0..worker_count {
9910            let sender = sender.clone();
9911            let next = &next;
9912            let failed = &failed;
9913            scope.spawn(move || {
9914                while !failed.load(Ordering::Acquire) {
9915                    let index = next.fetch_add(1, Ordering::Relaxed);
9916                    let Some(entry) = entries.get(index) else {
9917                        break;
9918                    };
9919                    let result = operation(root, entry);
9920                    if result.is_err() {
9921                        failed.store(true, Ordering::Release);
9922                    }
9923                    if sender.send(result).is_err() {
9924                        break;
9925                    }
9926                }
9927            });
9928        }
9929        drop(sender);
9930        for result in receiver {
9931            if let Err(error) = result {
9932                if first_error.is_none() {
9933                    first_error = Some(error);
9934                }
9935            }
9936        }
9937    });
9938    if let Some(error) = first_error {
9939        return Err(error);
9940    }
9941    if next.load(Ordering::Relaxed) < entries.len() {
9942        return Err(invalid_feed(
9943            "a bounded pull worker stopped before reporting every file",
9944        ));
9945    }
9946    Ok(())
9947}
9948
9949#[cfg(unix)]
9950fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9951    use std::os::fd::AsRawFd as _;
9952
9953    for name in directory_entry_names(root)? {
9954        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9955            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9956            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9957            sync_pull_directory_tree(&child, &child_display)?;
9958        }
9959    }
9960    root.sync_all()?;
9961    Ok(())
9962}
9963
9964#[cfg(unix)]
9965fn write_pull_sources_beneath_dir(
9966    root: &std::fs::File,
9967    entries: &[V2StagedFile],
9968) -> LinkResult<()> {
9969    // The stage is still private and unreachable as the checkout here. Write
9970    // and verify every leaf first so the filesystem can coalesce dirty data,
9971    // then re-open/re-hash/fsync leaves through the held root capability. Only
9972    // after every file barrier succeeds do we flush each directory once. This
9973    // preserves the same crash durability as file-fsync + directory-fsync per
9974    // path without serializing 50,000 redundant directory barriers.
9975    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9976    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9977    sync_pull_directory_tree(root, "v2 pull stage")
9978}
9979
9980#[cfg(unix)]
9981fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9982    use std::os::fd::AsRawFd as _;
9983    for path in paths {
9984        if !safe_store_rel_path(path) {
9985            return Err(LinkError::UnsafePath { path: path.clone() });
9986        }
9987        let components = path.split('/').collect::<Vec<_>>();
9988        let Some((leaf, parents)) = components.split_last() else {
9989            return Err(LinkError::UnsafePath { path: path.clone() });
9990        };
9991        let mut directory = root.try_clone()?;
9992        let mut missing = false;
9993        for component in parents {
9994            let name = c_name(component.as_bytes(), path)?;
9995            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9996                None => {
9997                    missing = true;
9998                    break;
9999                }
10000                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
10001                Some(true) => {
10002                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10003                }
10004            }
10005        }
10006        if missing {
10007            continue;
10008        }
10009        let leaf = c_name(leaf.as_bytes(), path)?;
10010        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
10011            None => {}
10012            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
10013            Some(false) => {
10014                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
10015                    return Err(std::io::Error::last_os_error().into());
10016                }
10017                directory.sync_all()?;
10018            }
10019        }
10020    }
10021    Ok(())
10022}
10023
10024#[cfg(unix)]
10025fn install_pulled_delta(
10026    dest: &Path,
10027    entries: &[(String, Vec<u8>)],
10028    deleted: &[String],
10029    rebuild_indexes: bool,
10030) -> LinkResult<()> {
10031    use ring::rand::SecureRandom as _;
10032    use std::os::fd::AsRawFd as _;
10033    use std::os::unix::ffi::OsStrExt as _;
10034
10035    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10036    let name = dest
10037        .file_name()
10038        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10039        .ok_or_else(|| LinkError::UnsafePath {
10040            path: dest.display().to_string(),
10041        })?;
10042    let parent_dir = open_or_create_dir_nofollow(parent)?;
10043    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10044    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10045        None => false,
10046        Some(true) => true,
10047        Some(false) => {
10048            return Err(LinkError::UnsafePath {
10049                path: dest.display().to_string(),
10050            });
10051        }
10052    };
10053
10054    let mut nonce = [0_u8; 16];
10055    ring::rand::SystemRandom::new()
10056        .fill(&mut nonce)
10057        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10058    let stage_label = format!(
10059        ".{}.dbmd-pull-stage-{}",
10060        name.to_string_lossy(),
10061        URL_SAFE_NO_PAD.encode(nonce)
10062    );
10063    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10064    let stage_dir = create_dir_exclusive_at(
10065        parent_dir.as_raw_fd(),
10066        &stage_name,
10067        &dest.display().to_string(),
10068    )?;
10069
10070    let prepared = (|| -> LinkResult<()> {
10071        if dest_exists {
10072            let live = open_dir_at(
10073                parent_dir.as_raw_fd(),
10074                &dest_name,
10075                &dest.display().to_string(),
10076            )?;
10077            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10078        }
10079        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10080        write_pull_entries_beneath_dir(&stage_dir, entries)?;
10081        if rebuild_indexes {
10082            let stage_store =
10083                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10084                    .map_err(|error| LinkError::InvalidPack {
10085                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10086                    })?;
10087            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10088                LinkError::InvalidPack {
10089                    message: format!("could not materialize v2 local catalogs: {error}"),
10090                }
10091            })?;
10092        }
10093        stage_dir.sync_all()?;
10094        Ok(())
10095    })();
10096    if let Err(error) = prepared {
10097        let _ = remove_tree_at(
10098            parent_dir.as_raw_fd(),
10099            &stage_name,
10100            &dest.display().to_string(),
10101        );
10102        return Err(error);
10103    }
10104
10105    if let Err(error) =
10106        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10107    {
10108        let _ = remove_tree_at(
10109            parent_dir.as_raw_fd(),
10110            &stage_name,
10111            &dest.display().to_string(),
10112        );
10113        return Err(error);
10114    }
10115    parent_dir.sync_all()?;
10116    if dest_exists {
10117        // Commit already happened atomically. Cleanup is best-effort so a
10118        // failure cannot be reported as a failed pull after the live tree
10119        // changed; a crash may leave only this private old-tree sibling.
10120        let _ = remove_tree_at(
10121            parent_dir.as_raw_fd(),
10122            &stage_name,
10123            &dest.display().to_string(),
10124        );
10125        let _ = parent_dir.sync_all();
10126    }
10127    Ok(())
10128}
10129
10130#[cfg(unix)]
10131fn install_pulled_delta_sources(
10132    dest: &Path,
10133    entries: &[V2StagedFile],
10134    deleted: &[String],
10135    rebuild_indexes: bool,
10136    _previous: Option<&V2SyncBaseline>,
10137    _next: &V2VerifiedHead,
10138) -> LinkResult<()> {
10139    use ring::rand::SecureRandom as _;
10140    use std::os::fd::AsRawFd as _;
10141    use std::os::unix::ffi::OsStrExt as _;
10142
10143    // An established checkout is updated only at the changed coordinates
10144    // through the durable pull journal shared with Windows. Initial clones
10145    // still use a private complete stage and one atomic directory install.
10146    if let Ok(store) = Store::open_strict(dest) {
10147        return install_established_v2_delta(
10148            store,
10149            entries,
10150            deleted,
10151            rebuild_indexes,
10152            _previous,
10153            _next,
10154        );
10155    }
10156
10157    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10158    let name = dest
10159        .file_name()
10160        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10161        .ok_or_else(|| LinkError::UnsafePath {
10162            path: dest.display().to_string(),
10163        })?;
10164    let parent_dir = open_or_create_dir_nofollow(parent)?;
10165    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10166    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10167        None => false,
10168        Some(true) => true,
10169        Some(false) => {
10170            return Err(LinkError::UnsafePath {
10171                path: dest.display().to_string(),
10172            })
10173        }
10174    };
10175    let mut nonce = [0_u8; 16];
10176    ring::rand::SystemRandom::new()
10177        .fill(&mut nonce)
10178        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10179    let stage_label = format!(
10180        ".{}.dbmd-pull-stage-{}",
10181        name.to_string_lossy(),
10182        URL_SAFE_NO_PAD.encode(nonce)
10183    );
10184    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10185    let stage_dir = create_dir_exclusive_at(
10186        parent_dir.as_raw_fd(),
10187        &stage_name,
10188        &dest.display().to_string(),
10189    )?;
10190    let prepared = (|| -> LinkResult<()> {
10191        if dest_exists {
10192            let live = open_dir_at(
10193                parent_dir.as_raw_fd(),
10194                &dest_name,
10195                &dest.display().to_string(),
10196            )?;
10197            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10198        }
10199        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10200        write_pull_sources_beneath_dir(&stage_dir, entries)?;
10201        if rebuild_indexes {
10202            let stage_store =
10203                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10204                    .map_err(|error| LinkError::InvalidPack {
10205                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10206                    })?;
10207            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10208                LinkError::InvalidPack {
10209                    message: format!("could not materialize v2 local catalogs: {error}"),
10210                }
10211            })?;
10212        }
10213        stage_dir.sync_all()?;
10214        Ok(())
10215    })();
10216    if let Err(error) = prepared {
10217        let _ = remove_tree_at(
10218            parent_dir.as_raw_fd(),
10219            &stage_name,
10220            &dest.display().to_string(),
10221        );
10222        return Err(error);
10223    }
10224    if let Err(error) =
10225        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10226    {
10227        let _ = remove_tree_at(
10228            parent_dir.as_raw_fd(),
10229            &stage_name,
10230            &dest.display().to_string(),
10231        );
10232        return Err(error);
10233    }
10234    parent_dir.sync_all()?;
10235    if dest_exists {
10236        let _ = remove_tree_at(
10237            parent_dir.as_raw_fd(),
10238            &stage_name,
10239            &dest.display().to_string(),
10240        );
10241        let _ = parent_dir.sync_all();
10242    }
10243    Ok(())
10244}
10245
10246#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10247struct V2PullCoordinate {
10248    head_seq: Option<u64>,
10249    commit_hash: Option<String>,
10250    view_kind: Option<String>,
10251    view_revision: Option<String>,
10252}
10253
10254#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10255struct V2PullFileCoordinate {
10256    sha256: String,
10257    bytes: u64,
10258}
10259
10260#[derive(Debug, Clone, Deserialize, Serialize)]
10261struct V2PullJournalEntry {
10262    path: String,
10263    old: Option<V2PullFileCoordinate>,
10264    new: Option<V2PullFileCoordinate>,
10265    backup: Option<String>,
10266}
10267
10268#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10269#[serde(rename_all = "snake_case")]
10270enum V2PullPhase {
10271    Preparing,
10272    Ready,
10273}
10274
10275#[derive(Debug, Clone, Deserialize, Serialize)]
10276struct V2PullJournal {
10277    v: u8,
10278    phase: V2PullPhase,
10279    brain: String,
10280    previous: V2PullCoordinate,
10281    next: V2PullCoordinate,
10282    backup_dir: String,
10283    entries: Vec<V2PullJournalEntry>,
10284}
10285
10286const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
10287
10288fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
10289    V2PullCoordinate {
10290        head_seq: baseline.and_then(|value| value.head_seq),
10291        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
10292        view_kind: baseline.and_then(|value| value.view_kind.clone()),
10293        view_revision: baseline.and_then(|value| value.view_revision.clone()),
10294    }
10295}
10296
10297fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
10298    V2PullCoordinate {
10299        head_seq: head.pointer.as_ref().map(|value| value.seq),
10300        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
10301        view_kind: Some(head.view_kind.clone()),
10302        view_revision: Some(head.view_revision.clone()),
10303    }
10304}
10305
10306fn v2_pull_file_coordinate(
10307    store: &Store,
10308    path: &str,
10309    limit: u64,
10310) -> LinkResult<Option<V2PullFileCoordinate>> {
10311    let file = match store.open_regular(Path::new(path)) {
10312        Ok(file) => file,
10313        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10314        Err(error) => return Err(error.into()),
10315    };
10316    let bytes = file.metadata()?.len();
10317    if bytes > limit || bytes > MAX_STORE_BYTES {
10318        return Err(invalid_feed(
10319            "pull transaction file exceeds its declared bound",
10320        ));
10321    }
10322    Ok(Some(V2PullFileCoordinate {
10323        sha256: content_sha256_reader(file)?,
10324        bytes,
10325    }))
10326}
10327
10328fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
10329    let mut bytes = serde_json::to_vec_pretty(journal)
10330        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
10331    bytes.push(b'\n');
10332    Ok(bytes)
10333}
10334
10335fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10336    let backup_prefix = ".dbmd/pull-backup-";
10337    let suffix = journal
10338        .backup_dir
10339        .strip_prefix(backup_prefix)
10340        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10341    let mut paths = std::collections::BTreeSet::new();
10342    if journal.v != 1
10343        || !crate::ulid::is_ulid(&journal.brain)
10344        || !crate::ulid::is_ulid(suffix)
10345        || journal.entries.is_empty()
10346        || journal.entries.len() > MAX_PUSH_FILES + 4
10347        || journal.previous == journal.next
10348    {
10349        return Err(invalid_feed("v2 pull journal failed validation"));
10350    }
10351    for (index, entry) in journal.entries.iter().enumerate() {
10352        if !safe_store_rel_path(&entry.path)
10353            || entry.path == V2_PULL_JOURNAL
10354            || entry.path.starts_with(backup_prefix)
10355            || !paths.insert(entry.path.clone())
10356            || (entry.old.is_none() && entry.new.is_none())
10357            || entry
10358                .old
10359                .iter()
10360                .chain(entry.new.iter())
10361                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10362            || entry.backup.as_deref()
10363                != entry
10364                    .old
10365                    .as_ref()
10366                    .map(|_| format!("{index:08x}"))
10367                    .as_deref()
10368        {
10369            return Err(invalid_feed("v2 pull journal entry failed validation"));
10370        }
10371    }
10372    Ok(())
10373}
10374
10375fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10376    #[cfg(unix)]
10377    {
10378        use std::os::unix::fs::PermissionsExt as _;
10379        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10380            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10381                return Err(invalid_feed(
10382                    "v2 pull journal is accessible to group/other; set mode 0600",
10383                ));
10384            }
10385            Ok(_) => {}
10386            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10387            Err(error) => return Err(error.into()),
10388        }
10389    }
10390    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10391        Ok(bytes) => bytes,
10392        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10393        Err(error) => return Err(error.into()),
10394    };
10395    let journal: V2PullJournal =
10396        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10397    validate_v2_pull_journal(&journal)?;
10398    Ok(Some(journal))
10399}
10400
10401fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10402    // The journal is the only recovery authority. Remove it first only after
10403    // rollback or baseline commit is durable; an orphan private backup is safe
10404    // and can be pruned, while a journal without its backups is not recoverable.
10405    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10406        Ok(()) => {}
10407        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10408        Err(error) => return Err(error.into()),
10409    }
10410    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10411        Ok(()) => Ok(()),
10412        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10413        Err(error) => Err(error.into()),
10414    }
10415}
10416
10417fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10418    let names = match store.directory_names(Path::new(".dbmd")) {
10419        Ok(names) => names,
10420        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10421        Err(error) => return Err(error.into()),
10422    };
10423    for name in names {
10424        let Some(name) = name.to_str() else {
10425            continue;
10426        };
10427        let Some(suffix) = name.strip_prefix("pull-backup-") else {
10428            continue;
10429        };
10430        if crate::ulid::is_ulid(suffix) {
10431            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10432        }
10433    }
10434    Ok(())
10435}
10436
10437fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10438    // Validate every live coordinate and every backup before the first restore.
10439    for entry in &journal.entries {
10440        let limit = entry
10441            .old
10442            .as_ref()
10443            .into_iter()
10444            .chain(entry.new.iter())
10445            .map(|value| value.bytes)
10446            .max()
10447            .unwrap_or(0);
10448        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10449        if current != entry.old && current != entry.new {
10450            return Err(LinkError::InvalidPack {
10451                message: format!(
10452                    "cannot recover interrupted pull because `{}` changed afterward",
10453                    entry.path
10454                ),
10455            });
10456        }
10457        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10458            let path = Path::new(&journal.backup_dir).join(backup);
10459            let file = store.open_regular(&path)?;
10460            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10461                return Err(invalid_feed("v2 pull recovery backup failed verification"));
10462            }
10463        }
10464    }
10465    for entry in journal.entries.iter().rev() {
10466        match (&entry.old, &entry.backup) {
10467            (Some(old), Some(backup)) => {
10468                let bytes =
10469                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10470                store.write_atomic(Path::new(&entry.path), &bytes)?;
10471            }
10472            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10473                store.remove_file(Path::new(&entry.path))?;
10474            }
10475            (None, None) => {}
10476            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10477        }
10478    }
10479    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10480        message: format!("could not rebuild catalogs after pull recovery: {error}"),
10481    })?;
10482    cleanup_v2_pull_journal(store, journal)
10483}
10484
10485fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10486    let Ok(store) = Store::open_strict(dest) else {
10487        return Ok(());
10488    };
10489    if let Some(journal) = load_v2_pull_journal(&store)? {
10490        if journal.brain != brain {
10491            return Err(invalid_feed("v2 pull journal belongs to another brain"));
10492        }
10493        if journal.phase == V2PullPhase::Preparing {
10494            cleanup_v2_pull_journal(&store, &journal)?;
10495        } else {
10496            let baseline = load_v2_baseline(cfg, brain, dest)?;
10497            let current = v2_pull_baseline_coordinate(baseline.as_ref());
10498            if current == journal.next {
10499                cleanup_v2_pull_journal(&store, &journal)?;
10500            } else {
10501                if current != journal.previous {
10502                    return Err(invalid_feed(
10503                        "cannot recover interrupted pull because its baseline changed afterward",
10504                    ));
10505                }
10506                rollback_v2_pull(&store, &journal)?;
10507            }
10508        }
10509    }
10510    // Cleanup removes the journal first after the state is durably old or new.
10511    // A hard kill in the tiny interval before deleting its backup directory can
10512    // therefore leave only an inert, private orphan. The per-brain operation
10513    // lock is held by the caller, so no live pull can own one here.
10514    prune_orphan_v2_pull_backups(&store)
10515}
10516
10517fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10518    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10519        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10520    })?;
10521    if let Some(journal) = load_v2_pull_journal(&store)? {
10522        cleanup_v2_pull_journal(&store, &journal)?;
10523    }
10524    Ok(())
10525}
10526
10527#[cfg(windows)]
10528fn install_windows_initial_sources(
10529    dest: &Path,
10530    entries: &[V2StagedFile],
10531    rebuild_indexes: bool,
10532) -> LinkResult<()> {
10533    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10534    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10535        path: dest.display().to_string(),
10536    })?;
10537    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10538    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10539        return Err(LinkError::UnsafePath {
10540            path: dest.display().to_string(),
10541        });
10542    }
10543    let stage_name = format!(
10544        ".{}.dbmd-pull-stage-{}",
10545        name.to_string_lossy(),
10546        crate::ulid::mint()
10547    );
10548    let stage_path = parent.join(&stage_name);
10549    let stage_capability =
10550        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10551    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10552    let prepared = (|| -> LinkResult<()> {
10553        for entry in entries {
10554            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10555            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10556                return Err(invalid_feed(
10557                    "private staged sync source failed final integrity verification",
10558                ));
10559            }
10560            stage.write_atomic(Path::new(&entry.path), &bytes)?;
10561        }
10562        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10563            .map_err(|error| LinkError::InvalidPack {
10564                message: format!("v2 staging tree is not a valid db.md store: {error}"),
10565            })?;
10566        if rebuild_indexes {
10567            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10568                message: format!("could not materialize v2 local catalogs: {error}"),
10569            })?;
10570        }
10571        Ok(())
10572    })();
10573    if let Err(error) = prepared {
10574        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10575        return Err(error);
10576    }
10577    crate::fsx::rename_directory_beneath(
10578        &parent_capability,
10579        Path::new(&stage_name),
10580        Path::new(name),
10581    )?;
10582    Ok(())
10583}
10584
10585fn install_established_v2_delta(
10586    store: Store,
10587    entries: &[V2StagedFile],
10588    deleted: &[String],
10589    rebuild_indexes: bool,
10590    previous: Option<&V2SyncBaseline>,
10591    next: &V2VerifiedHead,
10592) -> LinkResult<()> {
10593    if load_v2_pull_journal(&store)?.is_some() {
10594        return Err(invalid_feed(
10595            "an interrupted pull must be recovered before installing",
10596        ));
10597    }
10598    let mut sources = std::collections::BTreeMap::new();
10599    for entry in entries {
10600        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10601            return Err(invalid_feed("pull mutation repeats a path"));
10602        }
10603    }
10604    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10605    paths.extend(deleted.iter().cloned());
10606    paths.sort();
10607    paths.dedup();
10608    if paths.is_empty() {
10609        return Ok(());
10610    }
10611    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10612    let mut journal = V2PullJournal {
10613        v: 1,
10614        phase: V2PullPhase::Preparing,
10615        brain: next.brain_id.clone(),
10616        previous: v2_pull_baseline_coordinate(previous),
10617        next: v2_pull_head_coordinate(next),
10618        backup_dir: backup_dir.clone(),
10619        entries: Vec::with_capacity(paths.len()),
10620    };
10621    for path in &paths {
10622        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10623        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10624            sha256: entry.sha256.clone(),
10625            bytes: entry.bytes,
10626        });
10627        if old == new {
10628            continue;
10629        }
10630        let index = journal.entries.len();
10631        journal.entries.push(V2PullJournalEntry {
10632            path: path.clone(),
10633            backup: old.as_ref().map(|_| format!("{index:08x}")),
10634            old,
10635            new,
10636        });
10637    }
10638    if journal.entries.is_empty() {
10639        return Ok(());
10640    }
10641    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10642        entry
10643            .old
10644            .as_ref()
10645            .map_or(Some(total), |old| total.checked_add(old.bytes))
10646    });
10647    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10648        return Err(LinkError::InvalidPack {
10649            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10650        });
10651    }
10652    validate_v2_pull_journal(&journal)?;
10653    store.write_private_atomic_new(
10654        Path::new(V2_PULL_JOURNAL),
10655        &v2_pull_journal_bytes(&journal)?,
10656    )?;
10657    let prepared = (|| -> LinkResult<()> {
10658        store.create_private_dir_all(Path::new(&backup_dir))?;
10659        for entry in &journal.entries {
10660            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10661                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10662                if content_sha256(&bytes) != old.sha256 {
10663                    return Err(invalid_feed("live pull source changed during backup"));
10664                }
10665                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10666            }
10667        }
10668        journal.phase = V2PullPhase::Ready;
10669        store.write_private_atomic(
10670            Path::new(V2_PULL_JOURNAL),
10671            &v2_pull_journal_bytes(&journal)?,
10672        )?;
10673        Ok(())
10674    })();
10675    if let Err(error) = prepared {
10676        let cleanup = cleanup_v2_pull_journal(&store, &journal);
10677        return match cleanup {
10678            Ok(()) => Err(error),
10679            Err(cleanup) => Err(LinkError::InvalidPack {
10680                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10681            }),
10682        };
10683    }
10684    let installed = (|| -> LinkResult<()> {
10685        for entry in &journal.entries {
10686            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10687                return Err(LinkError::InvalidPack {
10688                    message: format!("local path `{}` changed during pull", entry.path),
10689                });
10690            }
10691            if let Some(source) = sources.get(&entry.path) {
10692                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10693                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10694                    return Err(invalid_feed(
10695                        "private staged sync source failed final integrity verification",
10696                    ));
10697                }
10698                store.write_atomic(Path::new(&entry.path), &bytes)?;
10699            } else if entry.old.is_some() {
10700                store.remove_file(Path::new(&entry.path))?;
10701            }
10702        }
10703        if rebuild_indexes {
10704            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10705                message: format!("could not materialize v2 local catalogs: {error}"),
10706            })?;
10707        }
10708        Ok(())
10709    })();
10710    if let Err(error) = installed {
10711        return match rollback_v2_pull(&store, &journal) {
10712            Ok(()) => Err(error),
10713            Err(rollback) => Err(LinkError::InvalidPack {
10714                message: format!("{error}; durable pull rollback also failed: {rollback}"),
10715            }),
10716        };
10717    }
10718    Ok(())
10719}
10720
10721#[cfg(windows)]
10722fn install_pulled_delta_sources(
10723    dest: &Path,
10724    entries: &[V2StagedFile],
10725    deleted: &[String],
10726    rebuild_indexes: bool,
10727    previous: Option<&V2SyncBaseline>,
10728    next: &V2VerifiedHead,
10729) -> LinkResult<()> {
10730    match Store::open_strict(dest) {
10731        Ok(store) => {
10732            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10733        }
10734        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10735    }
10736}
10737
10738#[cfg(not(any(unix, windows)))]
10739fn install_pulled_delta_sources(
10740    _dest: &Path,
10741    _entries: &[V2StagedFile],
10742    _deleted: &[String],
10743    _rebuild_indexes: bool,
10744    _previous: Option<&V2SyncBaseline>,
10745    _next: &V2VerifiedHead,
10746) -> LinkResult<()> {
10747    Err(LinkError::UnsupportedPlatform {
10748        operation: "atomic v2 pull install",
10749    })
10750}
10751
10752#[cfg(unix)]
10753fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10754    install_pulled_delta(dest, entries, &[], false)
10755}
10756
10757#[cfg(not(windows))]
10758fn is_safe_slug(slug: &str) -> bool {
10759    !slug.is_empty()
10760        && slug.len() <= 63
10761        && !slug.starts_with('-')
10762        && !slug.ends_with('-')
10763        && slug
10764            .bytes()
10765            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10766}
10767
10768fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10769    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10770}
10771
10772fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10773    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10774}
10775
10776fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10777    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10778}
10779
10780fn preflight_zip_central_directory(
10781    bytes: &[u8],
10782    offset: usize,
10783    size: usize,
10784    count: u64,
10785) -> LinkResult<()> {
10786    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10787    let end = offset
10788        .checked_add(size)
10789        .filter(|end| *end <= bytes.len())
10790        .ok_or_else(|| LinkError::InvalidPack {
10791            message: "ZIP central directory is out of bounds".to_string(),
10792        })?;
10793    let mut cursor = offset;
10794    for _ in 0..count {
10795        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10796            return Err(LinkError::InvalidPack {
10797                message: "ZIP central directory entry count is inconsistent".to_string(),
10798            });
10799        }
10800        if le_u16(bytes, cursor + 34) != Some(0) {
10801            return Err(LinkError::InvalidPack {
10802                message: "multi-disk ZIP archives are not supported".to_string(),
10803            });
10804        }
10805        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10806            total.checked_add(le_u16(bytes, cursor + at)? as usize)
10807        });
10808        cursor = cursor
10809            .checked_add(46)
10810            .and_then(|fixed| fixed.checked_add(variable?))
10811            .filter(|cursor| *cursor <= end)
10812            .ok_or_else(|| LinkError::InvalidPack {
10813                message: "ZIP central directory entry is truncated".to_string(),
10814            })?;
10815    }
10816    if cursor != end {
10817        return Err(LinkError::InvalidPack {
10818            message: "ZIP central directory size is inconsistent".to_string(),
10819        });
10820    }
10821    Ok(())
10822}
10823
10824/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
10825/// metadata object per central-directory entry. Supports ordinary EOCD and the
10826/// Zip64 locator/record emitted for >65,535-entry archives.
10827fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10828    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10829    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10830    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10831    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10832    let eocd = bytes[search_start..]
10833        .windows(4)
10834        .rposition(|window| window == EOCD_SIG)
10835        .map(|offset| search_start + offset)
10836        .ok_or_else(|| LinkError::InvalidPack {
10837            message: "ZIP has no end-of-central-directory record".to_string(),
10838        })?;
10839    let invalid_end = || LinkError::InvalidPack {
10840        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10841    };
10842    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10843    if eocd
10844        .checked_add(22)
10845        .and_then(|end| end.checked_add(comment_len))
10846        != Some(bytes.len())
10847    {
10848        // Do not fall back to an earlier signature. ZipArchive does, and a
10849        // fake low-count EOCD appended after a real Zip64 entry-count bomb
10850        // would otherwise bypass this allocation preflight.
10851        return Err(invalid_end());
10852    }
10853    let disk = le_u16(bytes, eocd + 4);
10854    let central_disk = le_u16(bytes, eocd + 6);
10855    if disk != Some(0) || central_disk != Some(0) {
10856        return Err(LinkError::InvalidPack {
10857            message: "multi-disk ZIP archives are not supported".to_string(),
10858        });
10859    }
10860    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10861    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10862    if entries_on_disk != ordinary {
10863        return Err(LinkError::InvalidPack {
10864            message: "multi-disk ZIP archives are not supported".to_string(),
10865        });
10866    }
10867    let zip64_locator = eocd
10868        .checked_sub(20)
10869        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10870    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10871        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10872        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10873        if central_offset
10874            .checked_add(central_size)
10875            .filter(|end| *end == eocd)
10876            .is_none()
10877        {
10878            return Err(invalid_end());
10879        }
10880        (ordinary as u64, central_offset, central_size)
10881    } else {
10882        let Some(locator) = zip64_locator else {
10883            return Err(invalid_end());
10884        };
10885        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10886            return Err(LinkError::InvalidPack {
10887                message: "multi-disk ZIP64 archives are not supported".to_string(),
10888            });
10889        }
10890        let record = le_u64(bytes, locator + 8)
10891            .and_then(|offset| usize::try_from(offset).ok())
10892            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10893            .ok_or_else(|| LinkError::InvalidPack {
10894                message: "ZIP64 archive has an invalid end record".to_string(),
10895            })?;
10896        let record_size = le_u64(bytes, record + 4)
10897            .and_then(|size| usize::try_from(size).ok())
10898            .filter(|size| *size >= 44)
10899            .ok_or_else(invalid_end)?;
10900        if record
10901            .checked_add(12)
10902            .and_then(|end| end.checked_add(record_size))
10903            != Some(locator)
10904            || le_u32(bytes, record + 16) != Some(0)
10905            || le_u32(bytes, record + 20) != Some(0)
10906        {
10907            return Err(invalid_end());
10908        }
10909        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10910        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10911        let central_size = le_u64(bytes, record + 40)
10912            .and_then(|size| usize::try_from(size).ok())
10913            .ok_or_else(invalid_end)?;
10914        let central_offset = le_u64(bytes, record + 48)
10915            .and_then(|offset| usize::try_from(offset).ok())
10916            .ok_or_else(invalid_end)?;
10917        if zip64_on_disk != zip64_total
10918            || central_offset
10919                .checked_add(central_size)
10920                .filter(|end| *end == record)
10921                .is_none()
10922        {
10923            return Err(invalid_end());
10924        }
10925        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10926        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10927        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10928            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10929        {
10930            return Err(invalid_end());
10931        }
10932        (zip64_total, central_offset, central_size)
10933    };
10934    if count == 0 || count > max_entries as u64 {
10935        return Err(LinkError::InvalidPack {
10936            message: format!("invalid file count {count}"),
10937        });
10938    }
10939    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10940    Ok(())
10941}
10942
10943fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10944    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10945    let mut archive =
10946        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10947            message: format!("ZIP parse failed: {err}"),
10948        })?;
10949    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10950        return Err(LinkError::InvalidPack {
10951            message: format!("invalid file count {}", archive.len()),
10952        });
10953    }
10954    let mut total = 0u64;
10955    let mut seen = std::collections::HashSet::new();
10956    let mut entries = Vec::with_capacity(archive.len());
10957    for index in 0..archive.len() {
10958        let mut file = archive
10959            .by_index(index)
10960            .map_err(|err| LinkError::InvalidPack {
10961                message: format!("ZIP entry failed: {err}"),
10962            })?;
10963        if file.is_dir() {
10964            continue;
10965        }
10966        let path = file.name().to_string();
10967        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10968            return Err(LinkError::UnsafePath { path });
10969        }
10970        if file
10971            .unix_mode()
10972            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10973        {
10974            return Err(LinkError::InvalidPack {
10975                message: format!("non-file entry `{path}`"),
10976            });
10977        }
10978        if !seen.insert(path.clone()) {
10979            return Err(LinkError::InvalidPack {
10980                message: format!("duplicate path `{path}`"),
10981            });
10982        }
10983        let remaining = MAX_STORE_BYTES.saturating_sub(total);
10984        if file.size() > remaining {
10985            return Err(LinkError::InvalidPack {
10986                message: "expanded content exceeds the 512 MB limit".to_string(),
10987            });
10988        }
10989        let mut content = Vec::new();
10990        (&mut file)
10991            .take(remaining + 1)
10992            .read_to_end(&mut content)
10993            .map_err(|err| LinkError::InvalidPack {
10994                message: format!("could not decompress `{path}`: {err}"),
10995            })?;
10996        if content.len() as u64 > remaining {
10997            return Err(LinkError::InvalidPack {
10998                message: "expanded content exceeds the 512 MB limit".to_string(),
10999            });
11000        }
11001        if content.len() as u64 != file.size() {
11002            return Err(LinkError::InvalidPack {
11003                message: format!("length mismatch for `{path}`"),
11004            });
11005        }
11006        total += content.len() as u64;
11007        entries.push((path, content));
11008    }
11009    if entries.is_empty() {
11010        return Err(LinkError::InvalidPack {
11011            message: "pack contains no files".to_string(),
11012        });
11013    }
11014    Ok(entries)
11015}
11016
11017fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
11018    let mut expected = std::collections::BTreeMap::new();
11019    for file in signed {
11020        if !safe_store_rel_path(&file.path) {
11021            return Err(LinkError::UnsafePath {
11022                path: file.path.clone(),
11023            });
11024        }
11025        if !is_sha256(&file.sha256)
11026            || expected
11027                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11028                .is_some()
11029        {
11030            return Err(invalid_feed(
11031                "signed snapshot manifest contains an invalid or duplicate file",
11032            ));
11033        }
11034    }
11035    if expected.len() != entries.len() {
11036        return Err(invalid_feed(
11037            "downloaded pack file set differs from the signed snapshot manifest",
11038        ));
11039    }
11040    for (path, bytes) in entries {
11041        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
11042            return Err(invalid_feed(format!(
11043                "downloaded pack contains unsigned path `{path}`"
11044            )));
11045        };
11046        if *declared_bytes != bytes.len() as u64
11047            || *sha256 != format!("{:x}", Sha256::digest(bytes))
11048        {
11049            return Err(invalid_feed(format!(
11050                "downloaded file `{path}` differs from its signed manifest"
11051            )));
11052        }
11053    }
11054    Ok(())
11055}
11056
11057/// Collect the files a push sends: the store's owned text — `DB.md`,
11058/// `assets.jsonl` when present, and every content `.md` under `records/` and
11059/// `sources/` (the store walk, which already excludes hidden dirs like
11060/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
11061/// derives its own index, and local history stays local). Returns
11062/// `(store-relative path, content)` pairs, path-sorted.
11063pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11064    require_hardened_filesystem("sync push")?;
11065    preflight_push_ownership(store)?;
11066    let mut out: Vec<(String, String)> = Vec::new();
11067    let mut total = 0u64;
11068
11069    let mut read_text = |rel: &str| -> LinkResult<String> {
11070        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11071        total = total
11072            .checked_add(bytes.len() as u64)
11073            .ok_or_else(|| LinkError::PushTooLarge {
11074                detail: "uncompressed byte count overflow".to_string(),
11075            })?;
11076        if total > MAX_STORE_BYTES {
11077            return Err(LinkError::PushTooLarge {
11078                detail: format!("{total} uncompressed bytes"),
11079            });
11080        }
11081        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11082            path: rel.to_string(),
11083        })
11084    };
11085
11086    out.push(("DB.md".to_string(), read_text("DB.md")?));
11087    if store
11088        .regular_file_exists(Path::new("assets.jsonl"))
11089        .unwrap_or(false)
11090    {
11091        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11092    }
11093
11094    for rel in store.walk()? {
11095        let rel_str = rel.to_string_lossy().replace('\\', "/");
11096        if !safe_store_rel_path(&rel_str) {
11097            // A locally-legal name outside the hub's portable charset cannot
11098            // travel this wire; refusing beats silently dropping it.
11099            return Err(LinkError::UnsafePath { path: rel_str });
11100        }
11101        let content = read_text(&rel_str)?;
11102        out.push((rel_str, content));
11103    }
11104
11105    out.sort_by(|a, b| a.0.cmp(&b.0));
11106    Ok(out)
11107}
11108
11109/// Refuse to build a destructive whole-store snapshot from an ambiguous local
11110/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
11111/// silently omitted them could delete the hosted copies of those paths.
11112fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11113    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11114        return Err(LinkError::from(std::io::Error::new(
11115            std::io::ErrorKind::PermissionDenied,
11116            format!("cannot push: nested db.md store at {}", nested.display()),
11117        )));
11118    }
11119
11120    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11121        return Err(LinkError::from(std::io::Error::new(
11122            std::io::ErrorKind::PermissionDenied,
11123            format!(
11124                "cannot push: {} is a symlink outside the store ownership model",
11125                symlink.display()
11126            ),
11127        )));
11128    }
11129    Ok(())
11130}
11131
11132/// Push `files` to `brain` as a whole-store snapshot — the hub's push
11133/// semantics: the hosted copy becomes exactly this set (pull first if the
11134/// hosted side may have records the local copy lacks). Client-side caps
11135/// mirror the hub's JSON-path limits so an oversized push fails before the
11136/// upload.
11137pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11138    require_safe_ref(brain)?;
11139    let remote = verified_remote_head(cfg, brain, false)?;
11140    if files.len() > MAX_PUSH_FILES {
11141        return Err(LinkError::PushTooLarge {
11142            detail: format!("{} files", files.len()),
11143        });
11144    }
11145    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11146    if raw_total > MAX_STORE_BYTES {
11147        return Err(LinkError::PushTooLarge {
11148            detail: format!("{raw_total} uncompressed bytes"),
11149        });
11150    }
11151
11152    // Self-custody (a brain key is configured): the JSON fast path is
11153    // hub-signed by construction, so every push goes through the pack flow
11154    // with a locally signed entry — the hub verifies and can never sign.
11155    if cfg.brain_key.is_none() {
11156        let body = json!({
11157            "files": files
11158                .iter()
11159                .map(|(p, c)| json!({ "path": p, "content": c }))
11160                .collect::<Vec<_>>(),
11161        });
11162        if body.to_string().len() <= MAX_PUSH_BYTES {
11163            let path = format!("/api/hub/brains/{brain}/push");
11164            let pushed = ensure_ok(
11165                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11166                "sync push",
11167            )?;
11168            return Ok(pushed);
11169        }
11170    }
11171
11172    let pack = build_store_pack(files)?;
11173    if pack.len() as u64 > MAX_PACK_BYTES {
11174        return Err(LinkError::PushTooLarge {
11175            detail: format!("{} pack bytes", pack.len()),
11176        });
11177    }
11178    let sha256 = format!("{:x}", Sha256::digest(&pack));
11179    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11180    if let Some(key) = &cfg.brain_key {
11181        if !remote.head.verified {
11182            return Err(invalid_feed(
11183                "self-custody push requires a fully verified, unscoped feed head",
11184            ));
11185        }
11186        let identity = remote
11187            .identity
11188            .as_ref()
11189            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
11190        let current_multikey = format!("ed25519:{}", identity.fingerprint);
11191        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
11192            return Err(invalid_feed(
11193                "configured brain key is not the verified current brain identity",
11194            ));
11195        }
11196        // The verified state pins seq + prev; a concurrent writer surfaces as
11197        // the hub's 422 on commit (re-run to retry against the new head).
11198        let next_seq = remote
11199            .head
11200            .seq
11201            .checked_add(1)
11202            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
11203        let mut manifest: Vec<WireFeedFile> = files
11204            .iter()
11205            .map(|(path, content)| WireFeedFile {
11206                path: path.clone(),
11207                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
11208                bytes: content.len() as u64,
11209            })
11210            .collect();
11211        manifest.sort_by(|a, b| a.path.cmp(&b.path));
11212        let ts = crate::now()
11213            .with_timezone(&chrono::Utc)
11214            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11215            .to_string();
11216        let entry = self_custody_entry(
11217            key,
11218            next_seq,
11219            ts,
11220            &sha256,
11221            &manifest,
11222            remote.head.feed_hash.as_deref(),
11223        )?;
11224        meta["entry"] = Value::String(entry);
11225    }
11226    let presigned = ensure_ok(
11227        request(
11228            cfg,
11229            "POST",
11230            &format!("/api/hub/brains/{brain}/packs/presign"),
11231            Some(&meta),
11232            Auth::Required,
11233        )?,
11234        "prepare pack upload",
11235    )?;
11236    let url = presigned
11237        .get("url")
11238        .and_then(Value::as_str)
11239        .ok_or_else(|| LinkError::InvalidPack {
11240            message: "the hub returned no upload URL".to_string(),
11241        })?;
11242    put_presigned(
11243        cfg,
11244        url,
11245        presigned.get("headers").unwrap_or(&Value::Null),
11246        &pack,
11247    )?;
11248    let committed = ensure_ok(
11249        request(
11250            cfg,
11251            "POST",
11252            &format!("/api/hub/brains/{brain}/packs/commit"),
11253            Some(&meta),
11254            Auth::Required,
11255        )?,
11256        "commit pack",
11257    )?;
11258    Ok(committed)
11259}
11260
11261fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
11262    const LOCAL_HEADER: u32 = 0x0403_4b50;
11263    const CENTRAL_HEADER: u32 = 0x0201_4b50;
11264    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
11265    const VERSION_20: u16 = 20;
11266    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
11267    const UTF8_FLAG: u16 = 1 << 11;
11268    const STORED: u16 = 0;
11269    const DOS_TIME_MIDNIGHT: u16 = 0;
11270    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
11271    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
11272
11273    struct CentralEntry<'a> {
11274        name: &'a [u8],
11275        crc32: u32,
11276        size: u32,
11277        local_offset: u32,
11278    }
11279
11280    fn push_u16(out: &mut Vec<u8>, value: u16) {
11281        out.extend_from_slice(&value.to_le_bytes());
11282    }
11283
11284    fn push_u32(out: &mut Vec<u8>, value: u32) {
11285        out.extend_from_slice(&value.to_le_bytes());
11286    }
11287
11288    if files.is_empty() {
11289        return Err(LinkError::InvalidPack {
11290            message: "cannot create an empty snapshot pack".to_string(),
11291        });
11292    }
11293    if files.len() > u16::MAX as usize {
11294        return Err(LinkError::PushTooLarge {
11295            detail: format!(
11296                "{} files (canonical ZIP32 packs cap at {})",
11297                files.len(),
11298                u16::MAX
11299            ),
11300        });
11301    }
11302
11303    let mut sorted: Vec<_> = files.iter().collect();
11304    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
11305    let mut previous: Option<&str> = None;
11306    for (path, content) in &sorted {
11307        if !safe_store_rel_path(path) {
11308            return Err(LinkError::UnsafePath {
11309                path: (*path).clone(),
11310            });
11311        }
11312        if previous == Some(path.as_str()) {
11313            return Err(LinkError::InvalidPack {
11314                message: format!("duplicate path `{path}`"),
11315            });
11316        }
11317        previous = Some(path.as_str());
11318        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
11319            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11320        })?;
11321    }
11322
11323    let mut out = Vec::new();
11324    let mut central = Vec::with_capacity(sorted.len());
11325    for (path, content) in sorted {
11326        let name = path.as_bytes();
11327        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
11328            message: format!("ZIP entry name is too long: `{path}`"),
11329        })?;
11330        let bytes = content.as_bytes();
11331        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
11332            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11333        })?;
11334        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11335            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11336        })?;
11337        let crc32 = crc32fast::hash(bytes);
11338
11339        // Canonical local header: sizes and CRC are known up front. Bit 3 is
11340        // deliberately clear, so there is no trailing data descriptor.
11341        push_u32(&mut out, LOCAL_HEADER);
11342        push_u16(&mut out, VERSION_20);
11343        push_u16(&mut out, UTF8_FLAG);
11344        push_u16(&mut out, STORED);
11345        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11346        push_u16(&mut out, DOS_DATE_1980_01_01);
11347        push_u32(&mut out, crc32);
11348        push_u32(&mut out, size);
11349        push_u32(&mut out, size);
11350        push_u16(&mut out, name_len);
11351        push_u16(&mut out, 0); // no local extra data
11352        out.extend_from_slice(name);
11353        out.extend_from_slice(bytes);
11354
11355        central.push(CentralEntry {
11356            name,
11357            crc32,
11358            size,
11359            local_offset,
11360        });
11361    }
11362
11363    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11364        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11365    })?;
11366    for entry in &central {
11367        push_u32(&mut out, CENTRAL_HEADER);
11368        push_u16(&mut out, MADE_BY_UNIX_20);
11369        push_u16(&mut out, VERSION_20);
11370        push_u16(&mut out, UTF8_FLAG);
11371        push_u16(&mut out, STORED);
11372        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11373        push_u16(&mut out, DOS_DATE_1980_01_01);
11374        push_u32(&mut out, entry.crc32);
11375        push_u32(&mut out, entry.size);
11376        push_u32(&mut out, entry.size);
11377        push_u16(&mut out, entry.name.len() as u16);
11378        push_u16(&mut out, 0); // no central extra data
11379        push_u16(&mut out, 0); // no file comment
11380        push_u16(&mut out, 0); // disk number
11381        push_u16(&mut out, 0); // internal attributes
11382        push_u32(&mut out, UNIX_REGULAR_0600);
11383        push_u32(&mut out, entry.local_offset);
11384        out.extend_from_slice(entry.name);
11385    }
11386    let central_size = u32::try_from(out.len())
11387        .ok()
11388        .and_then(|end| end.checked_sub(central_offset))
11389        .ok_or_else(|| LinkError::PushTooLarge {
11390            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11391        })?;
11392    let entry_count = central.len() as u16;
11393
11394    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11395    push_u16(&mut out, 0); // this disk
11396    push_u16(&mut out, 0); // central directory disk
11397    push_u16(&mut out, entry_count);
11398    push_u16(&mut out, entry_count);
11399    push_u32(&mut out, central_size);
11400    push_u32(&mut out, central_offset);
11401    push_u16(&mut out, 0); // no archive comment
11402
11403    if out.len() > u32::MAX as usize {
11404        return Err(LinkError::PushTooLarge {
11405            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11406        });
11407    }
11408    Ok(out)
11409}
11410
11411// ─────────────────────────────────────────────────────────────────────────────
11412// grant — issue / list / revoke capabilities (owner-side)
11413// ─────────────────────────────────────────────────────────────────────────────
11414
11415/// The two capabilities a v0 hub enforces.
11416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11417pub enum Capability {
11418    /// Read the granted slice.
11419    Read,
11420    /// Read and push (whole-store; a path-scoped grant is read-only).
11421    Write,
11422}
11423
11424impl Capability {
11425    /// The wire form.
11426    pub fn as_str(self) -> &'static str {
11427        match self {
11428            Capability::Read => "read",
11429            Capability::Write => "write",
11430        }
11431    }
11432}
11433
11434/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
11435/// by email in v0 (the protocol's near-term simplification; key-named
11436/// grantees arrive with the signing layer). `scope` is a store-path prefix
11437/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
11438/// revoked.
11439pub fn grant_issue(
11440    cfg: &HubConfig,
11441    brain: &str,
11442    grantee: &str,
11443    can: Capability,
11444    scope: Option<&str>,
11445    until: Option<&str>,
11446) -> LinkResult<Value> {
11447    require_safe_ref(brain)?;
11448    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
11449    // multikey holder (link.md §6 cross-party keys — no hub account; the
11450    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
11451    // hub principal named by email.
11452    let is_key_grantee = URL_SAFE_NO_PAD
11453        .decode(grantee)
11454        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11455        .unwrap_or(false);
11456    if let Some(head) = v2_verified_head(cfg, brain)? {
11457        if is_key_grantee {
11458            let scope = scope.unwrap_or("");
11459            let preset = match can {
11460                Capability::Read => "viewer",
11461                Capability::Write => "editor",
11462            };
11463            let entropy = format!(
11464                "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11465                normalized_origin(&cfg.hub)?,
11466                head.brain_id,
11467                head.control_revision,
11468                grantee,
11469                preset,
11470                scope,
11471                until.unwrap_or("")
11472            );
11473            let mut body = json!({
11474                "context": "external",
11475                "expected_control_revision": head.control_revision,
11476                "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11477                "preset": preset,
11478                "principal_kind": "key",
11479                "public_key": grantee,
11480                "scope": scope,
11481                "scope_kind": "prefix",
11482            });
11483            if let Some(value) = until {
11484                body["expires_at"] = json!(value);
11485            }
11486            let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11487            let response = ensure_ok(
11488                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11489                "v2 grant issue",
11490            )?;
11491            let expected_fingerprint = identity_fingerprint(grantee)?;
11492            if response.get("v").and_then(Value::as_u64) != Some(2)
11493                || response
11494                    .get("id")
11495                    .and_then(Value::as_str)
11496                    .is_none_or(|id| !crate::ulid::is_ulid(id))
11497                || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11498                || response.get("principal_id").and_then(Value::as_str)
11499                    != Some(expected_fingerprint.as_str())
11500                || response
11501                    .get("control_revision")
11502                    .and_then(Value::as_str)
11503                    .is_none_or(|value| !is_sha256(value))
11504            {
11505                return Err(invalid_feed(
11506                    "v2 grant issue response is not authority-bound",
11507                ));
11508            }
11509            return Ok(response);
11510        }
11511        // Account invitations remain a product workflow. The compatibility
11512        // endpoint resolves email/account identity, then commits a native v2
11513        // grant with the same control-plane recovery boundary. The v2 head
11514        // above is already the authenticated authority checkpoint; never
11515        // fall through to the retired v1 feed verifier for this request.
11516        let mut body = json!({ "email": grantee, "capability": can.as_str() });
11517        if let Some(value) = scope {
11518            body["scopePrefix"] = json!(value);
11519        }
11520        if let Some(value) = until {
11521            body["expiresAt"] = json!(value);
11522        }
11523        let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11524        return ensure_ok(
11525            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11526            "account grant issue",
11527        );
11528    }
11529    let _ = verified_remote_head(cfg, brain, false)?;
11530    let mut body = if is_key_grantee {
11531        json!({ "keySpki": grantee, "capability": can.as_str() })
11532    } else {
11533        json!({ "email": grantee, "capability": can.as_str() })
11534    };
11535    if let Some(s) = scope {
11536        body["scopePrefix"] = json!(s);
11537    }
11538    if let Some(u) = until {
11539        body["expiresAt"] = json!(u);
11540    }
11541    let path = format!("/api/hub/brains/{brain}/grants");
11542    ensure_ok(
11543        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11544        "grant issue",
11545    )
11546}
11547
11548/// List the active grants (and pending invites) on `brain`. Owner-side.
11549pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11550    require_safe_ref(brain)?;
11551    if let Some(head) = v2_verified_head(cfg, brain)? {
11552        let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11553        let response = ensure_ok(
11554            request(cfg, "GET", &path, None, Auth::Required)?,
11555            "v2 grant list",
11556        )?;
11557        if response.get("v").and_then(Value::as_u64) != Some(2)
11558            || response.get("control_revision").and_then(Value::as_str)
11559                != Some(head.control_revision.as_str())
11560            || !response.get("grants").is_some_and(Value::is_array)
11561        {
11562            return Err(invalid_feed(
11563                "v2 grant list is not bound to the verified authority",
11564            ));
11565        }
11566        return Ok(response);
11567    }
11568    let _ = verified_remote_head(cfg, brain, false)?;
11569    let path = format!("/api/hub/brains/{brain}/grants");
11570    ensure_ok(
11571        request(cfg, "GET", &path, None, Auth::Required)?,
11572        "grant list",
11573    )
11574}
11575
11576/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
11577/// is soft on the hub (the audit trail survives).
11578pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11579    require_safe_ref(brain)?;
11580    require_safe_grant_id(grant_id)?;
11581    if let Some(head) = v2_verified_head(cfg, brain)? {
11582        let entropy = format!(
11583            "{}\0{}\0{}\0{}",
11584            normalized_origin(&cfg.hub)?,
11585            head.brain_id,
11586            head.control_revision,
11587            grant_id
11588        );
11589        let body = json!({
11590            "expected_control_revision": head.control_revision,
11591            "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11592        });
11593        let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11594        let response = ensure_ok(
11595            request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11596            "v2 grant revoke",
11597        )?;
11598        if response.get("v").and_then(Value::as_u64) != Some(2)
11599            || response.get("id").and_then(Value::as_str) != Some(grant_id)
11600            || response.get("revoked").and_then(Value::as_bool) != Some(true)
11601            || response
11602                .get("control_revision")
11603                .and_then(Value::as_str)
11604                .is_none_or(|value| !is_sha256(value))
11605        {
11606            return Err(invalid_feed(
11607                "v2 grant revocation response is not authority-bound",
11608            ));
11609        }
11610        return Ok(response);
11611    }
11612    let _ = verified_remote_head(cfg, brain, false)?;
11613    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11614    ensure_ok(
11615        request(cfg, "DELETE", &path, None, Auth::Required)?,
11616        "grant revoke",
11617    )
11618}
11619
11620// ─────────────────────────────────────────────────────────────────────────────
11621// permissioned v2 proposals — inspect, accept, reject
11622// ─────────────────────────────────────────────────────────────────────────────
11623
11624#[derive(Debug)]
11625struct VerifiedV2Proposal {
11626    value: Value,
11627    changes: Value,
11628    blobs: Vec<(String, u64, String)>,
11629}
11630
11631fn require_proposal_id(id: &str) -> LinkResult<()> {
11632    if crate::ulid::is_ulid(id) {
11633        Ok(())
11634    } else {
11635        Err(invalid_feed("proposal id is not a lowercase ULID"))
11636    }
11637}
11638
11639fn verified_v2_proposal(
11640    cfg: &HubConfig,
11641    head: &V2VerifiedHead,
11642    proposal_id: &str,
11643) -> LinkResult<VerifiedV2Proposal> {
11644    require_proposal_id(proposal_id)?;
11645    if head.view_kind != "full" {
11646        return Err(invalid_feed(
11647            "proposal review requires a full readable view",
11648        ));
11649    }
11650    let path = format!(
11651        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11652        head.brain_id
11653    );
11654    let value = ensure_ok(
11655        request_capped(
11656            cfg,
11657            "GET",
11658            &path,
11659            None,
11660            Auth::Required,
11661            MAX_FEED_RESPONSE_BYTES,
11662        )?,
11663        "v2 proposal",
11664    )?;
11665    verify_v2_proposal_value(head, proposal_id, value)
11666}
11667
11668fn verify_v2_proposal_value(
11669    head: &V2VerifiedHead,
11670    proposal_id: &str,
11671    value: Value,
11672) -> LinkResult<VerifiedV2Proposal> {
11673    if value.get("v").and_then(Value::as_u64) != Some(2) {
11674        return Err(invalid_feed("proposal response has an invalid version"));
11675    }
11676    let proposal = value
11677        .get("proposal")
11678        .and_then(Value::as_object)
11679        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11680    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11681        return Err(invalid_feed("proposal response changed its id"));
11682    }
11683    let payload_hash = proposal
11684        .get("payload_sha256")
11685        .and_then(Value::as_str)
11686        .filter(|hash| is_sha256(hash))
11687        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11688    let clear_hash = proposal
11689        .get("clear_sha256")
11690        .and_then(Value::as_str)
11691        .filter(|hash| is_sha256(hash))
11692        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11693    let submission_hash = proposal
11694        .get("submission_claim_sha256")
11695        .and_then(Value::as_str)
11696        .filter(|hash| is_sha256(hash))
11697        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11698    let submission = STANDARD
11699        .decode(
11700            proposal
11701                .get("submission_claim_base64")
11702                .and_then(Value::as_str)
11703                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11704        )
11705        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11706    let submission_value: Value = serde_json::from_slice(&submission)
11707        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11708    if crate::linkmd_v2::canonical_bytes(&submission_value)
11709        .map_err(|error| invalid_feed(error.to_string()))?
11710        != submission
11711        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11712            .map_err(|error| invalid_feed(error.to_string()))?
11713            != submission_hash
11714    {
11715        return Err(invalid_feed(
11716            "proposal submission claim is not canonical or addressed",
11717        ));
11718    }
11719    let envelope = submission_value
11720        .as_object()
11721        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11722    let claim = envelope
11723        .get("claim")
11724        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11725    let claim_object = claim
11726        .as_object()
11727        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11728    let actor_root = claim_object
11729        .get("actor_root")
11730        .and_then(Value::as_object)
11731        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11732    let public_key = envelope
11733        .get("public_key")
11734        .and_then(Value::as_str)
11735        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11736    let fingerprint = envelope
11737        .get("fingerprint")
11738        .and_then(Value::as_str)
11739        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11740    let signature = envelope
11741        .get("sig")
11742        .and_then(Value::as_str)
11743        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11744    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11745        .map_err(|error| invalid_feed(error.to_string()))?;
11746    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11747    let signer = format!("{fingerprint}:{public_key}");
11748    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11749    let grants = actor_root.get("grants").and_then(Value::as_array);
11750    let grants_are_canonical = grants.is_some_and(|items| {
11751        let mut prior: Option<&str> = None;
11752        items.iter().all(|item| {
11753            let Some(grant) = item.as_str() else {
11754                return false;
11755            };
11756            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11757                return false;
11758            }
11759            prior = Some(grant);
11760            true
11761        })
11762    });
11763    let optional_actor_field = |name: &str| {
11764        actor_root.get(name).is_some_and(|value| {
11765            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11766        })
11767    };
11768    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11769    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11770        || format!("{:x}", Sha256::digest(&der)) != fingerprint
11771        || head
11772            .trust
11773            .hub_signer
11774            .as_ref()
11775            .is_some_and(|known| known != &signer)
11776        || !matches!(
11777            actor_class,
11778            Some(
11779                "user"
11780                    | "owned_agent"
11781                    | "foreign_key"
11782                    | "curation"
11783                    | "inbox"
11784                    | "restore"
11785                    | "migration"
11786                    | "operator_recovery"
11787            )
11788        )
11789        || actor_root
11790            .get("principal")
11791            .and_then(Value::as_str)
11792            .is_none_or(|value| value.is_empty())
11793        || actor_root
11794            .get("credential")
11795            .and_then(Value::as_str)
11796            .is_none_or(|value| value.is_empty())
11797        || !optional_actor_field("organization")
11798        || !optional_actor_field("role")
11799        || !grants_are_canonical
11800        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11801        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11802        || !claim_object
11803            .get("mutation_id")
11804            .and_then(Value::as_str)
11805            .is_some_and(|value| {
11806                !value.is_empty()
11807                    && value.len() <= 128
11808                    && value.chars().enumerate().all(|(index, char)| {
11809                        char.is_ascii_alphanumeric()
11810                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11811                    })
11812            })
11813        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11814        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11815        || !claim_object
11816            .get("control_revision")
11817            .and_then(Value::as_str)
11818            .is_some_and(is_sha256)
11819        || submitted_at.is_none_or(|value| {
11820            chrono::DateTime::parse_from_rfc3339(value).is_err()
11821                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11822        })
11823        || !proposal
11824            .get("state")
11825            .and_then(Value::as_str)
11826            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11827        || proposal
11828            .get("expires_at")
11829            .and_then(Value::as_str)
11830            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11831        || proposal
11832            .get("proposer")
11833            .and_then(Value::as_object)
11834            .and_then(|value| value.get("class"))
11835            .and_then(Value::as_str)
11836            != actor_class
11837    {
11838        return Err(invalid_feed(
11839            "proposal submission claim does not bind the verified proposal",
11840        ));
11841    }
11842    let changes_b64 = proposal
11843        .get("changes_base64")
11844        .and_then(Value::as_str)
11845        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11846    let changes_bytes = STANDARD
11847        .decode(changes_b64)
11848        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11849    let changes: Value = serde_json::from_slice(&changes_bytes)
11850        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11851    if crate::linkmd_v2::canonical_bytes(&changes)
11852        .map_err(|error| invalid_feed(error.to_string()))?
11853        != changes_bytes
11854        || changes.get("v").and_then(Value::as_u64) != Some(2)
11855        || !changes.get("operations").is_some_and(Value::is_array)
11856    {
11857        return Err(invalid_feed("proposal changeset is not canonical v2"));
11858    }
11859    let blob_values = proposal
11860        .get("blobs")
11861        .and_then(Value::as_array)
11862        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11863    let mut blobs = Vec::with_capacity(blob_values.len());
11864    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11865    let mut prior_hash: Option<String> = None;
11866    for item in blob_values {
11867        let hash = item
11868            .get("sha256")
11869            .and_then(Value::as_str)
11870            .filter(|hash| is_sha256(hash))
11871            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11872        let bytes = item
11873            .get("bytes")
11874            .and_then(Value::as_u64)
11875            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11876            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11877        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11878            return Err(invalid_feed(
11879                "proposal blob declarations are not unique and sorted",
11880            ));
11881        }
11882        prior_hash = Some(hash.to_string());
11883        let endpoint = item
11884            .get("endpoint")
11885            .and_then(Value::as_str)
11886            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11887        let expected_endpoint = format!(
11888            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11889            head.brain_id
11890        );
11891        if endpoint != expected_endpoint {
11892            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11893        }
11894        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11895        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11896    }
11897    let descriptor = json!({
11898        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11899        "blobs": descriptor_blobs,
11900        "changes_base64": changes_b64,
11901        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11902        "v": 2,
11903    });
11904    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11905        .map_err(|error| invalid_feed(error.to_string()))?;
11906    if content_sha256(&descriptor_bytes) != clear_hash {
11907        return Err(invalid_feed(
11908            "proposal clear payload differs from its signed submission claim",
11909        ));
11910    }
11911    Ok(VerifiedV2Proposal {
11912        value,
11913        changes,
11914        blobs,
11915    })
11916}
11917
11918pub fn proposal_list(
11919    cfg: &HubConfig,
11920    brain: &str,
11921    state: &str,
11922    after: Option<&str>,
11923    limit: usize,
11924) -> LinkResult<Value> {
11925    require_safe_ref(brain)?;
11926    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11927        return Err(invalid_feed("proposal state is invalid"));
11928    }
11929    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11930        return Err(invalid_feed("proposal cursor is invalid"));
11931    }
11932    let head = v2_verified_head(cfg, brain)?
11933        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11934    let path = format!(
11935        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11936        head.brain_id,
11937        limit.clamp(1, 100),
11938        after.map_or_else(String::new, |value| format!("&after={value}"))
11939    );
11940    ensure_ok(
11941        request_capped(
11942            cfg,
11943            "GET",
11944            &path,
11945            None,
11946            Auth::Required,
11947            MAX_FEED_RESPONSE_BYTES,
11948        )?,
11949        "v2 proposal list",
11950    )
11951}
11952
11953pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11954    require_safe_ref(brain)?;
11955    let head = v2_verified_head(cfg, brain)?
11956        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11957    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11958}
11959
11960pub fn proposal_reject(
11961    cfg: &HubConfig,
11962    brain: &str,
11963    proposal_id: &str,
11964    mutation_id: &str,
11965    reason: &str,
11966) -> LinkResult<Value> {
11967    require_safe_ref(brain)?;
11968    require_proposal_id(proposal_id)?;
11969    let head = v2_verified_head(cfg, brain)?
11970        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11971    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11972    let body = json!({
11973        "mutation_id": mutation_id,
11974        "control_revision": head.control_revision,
11975        "reason": reason,
11976    });
11977    let path = format!(
11978        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11979        head.brain_id
11980    );
11981    ensure_ok(
11982        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11983        "v2 proposal rejection",
11984    )
11985}
11986
11987pub fn proposal_accept_exact(
11988    cfg: &HubConfig,
11989    brain: &str,
11990    proposal_id: &str,
11991    mutation_id: &str,
11992    reason: &str,
11993) -> LinkResult<Value> {
11994    require_safe_ref(brain)?;
11995    require_proposal_id(proposal_id)?;
11996    let head = v2_verified_head(cfg, brain)?
11997        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11998    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11999    let operations = proposal
12000        .changes
12001        .get("operations")
12002        .and_then(Value::as_array)
12003        .cloned()
12004        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
12005    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
12006        return Err(invalid_feed("proposal operation count is invalid"));
12007    }
12008    let mut downloaded = std::collections::BTreeMap::new();
12009    for (hash, bytes, endpoint) in &proposal.blobs {
12010        let body = ensure_raw_ok(
12011            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
12012            "v2 proposal blob",
12013        )?;
12014        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
12015            return Err(invalid_feed("proposal blob does not match its declaration"));
12016        }
12017        downloaded.insert(hash.clone(), body);
12018    }
12019    let remote = files_for_v2_view(
12020        &head,
12021        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
12022    );
12023    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
12024    let mut expected_candidate = remote.clone();
12025    let mut expected_candidate_assets = remote_assets;
12026    for operation in &operations {
12027        let op = operation
12028            .get("op")
12029            .and_then(Value::as_str)
12030            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
12031        match op {
12032            "put" | "restore" => {
12033                let path = operation
12034                    .get("path")
12035                    .and_then(Value::as_str)
12036                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
12037                crate::linkmd_v2::normalize_path(path)
12038                    .map_err(|error| invalid_feed(error.to_string()))?;
12039                let hash = operation
12040                    .get("blob")
12041                    .and_then(Value::as_str)
12042                    .filter(|hash| is_sha256(hash))
12043                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12044                let bytes = operation
12045                    .get("bytes")
12046                    .and_then(Value::as_u64)
12047                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12048                expected_candidate.insert(
12049                    path.to_string(),
12050                    V2BaselineFile {
12051                        sha256: hash.to_string(),
12052                        bytes,
12053                        proof: None,
12054                    },
12055                );
12056            }
12057            "delete" | "withdraw_from_hosting" => {
12058                let path = operation
12059                    .get("path")
12060                    .and_then(Value::as_str)
12061                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12062                crate::linkmd_v2::normalize_path(path)
12063                    .map_err(|error| invalid_feed(error.to_string()))?;
12064                expected_candidate.remove(path);
12065            }
12066            "rename" => {
12067                let from = operation
12068                    .get("from")
12069                    .and_then(Value::as_str)
12070                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12071                let to = operation
12072                    .get("to")
12073                    .and_then(Value::as_str)
12074                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12075                crate::linkmd_v2::normalize_path(from)
12076                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
12077                    .map_err(|error| invalid_feed(error.to_string()))?;
12078                let hash = operation
12079                    .get("blob")
12080                    .and_then(Value::as_str)
12081                    .filter(|hash| is_sha256(hash))
12082                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12083                let bytes = operation
12084                    .get("bytes")
12085                    .and_then(Value::as_u64)
12086                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12087                expected_candidate.remove(from);
12088                expected_candidate.insert(
12089                    to.to_string(),
12090                    V2BaselineFile {
12091                        sha256: hash.to_string(),
12092                        bytes,
12093                        proof: None,
12094                    },
12095                );
12096            }
12097            "asset_delete" => {
12098                let path = operation
12099                    .get("path")
12100                    .and_then(Value::as_str)
12101                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12102                expected_candidate_assets.remove(path);
12103            }
12104            "asset_withdraw" => {
12105                let path = operation
12106                    .get("path")
12107                    .and_then(Value::as_str)
12108                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12109                let asset = expected_candidate_assets
12110                    .get_mut(path)
12111                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
12112                asset.disposition = "withheld".to_string();
12113                asset.leaf_hash.clear();
12114            }
12115            "asset_put" | "asset_resume" => {
12116                let path = operation
12117                    .get("path")
12118                    .and_then(Value::as_str)
12119                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12120                let asset = operation
12121                    .get("asset")
12122                    .and_then(Value::as_object)
12123                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12124                let blob_sha256 = asset
12125                    .get("blob_sha256")
12126                    .and_then(Value::as_str)
12127                    .filter(|hash| is_sha256(hash))
12128                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12129                let bytes = asset
12130                    .get("bytes")
12131                    .and_then(Value::as_u64)
12132                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12133                let media_type = asset
12134                    .get("media_type")
12135                    .and_then(Value::as_str)
12136                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12137                let wrappers = asset
12138                    .get("wrappers")
12139                    .and_then(Value::as_array)
12140                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12141                    .iter()
12142                    .map(|wrapper| {
12143                        wrapper
12144                            .as_str()
12145                            .map(str::to_string)
12146                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12147                    })
12148                    .collect::<LinkResult<Vec<_>>>()?;
12149                let required = asset
12150                    .get("required")
12151                    .and_then(Value::as_bool)
12152                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12153                let disposition = asset
12154                    .get("disposition")
12155                    .and_then(Value::as_str)
12156                    .filter(|value| matches!(*value, "hosted" | "withheld"))
12157                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
12158                expected_candidate_assets.insert(
12159                    path.to_string(),
12160                    V2BaselineAsset {
12161                        blob_sha256: blob_sha256.to_string(),
12162                        bytes,
12163                        media_type: media_type.to_string(),
12164                        wrappers,
12165                        required,
12166                        disposition: disposition.to_string(),
12167                        leaf_hash: String::new(),
12168                    },
12169                );
12170            }
12171            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
12172        }
12173    }
12174    let base = head.pointer.as_ref().map(|pointer| {
12175        json!({
12176            "seq": pointer.seq,
12177            "commit_hash": pointer.commit_hash,
12178            "content_root": pointer.content_root,
12179            "asset_root": pointer.asset_root,
12180        })
12181    });
12182    let mut body = json!({
12183        "mutation_id": mutation_id,
12184        "base": base,
12185        "rebase": "strict",
12186        "reason": reason,
12187        "operations": operations,
12188        "blobs": downloaded
12189            .iter()
12190            .map(|(sha256, bytes)| json!({
12191                "sha256": sha256,
12192                "bytes": bytes.len(),
12193                "content_base64": STANDARD.encode(bytes),
12194            }))
12195            .collect::<Vec<_>>(),
12196        "proposal_id": proposal_id,
12197        "proposal_mode": "exact",
12198    });
12199    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
12200        total
12201            .checked_add(bytes.len())
12202            .ok_or_else(|| LinkError::PushTooLarge {
12203                detail: "proposal changed-byte total overflow".to_string(),
12204            })
12205    })?;
12206    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
12207        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12208        for operation in &operations {
12209            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
12210                return Err(invalid_feed("proposal upload operation has no kind"));
12211            };
12212            let hash = match kind {
12213                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
12214                "asset_put" | "asset_resume" => operation
12215                    .get("asset")
12216                    .and_then(|asset| asset.get("blob_sha256"))
12217                    .and_then(Value::as_str),
12218                _ => None,
12219            };
12220            let Some(hash) = hash else { continue };
12221            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
12222            if kind == "rename" {
12223                for field in ["from", "to"] {
12224                    coordinates.insert(
12225                        operation
12226                            .get(field)
12227                            .and_then(Value::as_str)
12228                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
12229                            .to_string(),
12230                    );
12231                }
12232            } else {
12233                let path = operation
12234                    .get("path")
12235                    .and_then(Value::as_str)
12236                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
12237                coordinates.insert(if kind.starts_with("asset_") {
12238                    format!("assets/{path}")
12239                } else {
12240                    path.to_string()
12241                });
12242            }
12243        }
12244        let declarations = downloaded
12245            .iter()
12246            .map(|(sha256, bytes)| {
12247                json!({
12248                    "sha256": sha256,
12249                    "bytes": bytes.len(),
12250                    "coordinates": coordinates_by_hash
12251                        .get(sha256)
12252                        .into_iter()
12253                        .flatten()
12254                        .collect::<Vec<_>>(),
12255                })
12256            })
12257            .collect::<Vec<_>>();
12258        let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
12259        for batch in batch_upload_declarations(declarations) {
12260            let reserved = reserve_upload_window(
12261                cfg,
12262                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
12263                &json!({ "blobs": batch }),
12264                "prepare proposal blob transport",
12265            )?;
12266            let reserved_items = reserved
12267                .get("uploads")
12268                .and_then(Value::as_array)
12269                .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
12270            items.extend(reserved_items.iter().cloned());
12271        }
12272        if items.len() != downloaded.len() {
12273            return Err(invalid_feed("proposal upload reservation changed the set"));
12274        }
12275        let mut references = Vec::with_capacity(items.len());
12276        for item in items {
12277            let hash = item
12278                .get("sha256")
12279                .and_then(Value::as_str)
12280                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
12281            let bytes = downloaded
12282                .get(hash)
12283                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
12284            let reservation_id = item
12285                .get("reservation_id")
12286                .and_then(Value::as_str)
12287                .filter(|id| crate::ulid::is_ulid(id))
12288                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
12289            let expected_coordinates = coordinates_by_hash
12290                .get(hash)
12291                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
12292            let returned_coordinates = item
12293                .get("coordinates")
12294                .and_then(Value::as_array)
12295                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
12296            if returned_coordinates.len() != expected_coordinates.len()
12297                || returned_coordinates
12298                    .iter()
12299                    .zip(expected_coordinates)
12300                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
12301            {
12302                return Err(invalid_feed(
12303                    "proposal upload reservation changed its coordinates",
12304                ));
12305            }
12306            match item.get("status").and_then(Value::as_str) {
12307                Some("upload") => put_presigned(
12308                    cfg,
12309                    item.get("url")
12310                        .and_then(Value::as_str)
12311                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
12312                    item.get("headers").unwrap_or(&Value::Null),
12313                    bytes,
12314                )?,
12315                Some("already_present") => {}
12316                _ => return Err(invalid_feed("proposal upload status is invalid")),
12317            }
12318            references.push(json!({
12319                "sha256": hash,
12320                "bytes": bytes.len(),
12321                "reservation_id": reservation_id,
12322            }));
12323        }
12324        body["blobs"] = Value::Array(references);
12325    }
12326    // A proposal can be queued through the staged lane, so acceptance has to be
12327    // able to restate it. Capping only this side would make a large proposal
12328    // permanently unacceptable.
12329    stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
12330    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
12331    let mut result = ensure_ok(
12332        request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
12333        "exact proposal acceptance",
12334    )?;
12335    let mut candidate_hub_signer = None;
12336    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12337        let request_id = result
12338            .get("request_id")
12339            .and_then(Value::as_str)
12340            .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12341            .to_string();
12342        let challenge = result
12343            .get("signing_challenge")
12344            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12345        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12346            cfg,
12347            &head,
12348            &expected_candidate,
12349            &expected_candidate_assets,
12350            mutation_id,
12351            &v2_signed_request_view(&body, &operations),
12352            challenge,
12353        )?;
12354        body["signing_challenge_id"] = Value::String(challenge_id);
12355        body["signature_base64url"] = Value::String(signature);
12356        candidate_hub_signer = Some(actor_signer);
12357        result = ensure_ok(
12358            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12359            "signed exact proposal acceptance",
12360        )?;
12361    }
12362    let refreshed = v2_verified_head(cfg, brain)?
12363        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12364    if candidate_hub_signer
12365        .as_ref()
12366        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12367        || refreshed
12368            .pointer
12369            .as_ref()
12370            .map(|pointer| pointer.commit_hash.as_str())
12371            != result.get("commit_hash").and_then(Value::as_str)
12372        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12373        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12374    {
12375        return Err(LinkError::RemoteAdvancedDuringSync);
12376    }
12377    accept_v2_head(cfg, &refreshed)?;
12378    Ok(result)
12379}
12380
12381// ─────────────────────────────────────────────────────────────────────────────
12382// propose — write without trust: evidence into the owner's inbox
12383// ─────────────────────────────────────────────────────────────────────────────
12384
12385/// Submit `body` to the published site `handle`, addressed to its app page
12386/// `app` (a page that declares the `write-inbox` capability). Deliberately
12387/// unauthenticated — this is the cross-party door; the submission lands as
12388/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
12389/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
12390/// receipt.
12391pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12392    require_valid_handle(handle)?;
12393    if body.len() as u64 > MAX_PROPOSE_BYTES {
12394        return Err(LinkError::ProposeTooLarge {
12395            bytes: body.len() as u64,
12396        });
12397    }
12398    let payload = json!({ "app": app, "body": body });
12399    // A ULID-shaped target is a bare brain address (link.md §7.4's
12400    // generalization): the brain inbox door, open on public brains, where a
12401    // configured credential earns a bigger actor-class budget. Anything else
12402    // is a published-site handle: that door is unauthenticated by design.
12403    let (path, auth) = if crate::ulid::is_ulid(handle) {
12404        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12405    } else {
12406        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12407    };
12408    ensure_ok(
12409        request(cfg, "POST", &path, Some(&payload), auth)?,
12410        "propose",
12411    )
12412}
12413
12414// ─────────────────────────────────────────────────────────────────────────────
12415// subscribe — follow feed-head movement
12416// ─────────────────────────────────────────────────────────────────────────────
12417
12418/// One observation of a brain's feed head.
12419#[derive(Debug, serde::Serialize)]
12420pub struct Head {
12421    /// The brain id.
12422    pub brain: String,
12423    /// The hub's durable feed cursor — advances on every accepted write.
12424    pub seq: u64,
12425    /// The hub's `updatedAt` for the brain, when present.
12426    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12427    pub updated_at: Option<String>,
12428    /// SHA-256 of the exact signed head entry.
12429    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12430    pub feed_hash: Option<String>,
12431    /// Whether the head entry's content hash, identity, and Ed25519 signature
12432    /// were verified locally. Path-scoped grants get head movement only.
12433    pub verified: bool,
12434}
12435
12436struct BoundedVecVisitor<T, const MAX: usize> {
12437    label: &'static str,
12438    marker: std::marker::PhantomData<T>,
12439}
12440
12441impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12442where
12443    T: Deserialize<'de>,
12444{
12445    type Value = Vec<T>;
12446
12447    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12448        write!(formatter, "at most {MAX} {}", self.label)
12449    }
12450
12451    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12452    where
12453        A: serde::de::SeqAccess<'de>,
12454    {
12455        if sequence.size_hint().is_some_and(|size| size > MAX) {
12456            return Err(serde::de::Error::custom(format!(
12457                "{} exceeds the {MAX}-item limit",
12458                self.label
12459            )));
12460        }
12461        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12462        while let Some(value) = sequence.next_element()? {
12463            if values.len() == MAX {
12464                return Err(serde::de::Error::custom(format!(
12465                    "{} exceeds the {MAX}-item limit",
12466                    self.label
12467                )));
12468            }
12469            values.push(value);
12470        }
12471        Ok(values)
12472    }
12473}
12474
12475fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12476    deserializer: D,
12477    label: &'static str,
12478) -> Result<Vec<T>, D::Error>
12479where
12480    D: serde::Deserializer<'de>,
12481    T: Deserialize<'de>,
12482{
12483    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12484        label,
12485        marker: std::marker::PhantomData,
12486    })
12487}
12488
12489fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12490where
12491    D: serde::Deserializer<'de>,
12492{
12493    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12494}
12495
12496fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12497where
12498    D: serde::Deserializer<'de>,
12499{
12500    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12501}
12502
12503fn deserialize_previous_identities<'de, D>(
12504    deserializer: D,
12505) -> Result<Vec<PreviousIdentity>, D::Error>
12506where
12507    D: serde::Deserializer<'de>,
12508{
12509    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12510        deserializer,
12511        "previous identities",
12512    )
12513}
12514
12515fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12516where
12517    D: serde::Deserializer<'de>,
12518{
12519    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12520        deserializer,
12521        "rotation statements",
12522    )
12523}
12524
12525fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12526where
12527    D: serde::Deserializer<'de>,
12528{
12529    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12530}
12531
12532#[derive(Debug, Clone, Deserialize, Serialize)]
12533struct FeedFile {
12534    path: String,
12535    sha256: String,
12536    bytes: u64,
12537}
12538
12539#[cfg(test)]
12540#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12541enum V1DisclosureError {
12542    DuplicateFile,
12543    DuplicateRemoved,
12544    PushManifestMismatch,
12545    EditMissingChange,
12546    EditFalseFile,
12547    RemovedMismatch,
12548}
12549
12550/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
12551/// the exact previous and resulting pack manifests. `edit` accepts the
12552/// historical superset form while still requiring every actual changed path.
12553#[cfg(test)]
12554fn verify_v1_manifest_disclosure(
12555    kind: &str,
12556    previous: &[FeedFile],
12557    resulting: &[FeedFile],
12558    files: &[FeedFile],
12559    removed: &[String],
12560) -> Result<(), V1DisclosureError> {
12561    fn as_map(
12562        files: &[FeedFile],
12563    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12564        let mut result = std::collections::BTreeMap::new();
12565        for file in files {
12566            if result
12567                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12568                .is_some()
12569            {
12570                return Err(V1DisclosureError::DuplicateFile);
12571            }
12572        }
12573        Ok(result)
12574    }
12575    let previous = as_map(previous)?;
12576    let resulting = as_map(resulting)?;
12577    let disclosed = as_map(files)?;
12578    let removed_set: std::collections::BTreeSet<&str> =
12579        removed.iter().map(String::as_str).collect();
12580    if removed_set.len() != removed.len() {
12581        return Err(V1DisclosureError::DuplicateRemoved);
12582    }
12583    let expected_removed: std::collections::BTreeSet<&str> = previous
12584        .keys()
12585        .copied()
12586        .filter(|path| !resulting.contains_key(path))
12587        .collect();
12588    if removed_set != expected_removed {
12589        return Err(V1DisclosureError::RemovedMismatch);
12590    }
12591    if kind == "push" {
12592        return if disclosed == resulting {
12593            Ok(())
12594        } else {
12595            Err(V1DisclosureError::PushManifestMismatch)
12596        };
12597    }
12598    if kind != "edit" {
12599        return Err(V1DisclosureError::EditFalseFile);
12600    }
12601    if disclosed
12602        .iter()
12603        .any(|(path, value)| resulting.get(path) != Some(value))
12604    {
12605        return Err(V1DisclosureError::EditFalseFile);
12606    }
12607    for (path, value) in &resulting {
12608        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12609            return Err(V1DisclosureError::EditMissingChange);
12610        }
12611    }
12612    Ok(())
12613}
12614
12615#[derive(Debug, Clone, Deserialize, Serialize)]
12616struct FeedEntry {
12617    v: u8,
12618    seq: u64,
12619    ts: String,
12620    brain: String,
12621    public_key: String,
12622    kind: String,
12623    op: String,
12624    pack_sha256: String,
12625    #[serde(deserialize_with = "deserialize_feed_files")]
12626    files: Vec<FeedFile>,
12627    #[serde(deserialize_with = "deserialize_removed_paths")]
12628    removed: Vec<String>,
12629    prev_entry_hash: Option<String>,
12630    sig: String,
12631}
12632
12633#[derive(Serialize)]
12634struct UnsignedFeedEntry<'a> {
12635    v: u8,
12636    seq: u64,
12637    ts: &'a str,
12638    brain: &'a str,
12639    public_key: &'a str,
12640    kind: &'a str,
12641    op: &'a str,
12642    pack_sha256: &'a str,
12643    files: &'a [FeedFile],
12644    removed: &'a [String],
12645    prev_entry_hash: &'a Option<String>,
12646}
12647
12648#[derive(Debug, Clone, Deserialize, Serialize)]
12649struct FeedItem {
12650    hash: String,
12651    entry: FeedEntry,
12652}
12653
12654#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12655struct FeedIdentity {
12656    fingerprint: String,
12657    #[serde(rename = "publicKeySpki")]
12658    public_key_spki: String,
12659    /// Rotation history (link.md §9.1): identities this brain previously
12660    /// signed as. Entries verify against current OR previous — rotation
12661    /// never invalidates history.
12662    #[serde(default, deserialize_with = "deserialize_previous_identities")]
12663    previous: Vec<PreviousIdentity>,
12664    /// Exact normative rotation statements, oldest first. A list of previous
12665    /// public keys without these old-key signatures is not a trust chain.
12666    #[serde(default, deserialize_with = "deserialize_rotations")]
12667    rotations: Vec<String>,
12668}
12669
12670#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12671struct PreviousIdentity {
12672    fingerprint: String,
12673    #[serde(rename = "publicKeySpki")]
12674    public_key_spki: String,
12675}
12676
12677#[derive(Debug, Deserialize)]
12678struct FeedResponse {
12679    #[serde(rename = "headSeq")]
12680    head_seq: u64,
12681    #[serde(rename = "feedHash")]
12682    feed_hash: Option<String>,
12683    identity: Option<FeedIdentity>,
12684    #[serde(deserialize_with = "deserialize_feed_items")]
12685    entries: Vec<FeedItem>,
12686    #[serde(rename = "scopeLimited")]
12687    scope_limited: bool,
12688}
12689
12690#[derive(Debug, Deserialize, Serialize)]
12691#[serde(deny_unknown_fields)]
12692struct RotationStatement {
12693    v: u8,
12694    op: String,
12695    brain: String,
12696    public_key: String,
12697    new_brain: String,
12698    new_public_key: String,
12699    prior_head_seq: u64,
12700    prior_feed_hash: Option<String>,
12701    ts: String,
12702    sig: String,
12703}
12704
12705#[derive(Debug, Clone, Deserialize, Serialize)]
12706struct TrustState {
12707    v: u8,
12708    origin: String,
12709    /// The exact caller-visible ref whose resolution this checkpoint pins.
12710    /// Slugs/handles are mutable names at the hub; once observed, they may not
12711    /// silently resolve to a different canonical brain.
12712    #[serde(default)]
12713    requested: String,
12714    /// Canonical hub brain id returned for `requested`.
12715    brain: String,
12716    /// Federation home origin when this ref was learned from a registry.
12717    /// Once observed, the registry cannot silently relocate the same handle.
12718    #[serde(default, skip_serializing_if = "Option::is_none")]
12719    home: Option<String>,
12720    anchor: String,
12721    current: String,
12722    #[serde(rename = "headSeq")]
12723    head_seq: u64,
12724    #[serde(rename = "feedHash")]
12725    feed_hash: Option<String>,
12726    /// Exact accepted old-key-signed rotation statements, oldest first. v2
12727    /// checkpoints require this vector to be an immutable prefix of every
12728    /// subsequently served identity chain.
12729    #[serde(default)]
12730    rotations: Vec<String>,
12731    /// Hub pointer-signing identity pinned on first v2 observation. This is
12732    /// separate from the brain key, which signs commits.
12733    #[serde(default, skip_serializing_if = "Option::is_none")]
12734    hub_signer: Option<String>,
12735    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
12736    /// are recognized by their pinned hub signer during one-way migration.
12737    #[serde(default, skip_serializing_if = "Option::is_none")]
12738    protocol_profile: Option<String>,
12739}
12740
12741fn accepted_as_v2(state: &TrustState) -> bool {
12742    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12743}
12744
12745fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12746    let directory = open_trust_dir(cfg)?;
12747    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12748        return Ok(true);
12749    }
12750    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12751        return Ok(false);
12752    };
12753    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12754}
12755
12756#[derive(Debug, Clone, Deserialize, Serialize)]
12757struct AliasBinding {
12758    v: u8,
12759    origin: String,
12760    requested: String,
12761    brain: String,
12762    #[serde(default, skip_serializing_if = "Option::is_none")]
12763    home: Option<String>,
12764}
12765
12766struct VerifiedRemote {
12767    head: Head,
12768    identity: Option<FeedIdentity>,
12769    head_entry: Option<FeedItem>,
12770    /// Present when the caller requested and verified the complete chain.
12771    entries: Vec<FeedItem>,
12772    anchor: Option<String>,
12773}
12774
12775fn invalid_feed(message: impl Into<String>) -> LinkError {
12776    LinkError::InvalidFeed {
12777        message: message.into(),
12778    }
12779}
12780
12781fn is_sha256(value: &str) -> bool {
12782    value.len() == 64
12783        && value
12784            .bytes()
12785            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12786}
12787
12788fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12789    let der = URL_SAFE_NO_PAD
12790        .decode(public_key_spki)
12791        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12792    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12793        return Err(invalid_feed(
12794            "identity public key is not a valid Ed25519 SPKI",
12795        ));
12796    }
12797    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12798}
12799
12800/// Verify the hub's current identity and every old-key-signed rotation that
12801/// leads from the original TOFU anchor to it. `previous` alone is metadata,
12802/// never authority.
12803fn verify_identity_chain(
12804    identity: &FeedIdentity,
12805    pinned: Option<&TrustState>,
12806) -> LinkResult<String> {
12807    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12808        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12809    {
12810        return Err(invalid_feed(
12811            "identity rotation history exceeds the client cap",
12812        ));
12813    }
12814    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12815        return Err(invalid_feed(
12816            "current identity fingerprint does not match its public key",
12817        ));
12818    }
12819    for previous in &identity.previous {
12820        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12821            return Err(invalid_feed(
12822                "previous identity fingerprint does not match its public key",
12823            ));
12824        }
12825    }
12826    if identity.rotations.len() != identity.previous.len() {
12827        return Err(invalid_feed(
12828            "identity history is missing an old-key-signed rotation statement",
12829        ));
12830    }
12831
12832    // The HTTP identity lists prior identities newest first. Rotation
12833    // statements are chronological, so verification walks the reversed list
12834    // and ends at the current identity.
12835    let mut chain: Vec<(&str, &str)> = identity
12836        .previous
12837        .iter()
12838        .rev()
12839        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12840        .collect();
12841    chain.push((&identity.fingerprint, &identity.public_key_spki));
12842
12843    for (index, raw) in identity.rotations.iter().enumerate() {
12844        let statement: RotationStatement = serde_json::from_str(raw)
12845            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12846        let (old_fingerprint, old_spki) = chain[index];
12847        let (new_fingerprint, new_spki) = chain[index + 1];
12848        if statement.v != 1
12849            || statement.op != "rotate"
12850            || statement.brain != format!("ed25519:{old_fingerprint}")
12851            || statement.public_key != old_spki
12852            || statement.new_brain != format!("ed25519:{new_fingerprint}")
12853            || statement.new_public_key != new_spki
12854            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12855            || (statement.prior_head_seq > 0
12856                && statement
12857                    .prior_feed_hash
12858                    .as_deref()
12859                    .is_none_or(|hash| !is_sha256(hash)))
12860        {
12861            return Err(invalid_feed(
12862                "rotation statement does not connect adjacent identities",
12863            ));
12864        }
12865        let unsigned = serde_json::to_string(&UnsignedRotation {
12866            v: statement.v,
12867            op: &statement.op,
12868            brain: &statement.brain,
12869            public_key: &statement.public_key,
12870            new_brain: &statement.new_brain,
12871            new_public_key: &statement.new_public_key,
12872            prior_head_seq: statement.prior_head_seq,
12873            prior_feed_hash: statement.prior_feed_hash.as_deref(),
12874            ts: statement.ts.clone(),
12875        })
12876        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12877        let exact = format!(
12878            "{},\"sig\":\"{}\"}}",
12879            &unsigned[..unsigned.len() - 1],
12880            statement.sig
12881        );
12882        if exact != *raw {
12883            return Err(invalid_feed(
12884                "rotation statement is not in normative serialization",
12885            ));
12886        }
12887        let der = URL_SAFE_NO_PAD
12888            .decode(old_spki)
12889            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12890        let signature = URL_SAFE_NO_PAD
12891            .decode(&statement.sig)
12892            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12893        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12894            .verify(unsigned.as_bytes(), &signature)
12895            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12896        if index > 0 {
12897            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12898                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12899            if statement.prior_head_seq < prior.prior_head_seq {
12900                return Err(invalid_feed("rotation feed boundaries move backward"));
12901            }
12902        }
12903    }
12904
12905    let anchor = format!("ed25519:{}", chain[0].0);
12906    let current = format!("ed25519:{}", identity.fingerprint);
12907    if let Some(pin) = pinned {
12908        if pin.anchor != anchor {
12909            return Err(invalid_feed(
12910                "served identity chain does not descend from the pinned anchor",
12911            ));
12912        }
12913        if !chain
12914            .iter()
12915            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12916        {
12917            return Err(invalid_feed(
12918                "served identity chain forked away from the last pinned identity",
12919            ));
12920        }
12921        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12922            return Err(invalid_feed("served identity discarded its rotation chain"));
12923        }
12924        if pin.v >= 2
12925            && (identity.rotations.len() < pin.rotations.len()
12926                || identity.rotations[..pin.rotations.len()] != pin.rotations)
12927        {
12928            return Err(invalid_feed(
12929                "served identity rewrote the locally accepted rotation history",
12930            ));
12931        }
12932    }
12933    Ok(anchor)
12934}
12935
12936fn verify_rotation_feed_boundaries(
12937    identity: &FeedIdentity,
12938    pinned: Option<&TrustState>,
12939    observed: &[FeedItem],
12940    advertised_seq: u64,
12941) -> LinkResult<()> {
12942    let mut chain: Vec<String> = identity
12943        .previous
12944        .iter()
12945        .rev()
12946        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12947        .collect();
12948    chain.push(format!("ed25519:{}", identity.fingerprint));
12949    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12950
12951    for (index, raw) in identity.rotations.iter().enumerate() {
12952        let rotation: RotationStatement = serde_json::from_str(raw)
12953            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12954        if rotation.prior_head_seq > advertised_seq {
12955            return Err(invalid_feed(
12956                "rotation claims a feed boundary beyond the advertised head",
12957            ));
12958        }
12959        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12960            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12961                return Err(invalid_feed(
12962                    "newly disclosed rotation predates the local feed checkpoint",
12963                ));
12964            }
12965        }
12966        let actual = if rotation.prior_head_seq == 0 {
12967            None
12968        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12969            pinned.and_then(|pin| pin.feed_hash.as_deref())
12970        } else {
12971            observed
12972                .iter()
12973                .find(|item| item.entry.seq == rotation.prior_head_seq)
12974                .map(|item| item.hash.as_str())
12975        };
12976        if let Some(actual) = actual {
12977            if rotation.prior_feed_hash.as_deref() != Some(actual) {
12978                return Err(invalid_feed(
12979                    "rotation statement does not commit the verified feed boundary",
12980                ));
12981            }
12982        } else if rotation.prior_head_seq == 0 {
12983            // The empty-feed boundary is represented by (0, null); there is
12984            // no entry hash to find in `observed`.
12985        } else if pinned.is_some_and(|pin| {
12986            pinned_index.is_some_and(|pin_index| index >= pin_index)
12987                || rotation.prior_head_seq >= pin.head_seq
12988        }) {
12989            return Err(invalid_feed(
12990                "rotation feed boundary was not present in the verified chain",
12991            ));
12992        }
12993    }
12994    Ok(())
12995}
12996
12997/// Once a client has checkpointed identity K, identities retired before K may
12998/// verify old history but can never regain authority over a later sequence.
12999/// This is also the safe migration rule for legacy v1 checkpoints that did not
13000/// persist the exact accepted rotation statements.
13001fn reject_retired_signer_after_checkpoint(
13002    identity: &FeedIdentity,
13003    pinned: Option<&TrustState>,
13004    item: &FeedItem,
13005) -> LinkResult<()> {
13006    let Some(pin) = pinned else {
13007        return Ok(());
13008    };
13009    if item.entry.seq <= pin.head_seq {
13010        return Ok(());
13011    }
13012    let mut chain: Vec<String> = identity
13013        .previous
13014        .iter()
13015        .rev()
13016        .map(|previous| format!("ed25519:{}", previous.fingerprint))
13017        .collect();
13018    chain.push(format!("ed25519:{}", identity.fingerprint));
13019    let pinned_index = chain
13020        .iter()
13021        .position(|key| key == &pin.current)
13022        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
13023    let signer_index = chain
13024        .iter()
13025        .position(|key| key == &item.entry.brain)
13026        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
13027    if signer_index < pinned_index {
13028        return Err(invalid_feed(
13029            "a retired identity attempted to sign after the local checkpoint",
13030        ));
13031    }
13032    Ok(())
13033}
13034
13035fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
13036    let origin = normalized_origin(&cfg.hub)?;
13037    let key = format!(
13038        "{:x}",
13039        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
13040    );
13041    Ok(format!("{key}.json"))
13042}
13043
13044fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13045    let origin = normalized_origin(&cfg.hub)?;
13046    let key = format!(
13047        "{:x}",
13048        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13049    );
13050    Ok(format!("alias-{key}.json"))
13051}
13052
13053#[cfg(any(unix, windows))]
13054struct TrustLock {
13055    _file: std::fs::File,
13056}
13057
13058#[cfg(unix)]
13059fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13060    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13061
13062    let lock_string = format!(".{state_name}.lock");
13063    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13064    let fd = unsafe {
13065        libc::openat(
13066            directory.as_raw_fd(),
13067            lock_name.as_ptr(),
13068            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13069            0o600,
13070        )
13071    };
13072    if fd < 0 {
13073        return Err(std::io::Error::last_os_error().into());
13074    }
13075    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13076    if !file.metadata()?.is_file() {
13077        return Err(LinkError::UnsafePath { path: lock_string });
13078    }
13079    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13080        return Err(std::io::Error::last_os_error().into());
13081    }
13082    Ok(TrustLock { _file: file })
13083}
13084
13085#[cfg(windows)]
13086fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13087    let lock_name = format!(".{state_name}.lock");
13088    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13089    Ok(TrustLock { _file: file })
13090}
13091
13092#[cfg(any(unix, windows))]
13093fn lock_trust_many(
13094    cfg: &HubConfig,
13095    directory: &std::fs::File,
13096    refs: &[&str],
13097) -> LinkResult<Vec<TrustLock>> {
13098    let mut names = refs
13099        .iter()
13100        .map(|reference| trust_file_name(cfg, reference))
13101        .collect::<LinkResult<Vec<_>>>()?;
13102    names.sort();
13103    names.dedup();
13104    names
13105        .iter()
13106        .map(|name| lock_trust_name(directory, name))
13107        .collect()
13108}
13109
13110#[cfg(not(any(unix, windows)))]
13111fn lock_trust_many(
13112    _cfg: &HubConfig,
13113    _directory: &TrustDirectory,
13114    _refs: &[&str],
13115) -> LinkResult<Vec<()>> {
13116    Err(LinkError::UnsupportedPlatform {
13117        operation: "verified link.md state",
13118    })
13119}
13120
13121#[cfg(any(unix, windows))]
13122type TrustDirectory = std::fs::File;
13123
13124#[cfg(not(any(unix, windows)))]
13125struct TrustDirectory;
13126
13127#[cfg(unix)]
13128fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13129    use std::os::fd::AsRawFd as _;
13130
13131    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
13132    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
13133        return Err(std::io::Error::last_os_error().into());
13134    }
13135    directory.sync_all()?;
13136    Ok(directory)
13137}
13138
13139#[cfg(windows)]
13140fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13141    let marker = cfg.state_dir.join("trust").join(".directory");
13142    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
13143    Ok(crate::fsx::open_directory_nofollow(
13144        marker.parent().expect("trust marker has a parent"),
13145    )?)
13146}
13147
13148#[cfg(not(any(unix, windows)))]
13149fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13150    Err(LinkError::UnsupportedPlatform {
13151        operation: "verified link.md state",
13152    })
13153}
13154
13155#[cfg(unix)]
13156fn load_trust_in(
13157    cfg: &HubConfig,
13158    directory: &TrustDirectory,
13159    requested: &str,
13160) -> LinkResult<Option<TrustState>> {
13161    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13162
13163    let name_string = trust_file_name(cfg, requested)?;
13164    let name = c_name(name_string.as_bytes(), &name_string)?;
13165    let fd = unsafe {
13166        libc::openat(
13167            directory.as_raw_fd(),
13168            name.as_ptr(),
13169            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13170        )
13171    };
13172    if fd < 0 {
13173        let error = std::io::Error::last_os_error();
13174        if error.kind() == std::io::ErrorKind::NotFound {
13175            return Ok(None);
13176        }
13177        return Err(LinkError::UnsafePath { path: name_string });
13178    }
13179    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13180    if !file.metadata()?.is_file() {
13181        return Err(LinkError::UnsafePath { path: name_string });
13182    }
13183    let mut bytes = Vec::new();
13184    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
13185    if bytes.len() > 1024 * 1024 {
13186        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
13187    }
13188    let mut state: TrustState = serde_json::from_slice(&bytes)
13189        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13190    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13191        return Err(invalid_feed(
13192            "local identity/feed checkpoint does not match this hub and brain",
13193        ));
13194    }
13195    if state.v == 1 {
13196        // Legacy files were keyed by canonical brain id. They can migrate only
13197        // when the caller used that exact id; old slug lookups had no durable
13198        // alias binding and therefore cannot be guessed safely.
13199        if state.brain != requested {
13200            return Err(invalid_feed(
13201                "legacy checkpoint is not bound to the requested brain id",
13202            ));
13203        }
13204        state.requested = requested.to_string();
13205    } else if state.requested != requested {
13206        return Err(invalid_feed(
13207            "local identity/feed checkpoint is bound to a different requested ref",
13208        ));
13209    }
13210    Ok(Some(state))
13211}
13212
13213#[cfg(windows)]
13214fn load_trust_in(
13215    cfg: &HubConfig,
13216    directory: &TrustDirectory,
13217    requested: &str,
13218) -> LinkResult<Option<TrustState>> {
13219    let name = trust_file_name(cfg, requested)?;
13220    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13221    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
13222        Ok(bytes) => bytes,
13223        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13224        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13225    };
13226    let mut state: TrustState = serde_json::from_slice(&bytes)
13227        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13228    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13229        return Err(invalid_feed(
13230            "local identity/feed checkpoint does not match this hub and brain",
13231        ));
13232    }
13233    if state.v == 1 {
13234        if state.brain != requested {
13235            return Err(invalid_feed(
13236                "legacy checkpoint is not bound to the requested brain id",
13237            ));
13238        }
13239        state.requested = requested.to_string();
13240    } else if state.requested != requested {
13241        return Err(invalid_feed(
13242            "local identity/feed checkpoint is bound to a different requested ref",
13243        ));
13244    }
13245    Ok(Some(state))
13246}
13247
13248#[cfg(not(any(unix, windows)))]
13249fn load_trust_in(
13250    _cfg: &HubConfig,
13251    _directory: &TrustDirectory,
13252    _brain: &str,
13253) -> LinkResult<Option<TrustState>> {
13254    Err(LinkError::UnsupportedPlatform {
13255        operation: "verified link.md state",
13256    })
13257}
13258
13259#[cfg(all(test, any(unix, windows)))]
13260fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
13261    let directory = open_trust_dir(cfg)?;
13262    load_trust_in(cfg, &directory, requested)
13263}
13264
13265#[cfg(unix)]
13266fn save_trust_in(
13267    cfg: &HubConfig,
13268    directory: &TrustDirectory,
13269    state: &TrustState,
13270) -> LinkResult<()> {
13271    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13272
13273    let name_string = trust_file_name(cfg, &state.requested)?;
13274    let name = c_name(name_string.as_bytes(), &name_string)?;
13275    let mut bytes = serde_json::to_vec(state)
13276        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13277    bytes.push(b'\n');
13278
13279    let nonce = std::time::SystemTime::now()
13280        .duration_since(std::time::UNIX_EPOCH)
13281        .unwrap_or_default()
13282        .as_nanos();
13283    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13284    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13285    let fd = unsafe {
13286        libc::openat(
13287            directory.as_raw_fd(),
13288            temp.as_ptr(),
13289            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13290            0o600,
13291        )
13292    };
13293    if fd < 0 {
13294        return Err(std::io::Error::last_os_error().into());
13295    }
13296    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13297    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13298        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13299        return Err(error.into());
13300    }
13301    drop(file);
13302    if unsafe {
13303        libc::renameat(
13304            directory.as_raw_fd(),
13305            temp.as_ptr(),
13306            directory.as_raw_fd(),
13307            name.as_ptr(),
13308        )
13309    } != 0
13310    {
13311        let error = std::io::Error::last_os_error();
13312        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13313        return Err(error.into());
13314    }
13315    directory.sync_all()?;
13316    Ok(())
13317}
13318
13319#[cfg(windows)]
13320fn save_trust_in(
13321    cfg: &HubConfig,
13322    directory: &TrustDirectory,
13323    state: &TrustState,
13324) -> LinkResult<()> {
13325    let name = trust_file_name(cfg, &state.requested)?;
13326    let mut bytes = serde_json::to_vec(state)
13327        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13328    bytes.push(b'\n');
13329    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13330    Ok(())
13331}
13332
13333#[cfg(not(any(unix, windows)))]
13334fn save_trust_in(
13335    _cfg: &HubConfig,
13336    _directory: &TrustDirectory,
13337    _state: &TrustState,
13338) -> LinkResult<()> {
13339    Err(LinkError::UnsupportedPlatform {
13340        operation: "verified link.md state",
13341    })
13342}
13343
13344#[cfg(unix)]
13345fn load_alias_in(
13346    cfg: &HubConfig,
13347    directory: &TrustDirectory,
13348    requested: &str,
13349) -> LinkResult<Option<AliasBinding>> {
13350    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13351
13352    let name_string = alias_file_name(cfg, requested)?;
13353    let name = c_name(name_string.as_bytes(), &name_string)?;
13354    let fd = unsafe {
13355        libc::openat(
13356            directory.as_raw_fd(),
13357            name.as_ptr(),
13358            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13359        )
13360    };
13361    if fd < 0 {
13362        let error = std::io::Error::last_os_error();
13363        if error.kind() == std::io::ErrorKind::NotFound {
13364            return Ok(None);
13365        }
13366        return Err(LinkError::UnsafePath { path: name_string });
13367    }
13368    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13369    if !file.metadata()?.is_file() {
13370        return Err(LinkError::UnsafePath { path: name_string });
13371    }
13372    let mut bytes = Vec::new();
13373    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13374    if bytes.len() > 64 * 1024 {
13375        return Err(invalid_feed("local alias binding is oversized"));
13376    }
13377    let alias: AliasBinding = serde_json::from_slice(&bytes)
13378        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13379    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13380    {
13381        return Err(invalid_feed(
13382            "local alias binding does not match this hub and requested ref",
13383        ));
13384    }
13385    Ok(Some(alias))
13386}
13387
13388#[cfg(windows)]
13389fn load_alias_in(
13390    cfg: &HubConfig,
13391    directory: &TrustDirectory,
13392    requested: &str,
13393) -> LinkResult<Option<AliasBinding>> {
13394    let name = alias_file_name(cfg, requested)?;
13395    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13396    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13397        Ok(bytes) => bytes,
13398        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13399        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13400    };
13401    let alias: AliasBinding = serde_json::from_slice(&bytes)
13402        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13403    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13404    {
13405        return Err(invalid_feed(
13406            "local alias binding does not match this hub and requested ref",
13407        ));
13408    }
13409    Ok(Some(alias))
13410}
13411
13412#[cfg(not(any(unix, windows)))]
13413fn load_alias_in(
13414    _cfg: &HubConfig,
13415    _directory: &TrustDirectory,
13416    _requested: &str,
13417) -> LinkResult<Option<AliasBinding>> {
13418    Err(LinkError::UnsupportedPlatform {
13419        operation: "verified link.md state",
13420    })
13421}
13422
13423#[cfg(unix)]
13424fn save_alias_in(
13425    cfg: &HubConfig,
13426    directory: &TrustDirectory,
13427    alias: &AliasBinding,
13428) -> LinkResult<()> {
13429    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13430
13431    let name_string = alias_file_name(cfg, &alias.requested)?;
13432    let name = c_name(name_string.as_bytes(), &name_string)?;
13433    let mut bytes = serde_json::to_vec(alias)
13434        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13435    bytes.push(b'\n');
13436    let nonce = std::time::SystemTime::now()
13437        .duration_since(std::time::UNIX_EPOCH)
13438        .unwrap_or_default()
13439        .as_nanos();
13440    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13441    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13442    let fd = unsafe {
13443        libc::openat(
13444            directory.as_raw_fd(),
13445            temp.as_ptr(),
13446            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13447            0o600,
13448        )
13449    };
13450    if fd < 0 {
13451        return Err(std::io::Error::last_os_error().into());
13452    }
13453    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13454    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13455        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13456        return Err(error.into());
13457    }
13458    drop(file);
13459    if unsafe {
13460        libc::renameat(
13461            directory.as_raw_fd(),
13462            temp.as_ptr(),
13463            directory.as_raw_fd(),
13464            name.as_ptr(),
13465        )
13466    } != 0
13467    {
13468        let error = std::io::Error::last_os_error();
13469        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13470        return Err(error.into());
13471    }
13472    directory.sync_all()?;
13473    Ok(())
13474}
13475
13476#[cfg(windows)]
13477fn save_alias_in(
13478    cfg: &HubConfig,
13479    directory: &TrustDirectory,
13480    alias: &AliasBinding,
13481) -> LinkResult<()> {
13482    let name = alias_file_name(cfg, &alias.requested)?;
13483    let mut bytes = serde_json::to_vec(alias)
13484        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13485    bytes.push(b'\n');
13486    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13487    Ok(())
13488}
13489
13490#[cfg(not(any(unix, windows)))]
13491fn save_alias_in(
13492    _cfg: &HubConfig,
13493    _directory: &TrustDirectory,
13494    _alias: &AliasBinding,
13495) -> LinkResult<()> {
13496    Err(LinkError::UnsupportedPlatform {
13497        operation: "verified link.md state",
13498    })
13499}
13500
13501/// Load the canonical checkpoint shared by every spelling of one brain and
13502/// the separate alias binding. This also performs the one-way migration from
13503/// pre-v3 checkpoints that stored a full trust state under the alias itself.
13504/// Callers hold both alias and canonical locks before entering.
13505fn load_canonical_pin(
13506    cfg: &HubConfig,
13507    directory: &TrustDirectory,
13508    requested: &str,
13509    resolved_brain: &str,
13510) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13511    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13512    if requested == resolved_brain {
13513        return Ok((canonical, None));
13514    }
13515
13516    let mut alias = load_alias_in(cfg, directory, requested)?;
13517    if let Some(binding) = &alias {
13518        if binding.brain != resolved_brain {
13519            return Err(LinkError::AliasRebindRequired {
13520                alias: requested.to_string(),
13521                from: binding.brain.clone(),
13522                to: resolved_brain.to_string(),
13523            });
13524        }
13525        return Ok((canonical, alias));
13526    }
13527
13528    // A v2 build stored the complete checkpoint under the requested slug.
13529    // Promote it to the canonical ULID key before creating the lightweight
13530    // alias binding. Never silently merge two independently advanced states.
13531    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13532        if legacy.brain != resolved_brain {
13533            return Err(invalid_feed(
13534                "legacy alias checkpoint names a different canonical brain",
13535            ));
13536        }
13537        if let Some(existing) = &canonical {
13538            if existing.brain != legacy.brain
13539                || existing.anchor != legacy.anchor
13540                || existing.current != legacy.current
13541                || existing.head_seq != legacy.head_seq
13542                || existing.feed_hash != legacy.feed_hash
13543                || existing.rotations != legacy.rotations
13544            {
13545                return Err(invalid_feed(
13546                    "legacy alias checkpoint conflicts with the canonical checkpoint",
13547                ));
13548            }
13549        } else {
13550            let mut promoted = legacy.clone();
13551            promoted.requested = resolved_brain.to_string();
13552            promoted.home = None;
13553            save_trust_in(cfg, directory, &promoted)?;
13554            canonical = Some(promoted);
13555        }
13556        alias = Some(AliasBinding {
13557            v: 1,
13558            origin: normalized_origin(&cfg.hub)?,
13559            requested: requested.to_string(),
13560            brain: resolved_brain.to_string(),
13561            home: legacy.home,
13562        });
13563        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13564    }
13565    Ok((canonical, alias))
13566}
13567
13568/// Explicitly replace one mutable alias binding after the operator reviews the
13569/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
13570/// or rewritten, so old history remains pinned and the new brain establishes
13571/// its own independently verified trust chain.
13572pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13573    require_hardened_filesystem("verified alias rebind")?;
13574    require_safe_ref(alias)?;
13575    require_safe_ref(from)?;
13576    require_safe_ref(to)?;
13577    if crate::ulid::is_ulid(alias)
13578        || !crate::ulid::is_ulid(from)
13579        || !crate::ulid::is_ulid(to)
13580        || from == to
13581    {
13582        return Err(LinkError::InvalidPack {
13583            message:
13584                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13585                    .to_string(),
13586        });
13587    }
13588
13589    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13590        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13591    })?;
13592    accept_v2_head(cfg, &verified)?;
13593
13594    let alias_response = ensure_ok(
13595        request(
13596            cfg,
13597            "GET",
13598            &format!("/api/hub/brains/{alias}/v2/head"),
13599            None,
13600            Auth::Required,
13601        )?,
13602        "resolve alias for explicit rebind",
13603    )?;
13604    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13605        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13606    if resolved.v != 2 || resolved.brain_id != to {
13607        return Err(LinkError::RemoteAdvancedDuringSync);
13608    }
13609
13610    let directory = open_trust_dir(cfg)?;
13611    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13612    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13613        message: "the requested alias has no existing local binding to replace".to_string(),
13614    })?;
13615    if binding.brain != from {
13616        return Err(LinkError::AliasRebindRequired {
13617            alias: alias.to_string(),
13618            from: binding.brain,
13619            to: to.to_string(),
13620        });
13621    }
13622    save_alias_in(
13623        cfg,
13624        &directory,
13625        &AliasBinding {
13626            v: 1,
13627            origin: normalized_origin(&cfg.hub)?,
13628            requested: alias.to_string(),
13629            brain: to.to_string(),
13630            home: binding.home,
13631        },
13632    )?;
13633    Ok(json!({
13634        "v": 2,
13635        "alias": alias,
13636        "from": from,
13637        "to": to,
13638        "outcome": "alias_rebound",
13639    }))
13640}
13641
13642fn save_canonical_pin_and_alias(
13643    cfg: &HubConfig,
13644    directory: &TrustDirectory,
13645    requested: &str,
13646    resolved_brain: &str,
13647    mut state: TrustState,
13648    existing_alias: Option<&AliasBinding>,
13649) -> LinkResult<()> {
13650    state.requested = resolved_brain.to_string();
13651    state.brain = resolved_brain.to_string();
13652    state.home = None;
13653    save_trust_in(cfg, directory, &state)?;
13654    if requested != resolved_brain {
13655        save_alias_in(
13656            cfg,
13657            directory,
13658            &AliasBinding {
13659                v: 1,
13660                origin: normalized_origin(&cfg.hub)?,
13661                requested: requested.to_string(),
13662                brain: resolved_brain.to_string(),
13663                home: existing_alias.and_then(|alias| alias.home.clone()),
13664            },
13665        )?;
13666    }
13667    Ok(())
13668}
13669
13670fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13671    const ED25519_SPKI_PREFIX: &[u8] = &[
13672        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13673    ];
13674    let entry = &item.entry;
13675    let public_der = URL_SAFE_NO_PAD
13676        .decode(&entry.public_key)
13677        .map_err(|_| invalid_feed("public key is not base64url"))?;
13678    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13679        || !public_der.starts_with(ED25519_SPKI_PREFIX)
13680    {
13681        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13682    }
13683    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13684    if entry.brain != format!("ed25519:{fingerprint}") {
13685        return Err(invalid_feed(
13686            "brain fingerprint does not match its public key",
13687        ));
13688    }
13689    // Verify the complete rotation authority before using any previous key.
13690    let _ = verify_identity_chain(identity, None)?;
13691    let mut chain: Vec<(&str, &str)> = identity
13692        .previous
13693        .iter()
13694        .rev()
13695        .map(|previous| {
13696            (
13697                previous.fingerprint.as_str(),
13698                previous.public_key_spki.as_str(),
13699            )
13700        })
13701        .collect();
13702    chain.push((&identity.fingerprint, &identity.public_key_spki));
13703    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13704        *known_fingerprint == fingerprint && *spki == entry.public_key
13705    });
13706    let Some(signer_index) = signer_index else {
13707        return Err(invalid_feed(
13708            "entry signer is not this brain's identity (current or rotated-from)",
13709        ));
13710    };
13711    let lower_boundary = if signer_index == 0 {
13712        None
13713    } else {
13714        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13715            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13716        Some(prior.prior_head_seq)
13717    };
13718    let upper_boundary = if signer_index == identity.rotations.len() {
13719        None
13720    } else {
13721        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13722            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13723        Some(next.prior_head_seq)
13724    };
13725    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13726        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13727    {
13728        return Err(invalid_feed(
13729            "entry signer is outside its authenticated rotation epoch",
13730        ));
13731    }
13732    let unsigned = UnsignedFeedEntry {
13733        v: entry.v,
13734        seq: entry.seq,
13735        ts: &entry.ts,
13736        brain: &entry.brain,
13737        public_key: &entry.public_key,
13738        kind: &entry.kind,
13739        op: &entry.op,
13740        pack_sha256: &entry.pack_sha256,
13741        files: &entry.files,
13742        removed: &entry.removed,
13743        prev_entry_hash: &entry.prev_entry_hash,
13744    };
13745    let message =
13746        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13747    let signature = URL_SAFE_NO_PAD
13748        .decode(&entry.sig)
13749        .map_err(|_| invalid_feed("signature is not base64url"))?;
13750    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13751        .verify(&message, &signature)
13752        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13753
13754    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13755    exact.push(b'\n');
13756    let actual_hash = format!("{:x}", Sha256::digest(&exact));
13757    if actual_hash != item.hash {
13758        return Err(invalid_feed("entry SHA-256 does not match"));
13759    }
13760    Ok(())
13761}
13762
13763// ─────────────────────────────────────────────────────────────────────────────
13764// key rotation — link.md §9.1: the new key, signed by the old one
13765// ─────────────────────────────────────────────────────────────────────────────
13766
13767/// The unsigned rotation statement in its normative field order.
13768#[derive(Serialize)]
13769struct UnsignedRotation<'a> {
13770    v: u8,
13771    op: &'a str,
13772    brain: &'a str,
13773    public_key: &'a str,
13774    new_brain: &'a str,
13775    new_public_key: &'a str,
13776    prior_head_seq: u64,
13777    prior_feed_hash: Option<&'a str>,
13778    ts: String,
13779}
13780
13781/// Durable intent for an in-flight key rotation. The hub's recovery contract
13782/// identifies an ambiguous retry by the exact statement bytes, so the
13783/// statement cannot be reconstructed from the key and feed boundary later:
13784/// its timestamp and signature would differ.
13785#[derive(Debug, Deserialize, Serialize)]
13786#[serde(deny_unknown_fields)]
13787struct RotationJournal {
13788    v: u8,
13789    origin: String,
13790    brain: String,
13791    old_brain: String,
13792    new_brain: String,
13793    prior_head_seq: u64,
13794    prior_feed_hash: Option<String>,
13795    statement: String,
13796}
13797
13798fn rotation_journal_path(key_path: &Path) -> PathBuf {
13799    let mut path = key_path.as_os_str().to_os_string();
13800    path.push(".rotation.json");
13801    PathBuf::from(path)
13802}
13803
13804fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13805    #[cfg(unix)]
13806    let file = {
13807        use std::os::fd::{AsRawFd as _, FromRawFd as _};
13808        use std::os::unix::ffi::OsStrExt as _;
13809        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13810            .map_err(|error| {
13811                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13812            })?;
13813        let leaf_name = path
13814            .file_name()
13815            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13816        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13817        let fd = unsafe {
13818            libc::openat(
13819                parent.as_raw_fd(),
13820                leaf.as_ptr(),
13821                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13822            )
13823        };
13824        if fd < 0 {
13825            return Err(bad_agent_key(
13826                "the rotation journal must be an existing regular file without symlink ancestors",
13827            ));
13828        }
13829        unsafe { std::fs::File::from_raw_fd(fd) }
13830    };
13831    #[cfg(not(unix))]
13832    let file = std::fs::File::open(path)
13833        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13834    let metadata = file
13835        .metadata()
13836        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13837    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13838        return Err(bad_agent_key(
13839            "the rotation journal must be a bounded regular file",
13840        ));
13841    }
13842    #[cfg(unix)]
13843    {
13844        use std::os::unix::fs::PermissionsExt as _;
13845        if metadata.permissions().mode() & 0o077 != 0 {
13846            return Err(bad_agent_key(
13847                "the rotation journal is accessible to group/other; set mode 0600",
13848            ));
13849        }
13850    }
13851    serde_json::from_reader(file)
13852        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13853}
13854
13855fn remove_rotation_journal(path: &Path) {
13856    #[cfg(unix)]
13857    {
13858        use std::os::fd::AsRawFd as _;
13859        use std::os::unix::ffi::OsStrExt as _;
13860        let Ok(parent) =
13861            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13862        else {
13863            return;
13864        };
13865        let Some(leaf_name) = path.file_name() else {
13866            return;
13867        };
13868        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13869            return;
13870        };
13871        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13872            let _ = parent.sync_all();
13873        }
13874    }
13875    #[cfg(not(unix))]
13876    {
13877        let _ = std::fs::remove_file(path);
13878    }
13879}
13880
13881fn validate_rotation_journal(
13882    journal: &RotationJournal,
13883    cfg: &HubConfig,
13884    canonical_brain: &str,
13885    old_key: &AgentSigningKey,
13886    new_key: &AgentSigningKey,
13887    head: &Head,
13888) -> LinkResult<()> {
13889    if journal.v != 1
13890        || journal.origin != normalized_origin(&cfg.hub)?
13891        || journal.brain != canonical_brain
13892        || journal.old_brain != old_key.multikey
13893        || journal.new_brain != new_key.multikey
13894        || journal.prior_head_seq != head.seq
13895        || journal.prior_feed_hash != head.feed_hash
13896    {
13897        return Err(invalid_feed(
13898            "rotation journal does not match the verified key and feed boundary",
13899        ));
13900    }
13901    let statement: RotationStatement = serde_json::from_str(&journal.statement)
13902        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13903    if statement.prior_head_seq != journal.prior_head_seq
13904        || statement.prior_feed_hash != journal.prior_feed_hash
13905        || statement.brain != old_key.multikey
13906        || statement.public_key != old_key.public_key_spki
13907        || statement.new_brain != new_key.multikey
13908        || statement.new_public_key != new_key.public_key_spki
13909    {
13910        return Err(invalid_feed(
13911            "rotation journal statement does not match its durable intent",
13912        ));
13913    }
13914    let identity = FeedIdentity {
13915        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13916        public_key_spki: new_key.public_key_spki.clone(),
13917        previous: vec![PreviousIdentity {
13918            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13919            public_key_spki: old_key.public_key_spki.clone(),
13920        }],
13921        rotations: vec![journal.statement.clone()],
13922    };
13923    verify_identity_chain(&identity, None)?;
13924    Ok(())
13925}
13926
13927/// What `dbmd key rotate` returns.
13928#[derive(Debug, Serialize)]
13929pub struct RotationReport {
13930    /// The brain id rotated.
13931    pub brain: String,
13932    /// The NEW identity the hub now serves.
13933    pub multikey: String,
13934    /// Where the new PKCS#8 secret landed (0600).
13935    #[serde(rename = "keyFile")]
13936    pub key_file: String,
13937    /// Prior identities (newest first) the feed still verifies against.
13938    pub previous: Vec<String>,
13939}
13940
13941/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
13942/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
13943/// OLD key in normative serialization — and send it to the hub. The new secret
13944/// is durably created at 0600 before the POST; an existing output is reused for
13945/// idempotent retry/reconciliation. The old key is left untouched.
13946pub fn rotate_brain_key(
13947    cfg: &HubConfig,
13948    brain: &str,
13949    old_key: &AgentSigningKey,
13950    out: &Path,
13951) -> LinkResult<RotationReport> {
13952    require_hardened_filesystem("key rotation")?;
13953    require_safe_ref(brain)?;
13954    // The new private key must be durable *before* the hub can accept its
13955    // public half. An existing file is the retry/reconciliation path after an
13956    // ambiguous network failure: reuse it, never generate another identity.
13957    let new_key = if out.exists() {
13958        load_signing_key(out)?
13959    } else {
13960        let rng = ring::rand::SystemRandom::new();
13961        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13962            .map_err(|_| bad_agent_key("key generation failed"))?;
13963        let pair = agent_keypair(pkcs8.as_ref())?;
13964        let (public_key_spki, multikey) = public_identity_for(&pair);
13965        write_secret_new(
13966            out,
13967            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13968        )?;
13969        AgentSigningKey {
13970            pkcs8: pkcs8.as_ref().to_vec(),
13971            multikey,
13972            public_key_spki,
13973        }
13974    };
13975    let new_spki = new_key.public_key_spki.clone();
13976    let new_multikey = new_key.multikey.clone();
13977    let journal_path = rotation_journal_path(out);
13978    let before_v2 = v2_verified_head(cfg, brain)?;
13979    let (canonical_brain, served_identity, observed_head, v2_profile) =
13980        if let Some(head) = before_v2 {
13981            let observed = Head {
13982                brain: head.brain_id.clone(),
13983                seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13984                updated_at: head
13985                    .pointer
13986                    .as_ref()
13987                    .map(|pointer| pointer.signed_at.clone()),
13988                feed_hash: head
13989                    .pointer
13990                    .as_ref()
13991                    .map(|pointer| pointer.feed_hash.clone()),
13992                verified: true,
13993            };
13994            let identity = v2_identity(&head.identity);
13995            let canonical = head.brain_id.clone();
13996            accept_v2_head(cfg, &head)?;
13997            (canonical, identity, observed, true)
13998        } else {
13999            let remote = verified_remote_head(cfg, brain, false)?;
14000            let identity = remote
14001                .identity
14002                .clone()
14003                .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
14004            (remote.head.brain.clone(), identity, remote.head, false)
14005        };
14006    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
14007    let already_rotated = served_multikey == new_multikey;
14008    // A completed invocation removes the journal only after the server has
14009    // acknowledged its recovery boundary. If the verified identity advanced
14010    // but the journal remains, the prior response was ambiguous: replay the
14011    // exact signed statement so a v2 hub can repair/confirm its checkpoint.
14012    if already_rotated && !journal_path.exists() {
14013        remove_rotation_journal(&journal_path);
14014        return Ok(RotationReport {
14015            brain: brain.to_string(),
14016            multikey: new_multikey,
14017            key_file: out.display().to_string(),
14018            previous: served_identity
14019                .previous
14020                .iter()
14021                .map(|identity| format!("ed25519:{}", identity.fingerprint))
14022                .collect(),
14023        });
14024    }
14025    if !already_rotated && served_multikey != old_key.multikey {
14026        return Err(invalid_feed(
14027            "the supplied old key is not the brain's verified current identity",
14028        ));
14029    }
14030
14031    let journal = if journal_path.exists() {
14032        read_rotation_journal(&journal_path)?
14033    } else {
14034        let ts = crate::now()
14035            .with_timezone(&chrono::Utc)
14036            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14037            .to_string();
14038        let unsigned = serde_json::to_string(&UnsignedRotation {
14039            v: 1,
14040            op: "rotate",
14041            brain: &old_key.multikey,
14042            public_key: &old_key.public_key_spki,
14043            new_brain: &new_multikey,
14044            new_public_key: &new_spki,
14045            prior_head_seq: observed_head.seq,
14046            prior_feed_hash: observed_head.feed_hash.as_deref(),
14047            ts,
14048        })
14049        .expect("serialize rotation");
14050        let old_pair = agent_keypair(&old_key.pkcs8)?;
14051        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14052        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14053        let journal = RotationJournal {
14054            v: 1,
14055            origin: normalized_origin(&cfg.hub)?,
14056            brain: canonical_brain.clone(),
14057            old_brain: old_key.multikey.clone(),
14058            new_brain: new_multikey.clone(),
14059            prior_head_seq: observed_head.seq,
14060            prior_feed_hash: observed_head.feed_hash.clone(),
14061            statement,
14062        };
14063        let mut exact = serde_json::to_vec(&journal)
14064            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14065        exact.push(b'\n');
14066        if write_secret_new(&journal_path, &exact).is_err() {
14067            // A concurrent retry may have won the O_EXCL race. Only an exact,
14068            // fully validated journal is allowed to recover that race.
14069            read_rotation_journal(&journal_path)?
14070        } else {
14071            journal
14072        }
14073    };
14074    validate_rotation_journal(
14075        &journal,
14076        cfg,
14077        &canonical_brain,
14078        old_key,
14079        &new_key,
14080        &observed_head,
14081    )?;
14082
14083    let body = json!({ "statement": journal.statement });
14084    let path = format!("/api/hub/brains/{brain}/rotate");
14085    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14086    let attempted_failure = match attempted {
14087        Ok(response) if (200..300).contains(&response.status) => None,
14088        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14089        Err(error) => Some(error),
14090    };
14091
14092    // A 2xx body is not authority, and a failed response may have followed a
14093    // committed mutation. In both cases success means the normal verifier sees
14094    // an append-only rotation chain ending at the durable new key.
14095    let identity = if v2_profile {
14096        match v2_verified_head(cfg, brain) {
14097            Ok(Some(after)) => {
14098                let identity = v2_identity(&after.identity);
14099                accept_v2_head(cfg, &after)?;
14100                identity
14101            }
14102            Ok(None) => {
14103                return Err(attempted_failure.unwrap_or_else(|| {
14104                    invalid_feed("rotated v2 brain no longer serves a v2 head")
14105                }));
14106            }
14107            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14108        }
14109    } else {
14110        match verified_remote_head(cfg, brain, false) {
14111            Ok(after) => after
14112                .identity
14113                .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14114            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14115        }
14116    };
14117    if format!("ed25519:{}", identity.fingerprint) != new_multikey
14118        || identity.public_key_spki != new_spki
14119    {
14120        return Err(attempted_failure.unwrap_or_else(|| {
14121            invalid_feed("hub acknowledged rotation without committing the verified new identity")
14122        }));
14123    }
14124    if v2_profile {
14125        if let Some(error) = attempted_failure {
14126            // Seeing the new public identity is insufficient: v2 rotation is
14127            // successful only after the endpoint has forced and acknowledged
14128            // the recovery checkpoint that contains it. Keep the journal for
14129            // an exact retry after any non-2xx or transport ambiguity.
14130            return Err(error);
14131        }
14132    }
14133    let previous = identity
14134        .previous
14135        .iter()
14136        .map(|prior| format!("ed25519:{}", prior.fingerprint))
14137        .collect();
14138    remove_rotation_journal(&journal_path);
14139
14140    Ok(RotationReport {
14141        brain: brain.to_string(),
14142        multikey: new_multikey,
14143        key_file: out.display().to_string(),
14144        previous,
14145    })
14146}
14147
14148// ─────────────────────────────────────────────────────────────────────────────
14149// mirror — verified replication: the whole feed + files, re-servable
14150// ─────────────────────────────────────────────────────────────────────────────
14151
14152/// What `dbmd mirror` materialized.
14153#[derive(Debug, Serialize)]
14154pub struct MirrorReport {
14155    /// The brain id.
14156    pub brain: String,
14157    /// The mirrored feed head.
14158    #[serde(rename = "headSeq")]
14159    pub head_seq: u64,
14160    /// The head entry hash (the feed's advertised converged state).
14161    #[serde(rename = "feedHash")]
14162    pub feed_hash: Option<String>,
14163    /// Signed feed entries verified and stored.
14164    pub entries: u64,
14165    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
14166    pub pinned: String,
14167    /// Store files materialized by the pull.
14168    pub files: usize,
14169}
14170
14171/// The mirror state directory, relative to the mirror root.
14172pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
14173
14174/// Fully re-verified mirror material suitable for a read-only re-server.
14175#[derive(Debug)]
14176pub struct VerifiedMirrorMaterial {
14177    pub brain: String,
14178    pub head_seq: u64,
14179    pub feed_hash: Option<String>,
14180    pub identity: serde_json::Value,
14181    /// Sequence, exact normative entry JSON without newline, and entry hash.
14182    pub entries: Vec<(u64, String, String)>,
14183    pub pack_sha256: Option<String>,
14184}
14185
14186#[derive(Deserialize)]
14187#[serde(deny_unknown_fields)]
14188struct StoredMirrorHead {
14189    brain: String,
14190    #[serde(rename = "headSeq")]
14191    head_seq: u64,
14192    #[serde(rename = "feedHash")]
14193    feed_hash: Option<String>,
14194}
14195
14196/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
14197/// exact snapshot pack before `dbmd serve` exposes any bytes.
14198pub fn verify_mirror_material(
14199    head_bytes: &[u8],
14200    identity_bytes: &[u8],
14201    feed_bytes: &[Vec<u8>],
14202    snapshot_pack: Option<&[u8]>,
14203    expected_anchor: &str,
14204) -> LinkResult<VerifiedMirrorMaterial> {
14205    let snapshot_hash = snapshot_pack
14206        .filter(|pack| !pack.is_empty())
14207        .map(content_sha256);
14208    verify_mirror_material_with_pack_hash(
14209        head_bytes,
14210        identity_bytes,
14211        feed_bytes,
14212        snapshot_hash.as_deref(),
14213        expected_anchor,
14214    )
14215}
14216
14217/// Re-verify mirror metadata against a snapshot digest computed from a held
14218/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
14219/// large pack without ever buffering the pack in process memory.
14220pub fn verify_mirror_material_with_pack_hash(
14221    head_bytes: &[u8],
14222    identity_bytes: &[u8],
14223    feed_bytes: &[Vec<u8>],
14224    snapshot_pack_sha256: Option<&str>,
14225    expected_anchor: &str,
14226) -> LinkResult<VerifiedMirrorMaterial> {
14227    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
14228        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
14229    require_safe_ref(&head.brain)?;
14230    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
14231        return Err(invalid_feed(
14232            "stored mirror feed count does not match its bounded head sequence",
14233        ));
14234    }
14235    let aggregate = feed_bytes
14236        .iter()
14237        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
14238        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
14239    if aggregate > MAX_FEED_REPLAY_BYTES {
14240        return Err(invalid_feed(
14241            "stored mirror feed metadata exceeds the aggregate limit",
14242        ));
14243    }
14244    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
14245        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
14246    let anchor = verify_identity_chain(&identity, None)?;
14247    if anchor != expected_anchor {
14248        return Err(invalid_feed(
14249            "stored mirror identity does not descend from the explicitly trusted anchor",
14250        ));
14251    }
14252
14253    let mut entries = Vec::with_capacity(feed_bytes.len());
14254    let mut items = Vec::with_capacity(feed_bytes.len());
14255    let mut previous_hash = None;
14256    let mut pack_sha256 = None;
14257    for (index, bytes) in feed_bytes.iter().enumerate() {
14258        let exact = bytes
14259            .strip_suffix(b"\n")
14260            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
14261        if exact.ends_with(b"\n") {
14262            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
14263        }
14264        let entry: FeedEntry = serde_json::from_slice(exact)
14265            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
14266        let expected_seq = index as u64 + 1;
14267        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
14268            return Err(invalid_feed(
14269                "stored mirror feed is not contiguous and hash-chained",
14270            ));
14271        }
14272        let canonical = serde_json::to_vec(&entry)
14273            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
14274        if canonical != exact {
14275            return Err(invalid_feed(
14276                "stored feed entry is not in normative serialization",
14277            ));
14278        }
14279        let hash = content_sha256(bytes);
14280        let item = FeedItem {
14281            hash: hash.clone(),
14282            entry,
14283        };
14284        verify_feed_item(&item, &identity)?;
14285        previous_hash = Some(hash.clone());
14286        if expected_seq == head.head_seq {
14287            pack_sha256 = Some(item.entry.pack_sha256.clone());
14288        }
14289        entries.push((
14290            expected_seq,
14291            std::str::from_utf8(exact)
14292                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
14293                .to_string(),
14294            hash,
14295        ));
14296        items.push(item);
14297    }
14298    if previous_hash != head.feed_hash {
14299        return Err(invalid_feed(
14300            "stored mirror feed does not converge on its advertised head",
14301        ));
14302    }
14303    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
14304    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
14305        (0, None, None) => {}
14306        (_, Some(actual), Some(expected)) if actual == expected => {}
14307        _ => {
14308            return Err(LinkError::InvalidPack {
14309                message: "stored snapshot pack does not match the signed head digest".to_string(),
14310            });
14311        }
14312    }
14313    let identity_value = serde_json::to_value(&identity)
14314        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
14315    Ok(VerifiedMirrorMaterial {
14316        brain: head.brain,
14317        head_seq: head.head_seq,
14318        feed_hash: head.feed_hash,
14319        identity: identity_value,
14320        entries,
14321        pack_sha256,
14322    })
14323}
14324
14325/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
14326/// entry hash every consumer recomputes (SPEC §5.3).
14327pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
14328    format!(
14329        "{:x}",
14330        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
14331    )
14332}
14333
14334/// SHA-256 hex for content a signed manifest names. Exposed for the thin
14335/// `dbmd serve` adapter; cryptographic verification remains centralized here.
14336pub fn content_sha256(bytes: &[u8]) -> String {
14337    format!("{:x}", Sha256::digest(bytes))
14338}
14339
14340/// SHA-256 a stream with a fixed-size working buffer.
14341pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14342    let mut digest = Sha256::new();
14343    let mut buffer = [0u8; 64 * 1024];
14344    loop {
14345        let read = reader.read(&mut buffer)?;
14346        if read == 0 {
14347            break;
14348        }
14349        digest.update(&buffer[..read]);
14350    }
14351    Ok(format!("{:x}", digest.finalize()))
14352}
14353
14354/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
14355/// chain, not just the head): every entry's signature, hash, sequence
14356/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
14357/// checked in a sibling staging directory. Only then is the old mirror swapped
14358/// out through an atomic directory exchange. Every stage, install, and cleanup
14359/// operation is relative to one held no-follow parent-directory capability, so
14360/// renaming an ancestor cannot redirect any write or deletion.
14361#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14362pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14363    require_hardened_filesystem("mirror")?;
14364    require_safe_ref(brain)?;
14365    #[cfg(windows)]
14366    {
14367        let _ = (cfg, dest);
14368        return Err(LinkError::UnsupportedPlatform {
14369            operation: "atomic whole-mirror replacement on Windows",
14370        });
14371    }
14372    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14373    let name = dest
14374        .file_name()
14375        .and_then(|name| name.to_str())
14376        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14377        .ok_or_else(|| LinkError::UnsafePath {
14378            path: dest.display().to_string(),
14379        })?;
14380    #[cfg(unix)]
14381    let parent_dir = open_or_create_dir_nofollow(parent)?;
14382    #[cfg(unix)]
14383    use std::os::fd::AsRawFd as _;
14384    #[cfg(unix)]
14385    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14386    #[cfg(unix)]
14387    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14388        None => false,
14389        Some(true) => true,
14390        Some(false) => {
14391            return Err(LinkError::UnsafePath {
14392                path: dest.display().to_string(),
14393            });
14394        }
14395    };
14396
14397    // A fixed backup was used by pre-hardening builds. Never interpret or
14398    // delete an attacker-planted entry at that name; require manual recovery.
14399    #[cfg(unix)]
14400    let legacy_backup_name = c_name(
14401        format!(".{name}.dbmd-backup").as_bytes(),
14402        &dest.display().to_string(),
14403    )?;
14404    #[cfg(unix)]
14405    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14406        return Err(LinkError::UnsafePath {
14407            path: parent
14408                .join(format!(".{name}.dbmd-backup"))
14409                .display()
14410                .to_string(),
14411        });
14412    }
14413
14414    let nonce = std::time::SystemTime::now()
14415        .duration_since(std::time::UNIX_EPOCH)
14416        .unwrap_or_default()
14417        .as_nanos();
14418    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14419    #[cfg(unix)]
14420    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14421    #[cfg(unix)]
14422    let stage_dir = create_dir_exclusive_at(
14423        parent_dir.as_raw_fd(),
14424        &stage_name,
14425        &dest.display().to_string(),
14426    )?;
14427
14428    let assembled = (|| -> LinkResult<MirrorReport> {
14429        let remote = verified_remote_head(cfg, brain, true)?;
14430        let brain_id = remote.head.brain.clone();
14431        let identity = remote
14432            .identity
14433            .as_ref()
14434            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14435        let anchor = remote
14436            .anchor
14437            .clone()
14438            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14439        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14440        let snapshot_entries = parse_store_pack(pack.clone())?;
14441        let snapshot_count = snapshot_entries.len();
14442        let mut staged_entries = snapshot_entries;
14443        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14444        for item in &remote.entries {
14445            let mut exact = serde_json::to_vec(&item.entry)
14446                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14447            exact.push(b'\n');
14448            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14449                return Err(invalid_feed(
14450                    "serialized mirror entry differs from its verified hash",
14451                ));
14452            }
14453            staged_entries.push((
14454                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14455                exact,
14456            ));
14457        }
14458        let mut identity_bytes = serde_json::to_vec(identity)
14459            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14460        identity_bytes.push(b'\n');
14461        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14462        let mut head_bytes = serde_json::to_vec(&json!({
14463            "brain": brain_id,
14464            "headSeq": remote.head.seq,
14465            "feedHash": remote.head.feed_hash,
14466        }))
14467        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14468        head_bytes.push(b'\n');
14469        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14470        staged_entries.push((
14471            CONFIG_REL_PATH.to_string(),
14472            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14473        ));
14474        #[cfg(unix)]
14475        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14476
14477        Ok(MirrorReport {
14478            brain: brain_id,
14479            head_seq: remote.head.seq,
14480            feed_hash: remote.head.feed_hash,
14481            entries: remote.entries.len() as u64,
14482            pinned: anchor,
14483            files: snapshot_count,
14484        })
14485    })();
14486
14487    let report = match assembled {
14488        Ok(report) => report,
14489        Err(error) => {
14490            #[cfg(unix)]
14491            let _ = remove_tree_at(
14492                parent_dir.as_raw_fd(),
14493                &stage_name,
14494                &dest.display().to_string(),
14495            );
14496            return Err(error);
14497        }
14498    };
14499
14500    #[cfg(unix)]
14501    if let Err(error) =
14502        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14503    {
14504        let _ = remove_tree_at(
14505            parent_dir.as_raw_fd(),
14506            &stage_name,
14507            &dest.display().to_string(),
14508        );
14509        return Err(error);
14510    }
14511    // An exchange leaves the old mirror at the unique stage name. Cleanup is
14512    // capability-relative and never follows symlinks in hostile old content.
14513    #[cfg(unix)]
14514    if dest_exists {
14515        remove_tree_at(
14516            parent_dir.as_raw_fd(),
14517            &stage_name,
14518            &dest.display().to_string(),
14519        )?;
14520    }
14521    #[cfg(unix)]
14522    parent_dir.sync_all()?;
14523    Ok(report)
14524}
14525
14526fn verified_remote_head(
14527    cfg: &HubConfig,
14528    brain: &str,
14529    require_full_chain: bool,
14530) -> LinkResult<VerifiedRemote> {
14531    require_hardened_filesystem("verified link.md state")?;
14532    require_safe_ref(brain)?;
14533    // Refuse an unsafe state root before sending credentials or consulting an
14534    // untrusted card, and retain this exact directory inode for the complete
14535    // checkpoint transaction.
14536    let trust_directory = open_trust_dir(cfg)?;
14537    let path = format!("/api/hub/brains/{brain}");
14538    let body = ensure_ok(
14539        request(cfg, "GET", &path, None, Auth::Required)?,
14540        "subscribe",
14541    )?;
14542    let resolved_brain = body
14543        .get("id")
14544        .and_then(Value::as_str)
14545        .filter(|id| crate::ulid::is_ulid(id))
14546        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14547        .to_string();
14548    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14549        return Err(invalid_feed(
14550            "brain card id differs from the explicitly requested brain id",
14551        ));
14552    }
14553    // The card supplies the canonical ULID. Lock alias + canonical keys in
14554    // deterministic filename order, then hold both through verify + save.
14555    // Concurrent aliases for the same brain therefore converge on one
14556    // checkpoint instead of establishing independent TOFU universes.
14557    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14558    let (pinned, alias_binding) =
14559        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14560    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14561    let advertised_hash = body
14562        .get("feedHash")
14563        .and_then(Value::as_str)
14564        .map(str::to_string);
14565    let updated_at = body
14566        .get("updatedAt")
14567        .and_then(Value::as_str)
14568        .map(str::to_string);
14569    if let Some(pin) = &pinned {
14570        if seq < pin.head_seq {
14571            return Err(invalid_feed(format!(
14572                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14573                pin.head_seq
14574            )));
14575        }
14576        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14577            return Err(invalid_feed(
14578                "feed equivocation: the checkpoint sequence now has a different hash",
14579            ));
14580        }
14581    }
14582    if seq == 0 {
14583        if advertised_hash.is_some() {
14584            return Err(invalid_feed("an empty feed advertised a head hash"));
14585        }
14586        let identity: FeedIdentity = serde_json::from_value(
14587            body.get("identity")
14588                .cloned()
14589                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14590        )
14591        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14592        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14593        // A valid old-key-signed rotation is still inconsistent if it commits
14594        // to history that the same card now claims never existed. Run the
14595        // identical feed-boundary proof used by non-empty heads before this
14596        // identity can become a durable TOFU checkpoint.
14597        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14598        save_canonical_pin_and_alias(
14599            cfg,
14600            &trust_directory,
14601            brain,
14602            &resolved_brain,
14603            TrustState {
14604                v: 2,
14605                origin: normalized_origin(&cfg.hub)?,
14606                requested: resolved_brain.clone(),
14607                brain: resolved_brain.clone(),
14608                home: None,
14609                anchor: anchor.clone(),
14610                current: format!("ed25519:{}", identity.fingerprint),
14611                head_seq: 0,
14612                feed_hash: None,
14613                rotations: identity.rotations.clone(),
14614                hub_signer: None,
14615                protocol_profile: None,
14616            },
14617            alias_binding.as_ref(),
14618        )?;
14619        return Ok(VerifiedRemote {
14620            head: Head {
14621                brain: resolved_brain,
14622                seq,
14623                updated_at,
14624                feed_hash: None,
14625                verified: true,
14626            },
14627            identity: Some(identity),
14628            head_entry: None,
14629            entries: Vec::new(),
14630            anchor: Some(anchor),
14631        });
14632    }
14633    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14634        return Err(invalid_feed(
14635            "non-empty feed did not advertise a valid SHA-256 head",
14636        ));
14637    }
14638
14639    // On first contact the signed head itself is the TOFU checkpoint. A full
14640    // history replay cannot add authority before an anchor exists; mirrors
14641    // still request the complete chain because they promise an archival copy.
14642    let replay_head_only = !require_full_chain
14643        && pinned
14644            .as_ref()
14645            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14646    let mut after = if replay_head_only {
14647        seq - 1
14648    } else if require_full_chain || pinned.is_none() {
14649        0
14650    } else {
14651        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14652    };
14653    let mut expected_seq = after + 1;
14654    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14655        None
14656    } else {
14657        pinned
14658            .as_ref()
14659            .and_then(|checkpoint| checkpoint.feed_hash.clone())
14660    };
14661    let mut identity: Option<FeedIdentity> = None;
14662    let mut anchor: Option<String> = None;
14663    let mut head_entry: Option<FeedItem> = None;
14664    let mut all_entries = Vec::new();
14665    let mut observed_entries = Vec::new();
14666    let replay_count = seq
14667        .checked_sub(after)
14668        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14669    if replay_count > MAX_FEED_REPLAY_ENTRIES {
14670        return Err(invalid_feed(format!(
14671            "feed replay requires {replay_count} entries, over the client cap"
14672        )));
14673    }
14674    let mut replay_bytes = 0u64;
14675
14676    loop {
14677        let feed_bytes = ensure_raw_ok(
14678            request_raw(
14679                cfg,
14680                "GET",
14681                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14682                None,
14683                Auth::Required,
14684                MAX_FEED_RESPONSE_BYTES,
14685            )?,
14686            "subscribe feed",
14687        )?;
14688        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14689            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14690        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14691            return Err(invalid_feed("brain card and feed head disagree"));
14692        }
14693        if feed.entries.len() > FEED_PAGE_LIMIT {
14694            return Err(invalid_feed("feed page exceeds the requested entry limit"));
14695        }
14696        if feed.scope_limited {
14697            if require_full_chain {
14698                return Err(invalid_feed(
14699                    "path-scoped grants cannot verify a full snapshot chain",
14700                ));
14701            }
14702            return Ok(VerifiedRemote {
14703                head: Head {
14704                    brain: resolved_brain,
14705                    seq,
14706                    updated_at,
14707                    feed_hash: advertised_hash,
14708                    verified: false,
14709                },
14710                identity: None,
14711                head_entry: None,
14712                entries: Vec::new(),
14713                anchor: None,
14714            });
14715        }
14716        let page_identity = feed
14717            .identity
14718            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14719        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14720        if identity
14721            .as_ref()
14722            .is_some_and(|existing| existing != &page_identity)
14723        {
14724            return Err(invalid_feed("identity changed while reading the feed"));
14725        }
14726        if anchor
14727            .as_ref()
14728            .is_some_and(|existing| existing != &page_anchor)
14729        {
14730            return Err(invalid_feed(
14731                "identity anchor changed while reading the feed",
14732            ));
14733        }
14734        identity = Some(page_identity.clone());
14735        if anchor.is_none() {
14736            anchor = Some(page_anchor);
14737        }
14738        if feed.entries.is_empty() {
14739            return Err(invalid_feed("feed page was empty before the signed head"));
14740        }
14741
14742        for item in feed.entries {
14743            if item.entry.seq != expected_seq {
14744                return Err(invalid_feed(format!(
14745                    "expected entry {expected_seq}, feed served {}",
14746                    item.entry.seq
14747                )));
14748            }
14749            if item.entry.seq > seq {
14750                return Err(invalid_feed("feed advanced past the card snapshot"));
14751            }
14752            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14753                return Err(invalid_feed(format!(
14754                    "entry {} does not chain to the local checkpoint",
14755                    item.entry.seq
14756                )));
14757            }
14758            verify_feed_item(&item, &page_identity)?;
14759            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14760            replay_bytes = replay_bytes.saturating_add(
14761                serde_json::to_vec(&item)
14762                    .map_err(|_| invalid_feed("could not size feed entry"))?
14763                    .len() as u64,
14764            );
14765            if replay_bytes > MAX_FEED_REPLAY_BYTES {
14766                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14767            }
14768            previous_hash = Some(item.hash.clone());
14769            after = item.entry.seq;
14770            expected_seq = expected_seq
14771                .checked_add(1)
14772                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14773            if require_full_chain {
14774                all_entries.push(item.clone());
14775            }
14776            observed_entries.push(item.clone());
14777            head_entry = Some(item);
14778        }
14779        if after == seq {
14780            break;
14781        }
14782    }
14783
14784    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14785        return Err(invalid_feed(
14786            "verified chain does not converge on the advertised head",
14787        ));
14788    }
14789    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14790    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14791    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14792    save_canonical_pin_and_alias(
14793        cfg,
14794        &trust_directory,
14795        brain,
14796        &resolved_brain,
14797        TrustState {
14798            v: 2,
14799            origin: normalized_origin(&cfg.hub)?,
14800            requested: resolved_brain.clone(),
14801            brain: resolved_brain.clone(),
14802            home: None,
14803            anchor: anchor.clone(),
14804            current: format!("ed25519:{}", identity.fingerprint),
14805            head_seq: seq,
14806            feed_hash: advertised_hash.clone(),
14807            rotations: identity.rotations.clone(),
14808            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14809            protocol_profile: pinned
14810                .as_ref()
14811                .and_then(|state| state.protocol_profile.clone()),
14812        },
14813        alias_binding.as_ref(),
14814    )?;
14815    Ok(VerifiedRemote {
14816        head: Head {
14817            brain: resolved_brain,
14818            seq,
14819            updated_at,
14820            feed_hash: advertised_hash,
14821            verified: true,
14822        },
14823        identity: Some(identity),
14824        head_entry,
14825        entries: all_entries,
14826        anchor: Some(anchor),
14827    })
14828}
14829
14830/// Read and locally verify the brain's current signed feed head. Identity
14831/// rotation is accepted only through an old-key-signed chain rooted at the
14832/// local TOFU anchor; sequence and hash checkpoints reject rollback and
14833/// equivocation across invocations.
14834pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14835    if let Some(verified) = v2_verified_head(cfg, brain)? {
14836        let observation = Head {
14837            brain: verified.brain_id.clone(),
14838            seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14839            updated_at: verified
14840                .pointer
14841                .as_ref()
14842                .map(|pointer| pointer.signed_at.clone()),
14843            feed_hash: verified
14844                .pointer
14845                .as_ref()
14846                .map(|pointer| pointer.feed_hash.clone()),
14847            verified: true,
14848        };
14849        accept_v2_head(cfg, &verified)?;
14850        return Ok(observation);
14851    }
14852    Ok(verified_remote_head(cfg, brain, false)?.head)
14853}
14854
14855#[cfg(test)]
14856mod tests {
14857    use super::*;
14858
14859    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14860
14861    fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14862        json!({
14863            "sha256": "a".repeat(64),
14864            "bytes": 10,
14865            "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14866        })
14867    }
14868
14869    #[test]
14870    fn upload_reservations_batch_by_count_and_by_size() {
14871        // A real store's first push declares one blob per changed file, which
14872        // does not fit in one request. Every batch has to stay inside both
14873        // bounds, and concatenating them has to reproduce the original order.
14874        let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14875        let batches = batch_upload_declarations(declarations.clone());
14876
14877        assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14878        for batch in &batches {
14879            assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14880            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14881                .expect("batch serializes")
14882                .len();
14883            assert!(
14884                bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14885                "batch body {bytes} exceeds the reservation budget"
14886            );
14887        }
14888        let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14889        assert_eq!(
14890            flattened, declarations,
14891            "batching must preserve the set and order"
14892        );
14893    }
14894
14895    #[test]
14896    fn only_load_shaped_hub_answers_are_worth_asking_again() {
14897        // A migration opens dozens of staging windows, so a busy hub's 429 or
14898        // 5xx must not end the push — while a refusal that states something
14899        // about the request itself has to surface immediately rather than
14900        // being repeated six times behind a minute of waiting.
14901        for status in [408, 429, 500, 502, 503, 504] {
14902            assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14903        }
14904        for status in [400, 401, 403, 404, 409, 413, 422] {
14905            assert!(
14906                !is_retryable_hub_status(status),
14907                "{status} states something about the request"
14908            );
14909        }
14910        // The waits must reach past a whole limiter window to be worth taking.
14911        let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14912        assert!(total >= 60_000, "backoff totals only {total}ms");
14913    }
14914
14915    #[test]
14916    fn a_batch_shares_a_connection_only_within_one_authority() {
14917        // Sharing a pooled agent is what makes a migration stage in minutes,
14918        // but it must never widen where bytes can go: a batch that spans two
14919        // authorities, or names an unsafe URL, falls back to per-object agents
14920        // rather than reusing a connection pinned to somewhere else.
14921        let cfg = HubConfig {
14922            hub: "https://www.sevrahq.com".to_string(),
14923            key: Some("k".to_string()),
14924            agent_key: None,
14925            brain_key: None,
14926            state_dir: PathBuf::from("."),
14927            store_selected: false,
14928        };
14929        assert!(shared_staging_agent(&cfg, &[]).is_none());
14930        assert!(
14931            shared_staging_agent(
14932                &cfg,
14933                &[
14934                    "https://one.example.com/a?sig=1",
14935                    "https://two.example.com/b?sig=2",
14936                ]
14937            )
14938            .is_none(),
14939            "two authorities must not share a pinned pool"
14940        );
14941        assert!(
14942            shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14943            "an unsafe object-store URL must not produce an agent"
14944        );
14945        assert!(
14946            shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14947            "credentials in the URL must not produce an agent"
14948        );
14949    }
14950
14951    #[test]
14952    fn a_staged_change_states_only_operations_and_blobs() {
14953        // The hub refuses a manifest with any other key, so the two sides have
14954        // to agree exactly. A drifting extra field would fail a whole migration
14955        // at the commit, after every byte had already been staged.
14956        let operations = vec![json!({
14957            "op": "put",
14958            "path": "records/a.md",
14959            "blob": "a".repeat(64),
14960            "bytes": 3,
14961        })];
14962        let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14963        let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14964        let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14965        let keys: Vec<&str> = parsed
14966            .as_object()
14967            .expect("manifest is an object")
14968            .keys()
14969            .map(String::as_str)
14970            .collect();
14971        assert_eq!(keys, ["blobs", "operations"]);
14972        assert_eq!(parsed["operations"], Value::Array(operations));
14973        assert_eq!(parsed["blobs"], blobs);
14974    }
14975
14976    #[test]
14977    fn a_staged_push_signs_the_change_not_the_transport() {
14978        // Self-custody signs a request hash the hub recomputes from the parsed
14979        // change. A staged body no longer states its operations, so the signed
14980        // view has to restore them or every large self-custodied push would
14981        // sign a different request than the one the hub validated.
14982        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14983        let staged = json!({
14984            "mutation_id": "dbmd-1",
14985            "rebase": "strict",
14986            "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14987        });
14988        let view = v2_signed_request_view(&staged, &operations);
14989        assert_eq!(view["operations"], Value::Array(operations.clone()));
14990        assert!(view.get("staged_change").is_none());
14991        assert_eq!(view["mutation_id"], staged["mutation_id"]);
14992
14993        let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14994        assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14995    }
14996
14997    #[test]
14998    fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14999        let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
15000        let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
15001            .expect_err("an oversized change must not be staged");
15002        assert!(
15003            matches!(error, LinkError::PushTooLarge { .. }),
15004            "expected a size refusal, got {error:?}"
15005        );
15006    }
15007
15008    #[test]
15009    fn a_push_that_fits_the_request_is_left_inline() {
15010        // The staged lane costs a reservation and a round trip, so it must
15011        // engage only when the body genuinely does not fit. The hub here is a
15012        // closed port: reaching for it at all would fail this test.
15013        let cfg = HubConfig {
15014            hub: "http://127.0.0.1:9".to_string(),
15015            key: Some("k".to_string()),
15016            agent_key: None,
15017            brain_key: None,
15018            state_dir: PathBuf::from("."),
15019            store_selected: false,
15020        };
15021        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15022        let mut body = json!({
15023            "mutation_id": "dbmd-1",
15024            "operations": operations,
15025            "blobs": [],
15026        });
15027        stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
15028        assert!(body.get("staged_change").is_none());
15029        assert_eq!(body["operations"], Value::Array(operations));
15030    }
15031
15032    #[test]
15033    fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
15034        // Byte budget, not just count. One hash can carry many coordinates
15035        // (the same bytes reachable at several paths), so a batch sized only by
15036        // count would blow the body budget on a deduplicated store.
15037        let declarations: Vec<Value> = (0..2_000)
15038            .map(|index| {
15039                json!({
15040                    "sha256": "a".repeat(64),
15041                    "bytes": 10,
15042                    "coordinates": (0..24)
15043                        .map(|slot| format!(
15044                            "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15045                        ))
15046                        .collect::<Vec<_>>(),
15047                })
15048            })
15049            .collect();
15050        let batches = batch_upload_declarations(declarations);
15051        assert!(
15052            batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15053            "wide coordinate sets must bound the batch by size"
15054        );
15055        for batch in &batches {
15056            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15057                .expect("batch serializes")
15058                .len();
15059            assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15060        }
15061    }
15062
15063    #[test]
15064    fn a_small_push_still_rides_exactly_one_request() {
15065        let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
15066        assert_eq!(batch_upload_declarations(declarations).len(), 1);
15067        assert!(batch_upload_declarations(Vec::new()).is_empty());
15068    }
15069
15070    #[test]
15071    fn exact_source_move_becomes_one_provenance_preserving_rename() {
15072        let hash = "a".repeat(64);
15073        let operations = vec![
15074            json!({
15075                "op": "put",
15076                "path": "sources/curated/item.md",
15077                "expected": { "kind": "absent" },
15078                "blob": hash,
15079                "bytes": 19,
15080            }),
15081            json!({
15082                "op": "delete",
15083                "path": "sources/inbox/item.md",
15084                "expected": { "kind": "blob", "hash": hash },
15085            }),
15086        ];
15087
15088        assert_eq!(
15089            infer_exact_source_promotions(operations),
15090            vec![json!({
15091                "op": "rename",
15092                "from": "sources/inbox/item.md",
15093                "to": "sources/curated/item.md",
15094                "expected_from": { "kind": "blob", "hash": hash },
15095                "expected_to": { "kind": "absent" },
15096                "blob": hash,
15097                "bytes": 19,
15098            })]
15099        );
15100    }
15101
15102    #[test]
15103    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
15104        let hash = "b".repeat(64);
15105        let operations = vec![
15106            json!({
15107                "op": "delete",
15108                "path": "sources/inbox/a.md",
15109                "expected": { "kind": "blob", "hash": hash },
15110            }),
15111            json!({
15112                "op": "delete",
15113                "path": "sources/inbox/b.md",
15114                "expected": { "kind": "blob", "hash": hash },
15115            }),
15116            json!({
15117                "op": "put",
15118                "path": "sources/curated/item.md",
15119                "expected": { "kind": "absent" },
15120                "blob": hash,
15121                "bytes": 19,
15122            }),
15123        ];
15124
15125        assert_eq!(
15126            infer_exact_source_promotions(operations.clone()),
15127            operations,
15128            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
15129        );
15130    }
15131
15132    #[test]
15133    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
15134        let hash = "c".repeat(64);
15135        let mut candidate = std::collections::BTreeMap::from([(
15136            "sources/inbox/item.md".to_string(),
15137            V2BaselineFile {
15138                sha256: hash.clone(),
15139                bytes: 19,
15140                proof: None,
15141            },
15142        )]);
15143        let mut candidate_assets = std::collections::BTreeMap::new();
15144        let operations = vec![
15145            json!({
15146                "op": "rename",
15147                "from": "sources/inbox/item.md",
15148                "to": "sources/curated/item.md",
15149                "expected_from": { "kind": "blob", "hash": hash },
15150                "expected_to": { "kind": "absent" },
15151                "blob": hash,
15152                "bytes": 19,
15153            }),
15154            json!({
15155                "op": "put",
15156                "path": "records/rsvps/item.md",
15157                "expected": { "kind": "absent" },
15158                "blob": "d".repeat(64),
15159                "bytes": 23,
15160            }),
15161        ];
15162
15163        assert!(!apply_generated_v2_operations(
15164            &operations,
15165            &std::collections::BTreeMap::new(),
15166            &mut candidate,
15167            &mut candidate_assets,
15168        )
15169        .unwrap());
15170        assert!(!candidate.contains_key("sources/inbox/item.md"));
15171        assert_eq!(
15172            candidate
15173                .get("sources/curated/item.md")
15174                .map(|file| (&file.sha256, file.bytes)),
15175            Some((&hash, 19))
15176        );
15177        assert_eq!(
15178            candidate
15179                .get("records/rsvps/item.md")
15180                .map(|file| (file.sha256.as_str(), file.bytes)),
15181            Some((
15182                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
15183                23
15184            ))
15185        );
15186    }
15187
15188    fn merge_fixture(
15189        base: Option<&str>,
15190        remote: Option<&str>,
15191        local: Option<&str>,
15192        keep_local: bool,
15193    ) -> V2PulledMerge<String> {
15194        let map = |value: Option<&str>| {
15195            value
15196                .map(|value| [("records/a.md".to_string(), value.to_string())])
15197                .into_iter()
15198                .flatten()
15199                .collect::<std::collections::BTreeMap<_, _>>()
15200        };
15201        merge_v2_pulled_records(
15202            &map(base),
15203            &map(remote),
15204            &map(local),
15205            |value, _| value.clone(),
15206            |value, _| value.clone(),
15207            |_| keep_local,
15208        )
15209    }
15210
15211    #[test]
15212    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
15213        let path = "records/a.md".to_string();
15214
15215        let local_add = merge_fixture(None, None, Some("local"), false);
15216        assert_eq!(
15217            local_add.records.get(&path).map(String::as_str),
15218            Some("local")
15219        );
15220        assert!(local_add.accept_remote.is_empty());
15221        assert!(local_add.conflicts.is_empty());
15222
15223        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
15224        assert_eq!(
15225            local_edit.records.get(&path).map(String::as_str),
15226            Some("local")
15227        );
15228        assert!(local_edit.accept_remote.is_empty());
15229        assert!(local_edit.conflicts.is_empty());
15230
15231        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
15232        assert!(!local_delete.records.contains_key(&path));
15233        assert!(local_delete.accept_remote.is_empty());
15234        assert!(local_delete.conflicts.is_empty());
15235
15236        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
15237        assert_eq!(
15238            remote_edit.records.get(&path).map(String::as_str),
15239            Some("remote")
15240        );
15241        assert!(remote_edit.accept_remote.contains(&path));
15242        assert!(remote_edit.conflicts.is_empty());
15243
15244        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
15245        assert!(!remote_delete.records.contains_key(&path));
15246        assert!(remote_delete.accept_remote.contains(&path));
15247        assert!(remote_delete.conflicts.is_empty());
15248
15249        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
15250        assert_eq!(
15251            same_edit.records.get(&path).map(String::as_str),
15252            Some("same")
15253        );
15254        assert!(same_edit.accept_remote.contains(&path));
15255        assert!(same_edit.conflicts.is_empty());
15256
15257        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
15258        assert_eq!(conflict.conflicts, vec![path.clone()]);
15259        assert_eq!(
15260            conflict.records.get(&path).map(String::as_str),
15261            Some("local")
15262        );
15263        assert!(conflict.accept_remote.is_empty());
15264
15265        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
15266        assert_eq!(
15267            kept_home.records.get(&path).map(String::as_str),
15268            Some("local")
15269        );
15270        assert!(kept_home.accept_remote.is_empty());
15271        assert!(kept_home.conflicts.is_empty());
15272    }
15273
15274    #[test]
15275    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
15276        let path = "sources/report.pdf";
15277        let record = crate::AssetRecord {
15278            path: path.to_string(),
15279            sha256: "a".repeat(64),
15280            bytes: 42,
15281            media_type: "application/pdf".to_string(),
15282            wrappers: vec!["gzip".to_string()],
15283            required: true,
15284        };
15285        let mut remote = V2BaselineAsset {
15286            blob_sha256: record.sha256.clone(),
15287            bytes: record.bytes,
15288            media_type: record.media_type.clone(),
15289            wrappers: record.wrappers.clone(),
15290            required: record.required,
15291            disposition: "withheld".to_string(),
15292            leaf_hash: "b".repeat(64),
15293        };
15294
15295        assert!(v2_asset_resumes_hosting(
15296            Some(&remote),
15297            path,
15298            &record,
15299            "hosted"
15300        ));
15301        assert!(!v2_asset_resumes_hosting(
15302            Some(&remote),
15303            path,
15304            &record,
15305            "withheld"
15306        ));
15307
15308        remote.disposition = "hosted".to_string();
15309        assert!(!v2_asset_resumes_hosting(
15310            Some(&remote),
15311            path,
15312            &record,
15313            "hosted"
15314        ));
15315
15316        remote.disposition = "withheld".to_string();
15317        remote.blob_sha256 = "c".repeat(64);
15318        assert!(!v2_asset_resumes_hosting(
15319            Some(&remote),
15320            path,
15321            &record,
15322            "hosted"
15323        ));
15324        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
15325    }
15326
15327    #[test]
15328    fn v2_fresh_clone_preserves_only_exact_inherited_withheld_asset_absence() {
15329        let path = "sources/report.pdf";
15330        let record = crate::AssetRecord {
15331            path: path.to_string(),
15332            sha256: "a".repeat(64),
15333            bytes: 42,
15334            media_type: "application/pdf".to_string(),
15335            wrappers: vec!["records/report.md".to_string()],
15336            required: true,
15337        };
15338        let mut base = V2BaselineAsset {
15339            blob_sha256: record.sha256.clone(),
15340            bytes: record.bytes,
15341            media_type: record.media_type.clone(),
15342            wrappers: record.wrappers.clone(),
15343            required: record.required,
15344            disposition: "withheld".to_string(),
15345            leaf_hash: "b".repeat(64),
15346        };
15347
15348        assert!(v2_asset_inherits_withheld_absence(
15349            Some(&base),
15350            Some(&record),
15351            Some(&record),
15352            false,
15353        ));
15354        assert!(!v2_asset_inherits_withheld_absence(
15355            Some(&base),
15356            Some(&record),
15357            Some(&record),
15358            true,
15359        ));
15360
15361        base.disposition = "hosted".to_string();
15362        assert!(!v2_asset_inherits_withheld_absence(
15363            Some(&base),
15364            Some(&record),
15365            Some(&record),
15366            false,
15367        ));
15368
15369        base.disposition = "withheld".to_string();
15370        let mut changed = record.clone();
15371        changed.bytes += 1;
15372        assert!(!v2_asset_inherits_withheld_absence(
15373            Some(&base),
15374            Some(&record),
15375            Some(&changed),
15376            false,
15377        ));
15378        assert!(!v2_asset_inherits_withheld_absence(
15379            None,
15380            None,
15381            Some(&record),
15382            false,
15383        ));
15384    }
15385
15386    #[test]
15387    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
15388        let path = "records/team/alpha.md".to_string();
15389        let deleted_path = "records/team/deleted.md".to_string();
15390        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
15391            sha256,
15392            bytes,
15393            file: None,
15394        };
15395        let files = vec![
15396            V2ConflictFile {
15397                path: path.clone(),
15398                base: coordinate(None, None),
15399                local: coordinate(Some("b".repeat(64)), Some(7)),
15400                remote: coordinate(Some("a".repeat(64)), Some(5)),
15401            },
15402            V2ConflictFile {
15403                path: deleted_path.clone(),
15404                base: coordinate(Some("c".repeat(64)), Some(9)),
15405                local: coordinate(Some("d".repeat(64)), Some(11)),
15406                remote: coordinate(None, None),
15407            },
15408        ];
15409        let proven = V2BaselineFile {
15410            sha256: "a".repeat(64),
15411            bytes: 5,
15412            proof: None,
15413        };
15414        let current = [(path.clone(), proven.clone())]
15415            .into_iter()
15416            .collect::<std::collections::BTreeMap<_, _>>();
15417
15418        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
15419        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15420        assert_eq!(deleted, vec![deleted_path.clone()]);
15421
15422        let changed = [(
15423            path.clone(),
15424            V2BaselineFile {
15425                sha256: "e".repeat(64),
15426                bytes: 5,
15427                proof: None,
15428            },
15429        )]
15430        .into_iter()
15431        .collect::<std::collections::BTreeMap<_, _>>();
15432        assert!(v2_take_remote_selection(&files, &changed).is_err());
15433
15434        let resurrected = [
15435            (path, proven),
15436            (
15437                deleted_path,
15438                V2BaselineFile {
15439                    sha256: "f".repeat(64),
15440                    bytes: 13,
15441                    proof: None,
15442                },
15443            ),
15444        ]
15445        .into_iter()
15446        .collect::<std::collections::BTreeMap<_, _>>();
15447        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15448    }
15449
15450    #[cfg(target_os = "linux")]
15451    #[test]
15452    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15453        use std::os::fd::AsRawFd as _;
15454
15455        let sandbox = tempfile::TempDir::new().unwrap();
15456        let parent = std::fs::File::open(sandbox.path()).unwrap();
15457        let stage = std::ffi::CString::new("stage").unwrap();
15458        let destination = std::ffi::CString::new("brain").unwrap();
15459
15460        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15461        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15462        install_stage_at(
15463            parent.as_raw_fd(),
15464            stage.as_c_str(),
15465            destination.as_c_str(),
15466            false,
15467        )
15468        .unwrap();
15469        assert!(!sandbox.path().join("stage").exists());
15470        assert_eq!(
15471            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15472            b"created"
15473        );
15474
15475        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15476        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15477        install_stage_at(
15478            parent.as_raw_fd(),
15479            stage.as_c_str(),
15480            destination.as_c_str(),
15481            true,
15482        )
15483        .unwrap();
15484        assert_eq!(
15485            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15486            b"replacement"
15487        );
15488        assert_eq!(
15489            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15490            b"created",
15491            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15492        );
15493    }
15494
15495    struct SignedRemoteFixture {
15496        card: String,
15497        feed: String,
15498        key: AgentSigningKey,
15499        identity: FeedIdentity,
15500    }
15501
15502    fn signed_remote_fixture() -> SignedRemoteFixture {
15503        let rng = ring::rand::SystemRandom::new();
15504        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15505        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15506        let (public_key, multikey) = public_identity_for(&pair);
15507        let identity = FeedIdentity {
15508            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15509            public_key_spki: public_key.clone(),
15510            previous: Vec::new(),
15511            rotations: Vec::new(),
15512        };
15513        let mut entry = FeedEntry {
15514            v: 1,
15515            seq: 1,
15516            ts: "2026-07-30T12:00:00.000Z".to_string(),
15517            brain: multikey.clone(),
15518            public_key: public_key.clone(),
15519            kind: "push".to_string(),
15520            op: "snapshot".to_string(),
15521            pack_sha256: "a".repeat(64),
15522            files: Vec::new(),
15523            removed: Vec::new(),
15524            prev_entry_hash: None,
15525            sig: String::new(),
15526        };
15527        let unsigned = UnsignedFeedEntry {
15528            v: entry.v,
15529            seq: entry.seq,
15530            ts: &entry.ts,
15531            brain: &entry.brain,
15532            public_key: &entry.public_key,
15533            kind: &entry.kind,
15534            op: &entry.op,
15535            pack_sha256: &entry.pack_sha256,
15536            files: &entry.files,
15537            removed: &entry.removed,
15538            prev_entry_hash: &entry.prev_entry_hash,
15539        };
15540        entry.sig =
15541            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15542        let mut exact = serde_json::to_vec(&entry).unwrap();
15543        exact.push(b'\n');
15544        let hash = content_sha256(&exact);
15545        let card = json!({
15546            "id": TEST_BRAIN_ID,
15547            "headSeq": 1,
15548            "feedHash": hash,
15549            "identity": identity.clone(),
15550        })
15551        .to_string();
15552        let feed = json!({
15553            "headSeq": 1,
15554            "feedHash": hash,
15555            "identity": identity.clone(),
15556            "entries": [{"hash": hash, "entry": entry}],
15557            "scopeLimited": false,
15558        })
15559        .to_string();
15560        SignedRemoteFixture {
15561            card,
15562            feed,
15563            key: AgentSigningKey {
15564                pkcs8: pkcs8.as_ref().to_vec(),
15565                multikey,
15566                public_key_spki: public_key,
15567            },
15568            identity,
15569        }
15570    }
15571
15572    #[test]
15573    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15574        let file = |path: &str, byte: char| FeedFile {
15575            path: path.to_string(),
15576            sha256: byte.to_string().repeat(64),
15577            bytes: 1,
15578        };
15579        let a0 = file("records/a.md", 'a');
15580        let a1 = file("records/a.md", 'b');
15581        let stable = file("records/stable.md", 'c');
15582        let added = file("records/added.md", 'd');
15583        let removed_file = file("records/removed.md", 'e');
15584        let previous = vec![a0, stable.clone(), removed_file.clone()];
15585        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15586        let removed = vec![removed_file.path.clone()];
15587
15588        assert_eq!(
15589            verify_v1_manifest_disclosure(
15590                "edit",
15591                &previous,
15592                &resulting,
15593                &[a1.clone(), added.clone()],
15594                &removed,
15595            ),
15596            Ok(())
15597        );
15598        assert_eq!(
15599            verify_v1_manifest_disclosure(
15600                "edit",
15601                &previous,
15602                &resulting,
15603                &[stable.clone(), added.clone(), a1.clone()],
15604                &removed,
15605            ),
15606            Ok(())
15607        );
15608        assert_eq!(
15609            verify_v1_manifest_disclosure(
15610                "edit",
15611                &previous,
15612                &resulting,
15613                std::slice::from_ref(&added),
15614                &removed,
15615            ),
15616            Err(V1DisclosureError::EditMissingChange)
15617        );
15618        assert_eq!(
15619            verify_v1_manifest_disclosure(
15620                "edit",
15621                &previous,
15622                &resulting,
15623                &[file("records/a.md", 'f'), added.clone()],
15624                &removed,
15625            ),
15626            Err(V1DisclosureError::EditFalseFile)
15627        );
15628        assert_eq!(
15629            verify_v1_manifest_disclosure(
15630                "edit",
15631                &previous,
15632                &resulting,
15633                &[a1.clone(), added.clone()],
15634                &[],
15635            ),
15636            Err(V1DisclosureError::RemovedMismatch)
15637        );
15638        assert_eq!(
15639            verify_v1_manifest_disclosure(
15640                "push",
15641                &previous,
15642                &resulting,
15643                &[added.clone(), stable, a1],
15644                &removed,
15645            ),
15646            Ok(())
15647        );
15648        assert_eq!(
15649            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15650            Err(V1DisclosureError::PushManifestMismatch)
15651        );
15652    }
15653
15654    #[test]
15655    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15656        let fixture = signed_remote_fixture();
15657        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15658        let item = feed["entries"][0].to_string();
15659        let oversized_page = format!(
15660            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15661            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15662                .collect::<Vec<_>>()
15663                .join(",")
15664        );
15665        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15666
15667        let oversized_identity = format!(
15668            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15669            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15670                .collect::<Vec<_>>()
15671                .join(",")
15672        );
15673        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15674
15675        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15676        let oversized_entry = format!(
15677            "{{\"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\"}}",
15678            "a".repeat(64),
15679            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15680                .collect::<Vec<_>>()
15681                .join(",")
15682        );
15683        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15684    }
15685
15686    #[test]
15687    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15688        let id = "01arz3ndektsv4rrffq69g5fav";
15689        let digest = "a".repeat(64);
15690        assert_eq!(
15691            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15692            V2BulkConfirmation {
15693                id: id.to_string(),
15694                digest,
15695            }
15696        );
15697        for invalid in [
15698            "",
15699            "01arz3ndektsv4rrffq69g5fav",
15700            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15701            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15702            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15703        ] {
15704            assert!(matches!(
15705                V2BulkConfirmation::parse(invalid),
15706                Err(LinkError::InvalidPack { .. })
15707            ));
15708        }
15709    }
15710
15711    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15712        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15713        use std::net::TcpListener;
15714
15715        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15716        let url = format!("http://{}", listener.local_addr().unwrap());
15717        let handle = std::thread::spawn(move || {
15718            for (status, body) in responses {
15719                let (stream, _) = listener.accept().unwrap();
15720                let mut reader = BufReader::new(stream);
15721                let mut line = String::new();
15722                reader.read_line(&mut line).unwrap();
15723                let mut content_length = 0usize;
15724                loop {
15725                    line.clear();
15726                    reader.read_line(&mut line).unwrap();
15727                    if line == "\r\n" || line == "\n" || line.is_empty() {
15728                        break;
15729                    }
15730                    if let Some((name, value)) = line.split_once(':') {
15731                        if name.eq_ignore_ascii_case("content-length") {
15732                            content_length = value.trim().parse().unwrap();
15733                        }
15734                    }
15735                }
15736                let mut request_body = vec![0_u8; content_length];
15737                reader.read_exact(&mut request_body).unwrap();
15738                let response = format!(
15739                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15740                    body.len()
15741                );
15742                reader.get_mut().write_all(response.as_bytes()).unwrap();
15743            }
15744        });
15745        (url, handle)
15746    }
15747
15748    fn routed_json_hub(
15749        requests: usize,
15750        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15751    ) -> (String, std::thread::JoinHandle<()>) {
15752        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15753        use std::net::TcpListener;
15754
15755        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15756        let url = format!("http://{}", listener.local_addr().unwrap());
15757        let handle = std::thread::spawn(move || {
15758            for _ in 0..requests {
15759                let (stream, _) = listener.accept().unwrap();
15760                let mut reader = BufReader::new(stream);
15761                let mut line = String::new();
15762                reader.read_line(&mut line).unwrap();
15763                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15764                let mut content_length = 0usize;
15765                loop {
15766                    line.clear();
15767                    reader.read_line(&mut line).unwrap();
15768                    if line == "\r\n" || line == "\n" || line.is_empty() {
15769                        break;
15770                    }
15771                    if let Some((name, value)) = line.split_once(':') {
15772                        if name.eq_ignore_ascii_case("content-length") {
15773                            content_length = value.trim().parse().unwrap();
15774                        }
15775                    }
15776                }
15777                let mut request_body = vec![0_u8; content_length];
15778                reader.read_exact(&mut request_body).unwrap();
15779                let (status, body) = respond(&path);
15780                let response = format!(
15781                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15782                    body.len()
15783                );
15784                reader.get_mut().write_all(response.as_bytes()).unwrap();
15785            }
15786        });
15787        (url, handle)
15788    }
15789
15790    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15791        HubConfig {
15792            hub,
15793            key: Some("test-key".to_string()),
15794            agent_key: None,
15795            brain_key: None,
15796            state_dir,
15797            store_selected: false,
15798        }
15799    }
15800
15801    #[cfg(any(unix, windows))]
15802    #[test]
15803    fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
15804        use std::sync::{Arc, Mutex};
15805
15806        let bytes = b"immutable asset bytes".to_vec();
15807        let sha256 = content_sha256(&bytes);
15808        let commit_hash = "c".repeat(64);
15809        let base_url = Arc::new(Mutex::new(String::new()));
15810        let server_base = Arc::clone(&base_url);
15811        let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15812        let server_attempt = Arc::clone(&object_attempt);
15813        let response_bytes = bytes.clone();
15814        let response_sha = sha256.clone();
15815        let response_commit = commit_hash.clone();
15816        let (hub, server) = routed_json_hub(4, move |path| {
15817            if path.contains("/v2/assets/downloads") {
15818                let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
15819                let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
15820                return (
15821                    200,
15822                    json!({
15823                        "v": 2,
15824                        "commit": response_commit,
15825                        "downloads": [{
15826                            "path": "assets/proof.bin",
15827                            "sha256": response_sha,
15828                            "bytes": response_bytes.len(),
15829                            "url": url,
15830                            "method": "GET"
15831                        }]
15832                    })
15833                    .to_string(),
15834                );
15835            }
15836            let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15837            if attempt == 0 {
15838                (403, "{}".to_string())
15839            } else {
15840                (200, String::from_utf8(response_bytes.clone()).unwrap())
15841            }
15842        });
15843        *base_url.lock().unwrap() = hub.clone();
15844
15845        let temp = tempfile::tempdir().unwrap();
15846        let cache = temp.path().join("cache");
15847        std::fs::create_dir(&cache).unwrap();
15848        let cfg = test_hub_config(hub, temp.path().to_path_buf());
15849        let pointer = V2PointerBody {
15850            v: 2,
15851            brain: TEST_BRAIN_ID.to_string(),
15852            seq: 1,
15853            commit_hash,
15854            feed_hash: "f".repeat(64),
15855            content_root: Some("a".repeat(64)),
15856            asset_root: Some("b".repeat(64)),
15857            materializer: "m".repeat(64),
15858            signer_epoch: 1,
15859            control_revision: "d".repeat(64),
15860            backup_preparation: "ready".to_string(),
15861            prior_pointer_hash: None,
15862            signed_at: "2026-08-23T00:00:00Z".to_string(),
15863        };
15864        let path = "assets/proof.bin".to_string();
15865        let asset = V2BaselineAsset {
15866            blob_sha256: sha256.clone(),
15867            bytes: bytes.len() as u64,
15868            media_type: "application/octet-stream".to_string(),
15869            wrappers: Vec::new(),
15870            required: true,
15871            disposition: "hosted".to_string(),
15872            leaf_hash: "e".repeat(64),
15873        };
15874
15875        let staged = stage_v2_asset_download_window(
15876            &cfg,
15877            TEST_BRAIN_ID,
15878            &pointer,
15879            &cache,
15880            &[(&path, &asset)],
15881        )
15882        .expect("a fresh authority-checked capability recovers an expired one");
15883        assert_eq!(staged.len(), 1);
15884        assert_eq!(staged[0].path, path);
15885        assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
15886        assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
15887        server.join().unwrap();
15888    }
15889
15890    #[cfg(any(unix, windows))]
15891    #[test]
15892    fn asset_capability_windows_cannot_exceed_the_worker_bound() {
15893        let temp = tempfile::tempdir().unwrap();
15894        let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
15895        let pointer = V2PointerBody {
15896            v: 2,
15897            brain: TEST_BRAIN_ID.to_string(),
15898            seq: 1,
15899            commit_hash: "c".repeat(64),
15900            feed_hash: "f".repeat(64),
15901            content_root: Some("a".repeat(64)),
15902            asset_root: Some("b".repeat(64)),
15903            materializer: "m".repeat(64),
15904            signer_epoch: 1,
15905            control_revision: "d".repeat(64),
15906            backup_preparation: "ready".to_string(),
15907            prior_pointer_hash: None,
15908            signed_at: "2026-08-23T00:00:00Z".to_string(),
15909        };
15910        let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
15911            .map(|index| format!("assets/{index}.bin"))
15912            .collect::<Vec<_>>();
15913        let assets = paths
15914            .iter()
15915            .map(|_| V2BaselineAsset {
15916                blob_sha256: "a".repeat(64),
15917                bytes: 1,
15918                media_type: "application/octet-stream".to_string(),
15919                wrappers: Vec::new(),
15920                required: true,
15921                disposition: "hosted".to_string(),
15922                leaf_hash: "b".repeat(64),
15923            })
15924            .collect::<Vec<_>>();
15925        let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
15926
15927        let error =
15928            stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
15929                .expect_err("an oversized window must fail before any network request");
15930        assert!(matches!(error, LinkError::InvalidFeed { .. }));
15931    }
15932
15933    #[test]
15934    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15935        use ring::signature::KeyPair as _;
15936
15937        let rng = ring::rand::SystemRandom::new();
15938        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15939        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15940        let (spki, multikey) = public_identity_for(&pair);
15941        let key = AgentSigningKey {
15942            pkcs8: pkcs8.as_ref().to_vec(),
15943            multikey,
15944            public_key_spki: spki,
15945        };
15946        let header = linkmd_sig_header(
15947            &key,
15948            "https://hub-a.example",
15949            "post",
15950            "/api/hub/brains/brain/push?mode=exact",
15951            Some("{\"ok\":true}"),
15952        )
15953        .unwrap();
15954        assert!(header.starts_with("LinkMD-Sig v2,"));
15955        let ts = header
15956            .split(",ts=")
15957            .nth(1)
15958            .unwrap()
15959            .split(',')
15960            .next()
15961            .unwrap();
15962        let signature = URL_SAFE_NO_PAD
15963            .decode(header.rsplit(",sig=").next().unwrap())
15964            .unwrap();
15965        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15966        let accepted = format!(
15967            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15968        );
15969        let replayed = format!(
15970            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15971        );
15972        let public = pair.public_key().as_ref();
15973        assert!(UnparsedPublicKey::new(&ED25519, public)
15974            .verify(accepted.as_bytes(), &signature)
15975            .is_ok());
15976        assert!(
15977            UnparsedPublicKey::new(&ED25519, public)
15978                .verify(replayed.as_bytes(), &signature)
15979                .is_err(),
15980            "a proof captured at hub A must not authenticate at hub B"
15981        );
15982    }
15983
15984    #[test]
15985    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15986        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15987        let card = json!({
15988            "id": other,
15989            "headSeq": 0,
15990            "identity": signed_remote_fixture().identity,
15991        })
15992        .to_string();
15993        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15994        let state = tempfile::tempdir().unwrap();
15995        let cfg = test_hub_config(hub, state.path().to_path_buf());
15996        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15997        assert!(
15998            error.contains("differs from the explicitly requested"),
15999            "{error}"
16000        );
16001        server.join().unwrap();
16002    }
16003
16004    #[test]
16005    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
16006        let first = signed_remote_fixture().identity;
16007        let second = signed_remote_fixture().identity;
16008        let card = |identity: FeedIdentity| {
16009            json!({
16010                "id": TEST_BRAIN_ID,
16011                "headSeq": 0,
16012                "identity": identity,
16013            })
16014            .to_string()
16015        };
16016        let (hub, server) = scripted_json_hub(vec![
16017            (404, "{}".to_string()),
16018            (200, card(first)),
16019            (404, "{}".to_string()),
16020            (200, card(second)),
16021        ]);
16022        let state = tempfile::tempdir().unwrap();
16023        let cfg = test_hub_config(hub, state.path().to_path_buf());
16024        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16025        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16026        assert!(
16027            error.contains("pinned anchor") || error.contains("forked away"),
16028            "{error}"
16029        );
16030        server.join().unwrap();
16031    }
16032
16033    #[test]
16034    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
16035        let old = signed_remote_fixture();
16036        let new = signed_remote_fixture();
16037        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
16038        let unsigned = serde_json::to_string(&UnsignedRotation {
16039            v: 1,
16040            op: "rotate",
16041            brain: &old.key.multikey,
16042            public_key: &old.key.public_key_spki,
16043            new_brain: &new.key.multikey,
16044            new_public_key: &new.key.public_key_spki,
16045            prior_head_seq: 1,
16046            prior_feed_hash: Some(&"a".repeat(64)),
16047            ts: "2026-07-30T12:00:00.000Z".to_string(),
16048        })
16049        .unwrap();
16050        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
16051        let rotation = format!(
16052            "{},\"sig\":\"{}\"}}",
16053            &unsigned[..unsigned.len() - 1],
16054            signature
16055        );
16056        let identity = FeedIdentity {
16057            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
16058            public_key_spki: new.key.public_key_spki,
16059            previous: vec![PreviousIdentity {
16060                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
16061                public_key_spki: old.key.public_key_spki,
16062            }],
16063            rotations: vec![rotation],
16064        };
16065        let card = json!({
16066            "id": TEST_BRAIN_ID,
16067            "headSeq": 0,
16068            "feedHash": null,
16069            "identity": identity,
16070        })
16071        .to_string();
16072        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16073        let state = tempfile::tempdir().unwrap();
16074        let cfg = test_hub_config(hub, state.path().to_path_buf());
16075        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16076        assert!(
16077            error.contains("rotation claims a feed boundary beyond the advertised head"),
16078            "{error}"
16079        );
16080        assert!(
16081            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
16082            "an inconsistent empty-head identity must not become the TOFU checkpoint"
16083        );
16084        server.join().unwrap();
16085    }
16086
16087    #[test]
16088    fn trust_checkpoint_rejects_a_later_fork() {
16089        let fixture = signed_remote_fixture();
16090        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
16091        fork["feedHash"] = Value::String("b".repeat(64));
16092        let (hub, server) = scripted_json_hub(vec![
16093            (404, "{}".to_string()),
16094            (200, fixture.card),
16095            (200, fixture.feed),
16096            (404, "{}".to_string()),
16097            (200, fork.to_string()),
16098        ]);
16099        let state = tempfile::tempdir().unwrap();
16100        let cfg = test_hub_config(hub, state.path().to_path_buf());
16101        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16102        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
16103        server.join().unwrap();
16104    }
16105
16106    #[test]
16107    fn alias_and_canonical_id_share_one_identity_checkpoint() {
16108        let trusted = signed_remote_fixture();
16109        let attacker = signed_remote_fixture();
16110        let (hub, server) = scripted_json_hub(vec![
16111            (404, "{}".to_string()),
16112            (200, trusted.card),
16113            (200, trusted.feed),
16114            (404, "{}".to_string()),
16115            (200, attacker.card),
16116        ]);
16117        let state = tempfile::tempdir().unwrap();
16118        let cfg = test_hub_config(hub, state.path().to_path_buf());
16119        assert!(head(&cfg, "trusted-slug").unwrap().verified);
16120        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16121        assert!(
16122            error.contains("equivocation")
16123                || error.contains("pinned")
16124                || error.contains("identity"),
16125            "{error}"
16126        );
16127        server.join().unwrap();
16128    }
16129
16130    #[test]
16131    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
16132        let state = tempfile::tempdir().unwrap();
16133        let cfg = test_hub_config(
16134            "https://hub.example".to_string(),
16135            state.path().to_path_buf(),
16136        );
16137        let directory = open_trust_dir(&cfg).unwrap();
16138        let old = TEST_BRAIN_ID;
16139        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16140        save_alias_in(
16141            &cfg,
16142            &directory,
16143            &AliasBinding {
16144                v: 1,
16145                origin: normalized_origin(&cfg.hub).unwrap(),
16146                requested: "company-brain".to_string(),
16147                brain: old.to_string(),
16148                home: Some("company-brain".to_string()),
16149            },
16150        )
16151        .unwrap();
16152
16153        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
16154        assert!(matches!(
16155            error,
16156            LinkError::AliasRebindRequired {
16157                alias,
16158                from,
16159                to
16160            } if alias == "company-brain" && from == old && to == new
16161        ));
16162        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
16163            .unwrap()
16164            .unwrap();
16165        assert_eq!(unchanged.brain, old);
16166        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
16167    }
16168
16169    #[test]
16170    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
16171        let alpha = signed_remote_fixture();
16172        let beta = signed_remote_fixture();
16173        let alpha_card = alpha.card.clone();
16174        let alpha_feed = alpha.feed.clone();
16175        let beta_card = beta.card.clone();
16176        let beta_feed = beta.feed.clone();
16177        let (hub, server) = routed_json_hub(5, move |path| {
16178            if path.ends_with("/v2/head") {
16179                (404, "{}".to_string())
16180            } else if path.contains("/alpha/feed?") {
16181                (200, alpha_feed.clone())
16182            } else if path.contains("/beta/feed?") {
16183                (200, beta_feed.clone())
16184            } else if path.ends_with("/alpha") {
16185                (200, alpha_card.clone())
16186            } else if path.ends_with("/beta") {
16187                (200, beta_card.clone())
16188            } else {
16189                (500, r#"{"error":"unexpected path"}"#.to_string())
16190            }
16191        });
16192        let state = tempfile::tempdir().unwrap();
16193        let cfg = test_hub_config(hub, state.path().to_path_buf());
16194        let alpha_cfg = cfg.clone();
16195        let beta_cfg = cfg;
16196        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
16197        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
16198        let results = [first.join().unwrap(), second.join().unwrap()];
16199        assert_eq!(
16200            results.iter().filter(|result| result.is_ok()).count(),
16201            1,
16202            "only one alias identity may establish canonical TOFU: {results:?}"
16203        );
16204        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
16205        server.join().unwrap();
16206    }
16207
16208    #[cfg(unix)]
16209    #[test]
16210    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
16211        use std::os::unix::fs::symlink;
16212
16213        let fixture = signed_remote_fixture();
16214        let card = json!({
16215            "id": TEST_BRAIN_ID,
16216            "headSeq": 0,
16217            "feedHash": Value::Null,
16218            "identity": fixture.identity,
16219        })
16220        .to_string();
16221        let work = tempfile::tempdir().unwrap();
16222        let outside = tempfile::tempdir().unwrap();
16223        let state = work.path().join("state");
16224        let moved = work.path().join("state-held");
16225        let swap_state = state.clone();
16226        let swap_moved = moved.clone();
16227        let outside_path = outside.path().to_path_buf();
16228        let (hub, server) = routed_json_hub(1, move |_| {
16229            // The client has already opened state/trust before this response.
16230            std::fs::rename(&swap_state, &swap_moved).unwrap();
16231            symlink(&outside_path, &swap_state).unwrap();
16232            (200, card.clone())
16233        });
16234        let cfg = test_hub_config(hub, state);
16235
16236        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
16237        assert_eq!(verified.head.seq, 0);
16238        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
16239        assert!(std::fs::read_dir(moved.join("trust"))
16240            .unwrap()
16241            .flatten()
16242            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
16243        server.join().unwrap();
16244    }
16245
16246    #[test]
16247    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
16248        let remote = signed_remote_fixture();
16249        let unrelated = signed_remote_fixture().key;
16250        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
16251        let state = tempfile::tempdir().unwrap();
16252        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
16253        cfg.brain_key = Some(unrelated);
16254        let error = sync_push(
16255            &cfg,
16256            TEST_BRAIN_ID,
16257            &[("DB.md".to_string(), "signed local content".to_string())],
16258        )
16259        .unwrap_err()
16260        .to_string();
16261        assert!(
16262            error.contains("not the verified current brain identity"),
16263            "{error}"
16264        );
16265        server.join().unwrap();
16266    }
16267
16268    #[test]
16269    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
16270        let remote = signed_remote_fixture();
16271        let new = signed_remote_fixture().key;
16272        let state = tempfile::tempdir().unwrap();
16273        let new_file = state.path().join("new.key");
16274        std::fs::write(
16275            &new_file,
16276            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
16277        )
16278        .unwrap();
16279        #[cfg(unix)]
16280        {
16281            use std::os::unix::fs::PermissionsExt as _;
16282            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
16283        }
16284        let forged = json!({
16285            "brain": TEST_BRAIN_ID,
16286            "identity": {
16287                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
16288                "publicKeySpki": new.public_key_spki,
16289            }
16290        })
16291        .to_string();
16292        let (hub, server) = scripted_json_hub(vec![
16293            (404, "{}".to_string()),
16294            (200, remote.card.clone()),
16295            (200, remote.feed.clone()),
16296            (200, forged),
16297            (200, remote.card),
16298            (200, remote.feed),
16299        ]);
16300        let cfg = test_hub_config(hub, state.path().to_path_buf());
16301        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
16302            .unwrap_err()
16303            .to_string();
16304        assert!(
16305            error.contains("without committing the verified new identity"),
16306            "{error}"
16307        );
16308        server.join().unwrap();
16309    }
16310
16311    #[test]
16312    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
16313        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16314        let raw = format!(
16315            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16316        );
16317        let pack = build_store_pack(&[
16318            (
16319                "DB.md".to_string(),
16320                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
16321            ),
16322            ("records/clients/truth.md".to_string(), raw.clone()),
16323        ])
16324        .unwrap();
16325        let by_id = resolve_from_verified_pack(
16326            "01j5qc3v9k4ym8rwbn2tqe6f7d",
16327            &AddressTarget::Id(record_id.to_string()),
16328            pack.clone(),
16329        )
16330        .unwrap();
16331        assert_eq!(by_id["document"]["summary"], "Signed truth");
16332        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
16333        assert_eq!(
16334            by_id["document"]["contentSha"],
16335            content_sha256(raw.as_bytes())
16336        );
16337
16338        let by_path = resolve_from_verified_pack(
16339            "01j5qc3v9k4ym8rwbn2tqe6f7d",
16340            &AddressTarget::Path("records/clients/truth.md".to_string()),
16341            pack,
16342        )
16343        .unwrap();
16344        assert_eq!(by_path["document"]["id"], record_id);
16345        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
16346
16347        let wrong_id = resolve_from_verified_record_bytes(
16348            TEST_BRAIN_ID,
16349            &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
16350            "records/clients/truth.md".to_string(),
16351            raw.as_bytes().to_vec(),
16352        )
16353        .unwrap_err()
16354        .to_string();
16355        assert!(wrong_id.contains("id differs"), "{wrong_id}");
16356
16357        let wrong_path = resolve_from_verified_record_bytes(
16358            TEST_BRAIN_ID,
16359            &AddressTarget::Path("records/clients/other.md".to_string()),
16360            "records/clients/truth.md".to_string(),
16361            raw.into_bytes(),
16362        )
16363        .unwrap_err()
16364        .to_string();
16365        assert!(wrong_path.contains("path differs"), "{wrong_path}");
16366    }
16367
16368    #[test]
16369    fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
16370        let path = "records/clients/truth.md";
16371        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16372        let raw = format!(
16373            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16374        );
16375        let sha256 = content_sha256(raw.as_bytes());
16376        let mut nonce = 0_u128;
16377        let tree = crate::linkmd_v2::build_content_tree(
16378            &[crate::linkmd_v2::ContentFile {
16379                path: path.to_string(),
16380                blob_hash: sha256.clone(),
16381                bytes: raw.len() as u64,
16382            }],
16383            None,
16384            &mut || {
16385                nonce += 1;
16386                format!("{nonce:032x}")
16387            },
16388        )
16389        .unwrap();
16390        let root = tree.root.clone().unwrap();
16391        let mut directory_root = root.clone();
16392        let mut proof = Vec::new();
16393        for component in path.split('/') {
16394            let inclusion =
16395                crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
16396            let child = match &inclusion {
16397                crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
16398                crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
16399                    panic!("fixture path must have an inclusion proof")
16400                }
16401            };
16402            proof.push(json!({
16403                "directory_root": directory_root,
16404                "component": component,
16405                "proof": inclusion,
16406            }));
16407            directory_root = child;
16408        }
16409        let commit_hash = "c".repeat(64);
16410        let pointer = V2PointerBody {
16411            v: 2,
16412            brain: TEST_BRAIN_ID.to_string(),
16413            seq: 1,
16414            commit_hash: commit_hash.clone(),
16415            feed_hash: "f".repeat(64),
16416            content_root: Some(root.clone()),
16417            asset_root: None,
16418            materializer: "dbmd-projection-v1".to_string(),
16419            signer_epoch: 1,
16420            control_revision: "d".repeat(64),
16421            backup_preparation: "e".repeat(64),
16422            prior_pointer_hash: None,
16423            signed_at: "2026-08-21T12:00:00.000Z".to_string(),
16424        };
16425        let manifest = json!({
16426            "v": 2,
16427            "commit": commit_hash,
16428            "content_root": root,
16429            "files": [{
16430                "path": path,
16431                "sha256": sha256,
16432                "bytes": raw.len(),
16433                "proof": proof,
16434            }],
16435            "next_cursor": Value::Null,
16436        })
16437        .to_string();
16438
16439        let path_manifest = manifest.clone();
16440        let (hub, server) = routed_json_hub(1, move |request| {
16441            assert_eq!(
16442                request,
16443                format!(
16444                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
16445                    "c".repeat(64)
16446                )
16447            );
16448            (200, path_manifest.clone())
16449        });
16450        let state = tempfile::tempdir().unwrap();
16451        let cfg = test_hub_config(hub, state.path().to_path_buf());
16452        let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
16453            .unwrap()
16454            .unwrap();
16455        assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
16456        assert!(by_path.proof.is_some());
16457        server.join().unwrap();
16458
16459        let (hub, server) = routed_json_hub(1, move |request| {
16460            assert_eq!(
16461                request,
16462                format!(
16463                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
16464                    "c".repeat(64)
16465                )
16466            );
16467            (404, r#"{"error":"File not found"}"#.to_string())
16468        });
16469        let state = tempfile::tempdir().unwrap();
16470        let cfg = test_hub_config(hub, state.path().to_path_buf());
16471        assert!(
16472            v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
16473                .unwrap()
16474                .is_none()
16475        );
16476        server.join().unwrap();
16477
16478        let id_manifest = manifest;
16479        let (hub, server) = routed_json_hub(1, move |request| {
16480            assert_eq!(
16481                request,
16482                format!(
16483                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
16484                    "c".repeat(64)
16485                )
16486            );
16487            (200, id_manifest.clone())
16488        });
16489        let state = tempfile::tempdir().unwrap();
16490        let cfg = test_hub_config(hub, state.path().to_path_buf());
16491        let (located_path, by_id) =
16492            v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
16493        assert_eq!(located_path, path);
16494        assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
16495        server.join().unwrap();
16496    }
16497
16498    #[test]
16499    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
16500        let unsorted = vec![
16501            ("records/a.md".to_string(), "alpha\n".to_string()),
16502            ("DB.md".to_string(), "# db\n".to_string()),
16503        ];
16504        let sorted = vec![
16505            ("DB.md".to_string(), "# db\n".to_string()),
16506            ("records/a.md".to_string(), "alpha\n".to_string()),
16507        ];
16508        let pack = build_store_pack(&unsorted).unwrap();
16509
16510        // This digest is shared with the hub implementation. It locks every
16511        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
16512        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
16513        // absence of descriptors/extras/comments/ZIP64.
16514        assert_eq!(pack.len(), 219);
16515        assert_eq!(
16516            content_sha256(&pack),
16517            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
16518        );
16519        assert_eq!(pack, build_store_pack(&sorted).unwrap());
16520        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
16521        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
16522        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
16523
16524        assert_eq!(
16525            parse_store_pack(pack).unwrap(),
16526            vec![
16527                ("DB.md".to_string(), b"# db\n".to_vec()),
16528                ("records/a.md".to_string(), b"alpha\n".to_vec()),
16529            ]
16530        );
16531    }
16532
16533    #[test]
16534    fn canonical_store_pack_validates_every_path_before_writing() {
16535        let duplicate = vec![
16536            ("DB.md".to_string(), "first".to_string()),
16537            ("DB.md".to_string(), "second".to_string()),
16538        ];
16539        assert!(build_store_pack(&duplicate)
16540            .unwrap_err()
16541            .to_string()
16542            .contains("duplicate path"));
16543        assert!(matches!(
16544            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16545            Err(LinkError::UnsafePath { .. })
16546        ));
16547    }
16548
16549    #[test]
16550    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16551        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16552        // One byte stands in for the central directory. The trailer itself is
16553        // structurally valid; the count is the sole reason for refusal.
16554        let mut bytes = vec![0_u8];
16555        let zip64_offset = bytes.len() as u64;
16556        bytes.extend_from_slice(b"PK\x06\x06");
16557        bytes.extend_from_slice(&44_u64.to_le_bytes());
16558        bytes.extend_from_slice(&[0_u8; 12]);
16559        bytes.extend_from_slice(&COUNT.to_le_bytes());
16560        bytes.extend_from_slice(&COUNT.to_le_bytes());
16561        bytes.extend_from_slice(&1_u64.to_le_bytes());
16562        bytes.extend_from_slice(&0_u64.to_le_bytes());
16563        bytes.extend_from_slice(b"PK\x06\x07");
16564        bytes.extend_from_slice(&0_u32.to_le_bytes());
16565        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16566        bytes.extend_from_slice(&1_u32.to_le_bytes());
16567        bytes.extend_from_slice(b"PK\x05\x06");
16568        bytes.extend_from_slice(&0_u16.to_le_bytes());
16569        bytes.extend_from_slice(&0_u16.to_le_bytes());
16570        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16571        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16572        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16573        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16574        bytes.extend_from_slice(&0_u16.to_le_bytes());
16575
16576        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16577            .unwrap_err()
16578            .to_string();
16579        assert!(error.contains("invalid file count"), "{error}");
16580    }
16581
16582    #[test]
16583    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16584        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16585        let mut bytes = vec![0_u8];
16586        let zip64_offset = bytes.len() as u64;
16587        bytes.extend_from_slice(b"PK\x06\x06");
16588        bytes.extend_from_slice(&44_u64.to_le_bytes());
16589        bytes.extend_from_slice(&[0_u8; 12]);
16590        bytes.extend_from_slice(&COUNT.to_le_bytes());
16591        bytes.extend_from_slice(&COUNT.to_le_bytes());
16592        bytes.extend_from_slice(&1_u64.to_le_bytes());
16593        bytes.extend_from_slice(&0_u64.to_le_bytes());
16594        bytes.extend_from_slice(b"PK\x06\x07");
16595        bytes.extend_from_slice(&0_u32.to_le_bytes());
16596        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16597        bytes.extend_from_slice(&1_u32.to_le_bytes());
16598        bytes.extend_from_slice(b"PK\x05\x06");
16599        bytes.extend_from_slice(&0_u16.to_le_bytes());
16600        bytes.extend_from_slice(&0_u16.to_le_bytes());
16601        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16602        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16603        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16604        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16605        bytes.extend_from_slice(&0_u16.to_le_bytes());
16606        // The old parser trusted this last low-count signature, while
16607        // ZipArchive fell back to the real Zip64 directory and allocated for
16608        // COUNT entries. Its central-directory offsets are deliberately fake.
16609        let fake_eocd = bytes.len() as u32;
16610        bytes.extend_from_slice(b"PK\x05\x06");
16611        bytes.extend_from_slice(&0_u16.to_le_bytes());
16612        bytes.extend_from_slice(&0_u16.to_le_bytes());
16613        bytes.extend_from_slice(&1_u16.to_le_bytes());
16614        bytes.extend_from_slice(&1_u16.to_le_bytes());
16615        bytes.extend_from_slice(&0_u32.to_le_bytes());
16616        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16617        bytes.extend_from_slice(&0_u16.to_le_bytes());
16618
16619        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16620            .unwrap_err()
16621            .to_string();
16622        assert!(error.contains("central directory"), "{error}");
16623    }
16624
16625    #[test]
16626    fn strict_http_status_handling_rejects_redirects_without_panicking() {
16627        let error = ensure_ok(
16628            HubResponse {
16629                status: 302,
16630                body: Some(json!({"redirect": "/elsewhere"})),
16631            },
16632            "mutation",
16633        )
16634        .unwrap_err();
16635        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16636
16637        let error = ensure_raw_ok(
16638            RawHubResponse {
16639                status: 302,
16640                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16641            },
16642            "feed",
16643        )
16644        .unwrap_err();
16645        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16646    }
16647
16648    #[cfg(unix)]
16649    #[test]
16650    fn collect_push_files_refuses_external_symlink_and_nested_store() {
16651        use std::os::unix::fs::symlink;
16652
16653        let root = tempfile::tempdir().unwrap();
16654        std::fs::write(
16655            root.path().join("DB.md"),
16656            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16657        )
16658        .unwrap();
16659        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16660
16661        let external = tempfile::tempdir().unwrap();
16662        let secret = external.path().join("secret.md");
16663        std::fs::write(&secret, "TOP SECRET").unwrap();
16664        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16665
16666        let store = Store::open_strict(root.path()).unwrap();
16667        let err = collect_push_files(&store).unwrap_err().to_string();
16668        assert!(err.contains("cannot push"), "{err}");
16669        assert!(
16670            !err.contains("TOP SECRET"),
16671            "external bytes must never leak"
16672        );
16673
16674        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16675        let nested = root.path().join("records/nested");
16676        std::fs::create_dir_all(&nested).unwrap();
16677        std::fs::write(
16678            nested.join("DB.md"),
16679            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16680        )
16681        .unwrap();
16682        let err = collect_push_files(&store).unwrap_err().to_string();
16683        assert!(err.contains("nested db.md store"), "{err}");
16684    }
16685
16686    #[cfg(unix)]
16687    #[test]
16688    fn remote_push_uses_opened_root_after_path_replacement() {
16689        use std::os::unix::fs::symlink;
16690
16691        let sandbox = tempfile::tempdir().unwrap();
16692        let root = sandbox.path().join("store");
16693        std::fs::create_dir_all(root.join("records/notes")).unwrap();
16694        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16695        std::fs::write(
16696            root.join("records/notes/owned.md"),
16697            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16698        )
16699        .unwrap();
16700        let store = Store::open_strict(&root).unwrap();
16701        let detached = sandbox.path().join("detached");
16702        std::fs::rename(&root, &detached).unwrap();
16703
16704        let replacement = sandbox.path().join("replacement");
16705        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16706        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16707        std::fs::write(
16708            replacement.join("records/notes/secret.md"),
16709            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16710        )
16711        .unwrap();
16712        symlink(&replacement, &root).unwrap();
16713
16714        let files = collect_push_files(&store).unwrap();
16715        let wire_text = files
16716            .iter()
16717            .map(|(path, content)| format!("{path}\n{content}"))
16718            .collect::<Vec<_>>()
16719            .join("\n");
16720        assert!(wire_text.contains("owned upload"));
16721        assert!(!wire_text.contains("replacement sentinel"));
16722        assert!(!wire_text.contains("records/notes/secret.md"));
16723
16724        let remote = signed_remote_fixture();
16725        let (hub, server) = scripted_json_hub(vec![
16726            (200, remote.card),
16727            (200, remote.feed),
16728            (200, json!({"ok": true}).to_string()),
16729        ]);
16730        let state = tempfile::tempdir().unwrap();
16731        let cfg = test_hub_config(hub, state.path().to_path_buf());
16732        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16733        assert_eq!(pushed, json!({"ok": true}));
16734        server.join().unwrap();
16735    }
16736
16737    #[test]
16738    fn signed_feed_item_verifies_identity_hash_and_signature() {
16739        use ring::rand::SystemRandom;
16740        use ring::signature::{Ed25519KeyPair, KeyPair};
16741
16742        const PREFIX: &[u8] = &[
16743            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16744        ];
16745        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16746        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16747        let mut spki = PREFIX.to_vec();
16748        spki.extend_from_slice(pair.public_key().as_ref());
16749        let public_key = URL_SAFE_NO_PAD.encode(&spki);
16750        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16751        let mut entry = FeedEntry {
16752            v: 1,
16753            seq: 1,
16754            ts: "2026-07-14T00:00:00.000Z".to_string(),
16755            brain: format!("ed25519:{fingerprint}"),
16756            public_key: public_key.clone(),
16757            kind: "push".to_string(),
16758            op: "snapshot".to_string(),
16759            pack_sha256: "a".repeat(64),
16760            files: vec![FeedFile {
16761                path: "DB.md".to_string(),
16762                sha256: "b".repeat(64),
16763                bytes: 3,
16764            }],
16765            removed: vec![],
16766            prev_entry_hash: None,
16767            sig: String::new(),
16768        };
16769        let unsigned = UnsignedFeedEntry {
16770            v: entry.v,
16771            seq: entry.seq,
16772            ts: &entry.ts,
16773            brain: &entry.brain,
16774            public_key: &entry.public_key,
16775            kind: &entry.kind,
16776            op: &entry.op,
16777            pack_sha256: &entry.pack_sha256,
16778            files: &entry.files,
16779            removed: &entry.removed,
16780            prev_entry_hash: &entry.prev_entry_hash,
16781        };
16782        entry.sig =
16783            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16784        let mut exact = serde_json::to_vec(&entry).unwrap();
16785        exact.push(b'\n');
16786        let item = FeedItem {
16787            hash: format!("{:x}", Sha256::digest(&exact)),
16788            entry,
16789        };
16790        let identity = FeedIdentity {
16791            fingerprint,
16792            public_key_spki: public_key,
16793            previous: Vec::new(),
16794            rotations: Vec::new(),
16795        };
16796        assert!(verify_feed_item(&item, &identity).is_ok());
16797        let mut tampered = item;
16798        tampered.entry.pack_sha256 = "c".repeat(64);
16799        assert!(verify_feed_item(&tampered, &identity).is_err());
16800    }
16801
16802    #[test]
16803    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16804        let rng = ring::rand::SystemRandom::new();
16805        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16806        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16807        let (spki, multikey) = public_identity_for(&pair);
16808        let identity = V2HeadIdentity {
16809            custody: "self".to_string(),
16810            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16811            public_key_spki: spki.clone(),
16812            previous: Vec::new(),
16813            rotations: Vec::new(),
16814        };
16815        let unsigned = json!({
16816            "actor_ref": "a".repeat(64),
16817            "asset_root": Value::Null,
16818            "brain": multikey,
16819            "changes_sha256": "b".repeat(64),
16820            "control_revision": "c".repeat(64),
16821            "materializer": "dbmd-projection-v1",
16822            "op": "changeset",
16823            "parent_asset_root": Value::Null,
16824            "parent_commit": Value::Null,
16825            "parent_root": Value::Null,
16826            "prev_entry_hash": Value::Null,
16827            "public_key": spki,
16828            "seq": 1,
16829            "signer_epoch": 1,
16830            "state_root": "d".repeat(64),
16831            "ts": "2026-08-19T12:00:00.000Z",
16832            "v": 2,
16833            "v1_bridge": {
16834                "feed_hash": "e".repeat(64),
16835                "head_seq": 7,
16836                "pack_sha256": "f".repeat(64),
16837            },
16838        });
16839        let sign_value = |value: Value| {
16840            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16841            let mut object = value.as_object().unwrap().clone();
16842            object.insert(
16843                "sig".to_string(),
16844                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16845            );
16846            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16847        };
16848        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16849
16850        let mut extra = unsigned.clone();
16851        extra
16852            .as_object_mut()
16853            .unwrap()
16854            .insert("future".to_string(), Value::Bool(true));
16855        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16856
16857        let mut missing = unsigned.clone();
16858        missing.as_object_mut().unwrap().remove("v1_bridge");
16859        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16860
16861        let mut invalid_bridge = unsigned;
16862        invalid_bridge.as_object_mut().unwrap().insert(
16863            "v1_bridge".to_string(),
16864            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16865        );
16866        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16867    }
16868
16869    #[test]
16870    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16871        let vector: Value = serde_json::from_str(include_str!(
16872            "../tests/vectors/linkmd-v2-commit-bridge.json"
16873        ))
16874        .unwrap();
16875        let identity_value = vector.get("identity").unwrap();
16876        let identity = V2HeadIdentity {
16877            custody: "self".to_string(),
16878            fingerprint: identity_value
16879                .get("fingerprint")
16880                .and_then(Value::as_str)
16881                .unwrap()
16882                .to_string(),
16883            public_key_spki: identity_value
16884                .get("public_key_spki")
16885                .and_then(Value::as_str)
16886                .unwrap()
16887                .to_string(),
16888            previous: Vec::new(),
16889            rotations: Vec::new(),
16890        };
16891        let private = URL_SAFE_NO_PAD
16892            .decode(
16893                identity_value
16894                    .get("private_key_pkcs8")
16895                    .and_then(Value::as_str)
16896                    .unwrap(),
16897            )
16898            .unwrap();
16899        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16900            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16901            .unwrap();
16902        let base = vector.get("body").unwrap().as_object().unwrap();
16903
16904        for item in vector.get("valid").unwrap().as_array().unwrap() {
16905            let mut body = base.clone();
16906            body.insert(
16907                "v1_bridge".to_string(),
16908                item.get("v1_bridge").unwrap().clone(),
16909            );
16910            body.insert(
16911                "sig".to_string(),
16912                item.get("signature_base64url").unwrap().clone(),
16913            );
16914            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16915            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16916            assert_eq!(
16917                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16918                item.get("commit_hash").and_then(Value::as_str).unwrap()
16919            );
16920            assert_eq!(
16921                format!("{:x}", Sha256::digest(&signed)),
16922                item.get("feed_hash").and_then(Value::as_str).unwrap()
16923            );
16924        }
16925
16926        for item in vector.get("invalid").unwrap().as_array().unwrap() {
16927            let mut body = base.clone();
16928            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16929                for field in remove {
16930                    body.remove(field.as_str().unwrap());
16931                }
16932            }
16933            if let Some(set) = item.get("set").and_then(Value::as_object) {
16934                for (field, value) in set {
16935                    body.insert(field.clone(), value.clone());
16936                }
16937            }
16938            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16939            body.insert(
16940                "sig".to_string(),
16941                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16942            );
16943            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16944            assert!(
16945                verified_v2_commit_object(&signed, &identity).is_err(),
16946                "accepted invalid shared vector {}",
16947                item.get("reason").and_then(Value::as_str).unwrap()
16948            );
16949        }
16950    }
16951
16952    #[test]
16953    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16954        let vector: Value = serde_json::from_str(include_str!(
16955            "../tests/vectors/linkmd-v2-changeset-withheld.json"
16956        ))
16957        .unwrap();
16958        assert_eq!(
16959            vector.get("profile").and_then(Value::as_str),
16960            Some("link.md-v2-changeset-withheld")
16961        );
16962        let canonical =
16963            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16964        let expected = STANDARD
16965            .decode(
16966                vector
16967                    .get("canonical_base64")
16968                    .and_then(Value::as_str)
16969                    .unwrap(),
16970            )
16971            .unwrap();
16972        assert_eq!(canonical, expected);
16973        assert_eq!(
16974            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16975            vector.get("domain_hash").and_then(Value::as_str).unwrap()
16976        );
16977    }
16978
16979    #[test]
16980    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16981        let remote = signed_remote_fixture();
16982        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16983        let legacy_item = legacy.entries.first().unwrap();
16984        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16985        let body = json!({
16986            "actor_ref": "a".repeat(64),
16987            "asset_root": Value::Null,
16988            "brain": remote.key.multikey,
16989            "changes_sha256": "b".repeat(64),
16990            "control_revision": "c".repeat(64),
16991            "materializer": "dbmd-projection-v1",
16992            "op": "changeset",
16993            "parent_asset_root": Value::Null,
16994            "parent_commit": Value::Null,
16995            "parent_root": Value::Null,
16996            "prev_entry_hash": Value::Null,
16997            "public_key": remote.key.public_key_spki,
16998            "seq": 1,
16999            "signer_epoch": 1,
17000            "state_root": "d".repeat(64),
17001            "ts": "2026-08-19T12:00:00.000Z",
17002            "v": 2,
17003            "v1_bridge": {
17004                "feed_hash": legacy_item.hash,
17005                "head_seq": legacy_item.entry.seq,
17006                "pack_sha256": legacy_item.entry.pack_sha256,
17007            },
17008        });
17009        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
17010        let mut signed = body.as_object().unwrap().clone();
17011        signed.insert(
17012            "sig".to_string(),
17013            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17014        );
17015        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
17016        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
17017        let feed_hash = content_sha256(&raw);
17018        let pointer = V2PointerBody {
17019            v: 2,
17020            brain: TEST_BRAIN_ID.to_string(),
17021            seq: 1,
17022            commit_hash: commit_hash.clone(),
17023            feed_hash: feed_hash.clone(),
17024            content_root: Some("d".repeat(64)),
17025            asset_root: None,
17026            materializer: "dbmd-projection-v1".to_string(),
17027            signer_epoch: 1,
17028            control_revision: "c".repeat(64),
17029            backup_preparation: "e".repeat(64),
17030            prior_pointer_hash: None,
17031            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
17032        };
17033        let v2_page = json!({
17034            "v": 2,
17035            "head_seq": 1,
17036            "head_commit_hash": commit_hash,
17037            "head_feed_hash": feed_hash,
17038            "entries": [{
17039                "seq": 1,
17040                "commit_hash": pointer.commit_hash,
17041                "feed_hash": pointer.feed_hash,
17042                "bytes_base64": STANDARD.encode(&raw),
17043            }],
17044            "next_after": 1,
17045            "complete": true,
17046        })
17047        .to_string();
17048        let identity = V2HeadIdentity {
17049            custody: "self".to_string(),
17050            fingerprint: remote.identity.fingerprint.clone(),
17051            public_key_spki: remote.identity.public_key_spki.clone(),
17052            previous: Vec::new(),
17053            rotations: Vec::new(),
17054        };
17055        let checkpoint = TrustState {
17056            v: 2,
17057            origin: "unused".to_string(),
17058            requested: TEST_BRAIN_ID.to_string(),
17059            brain: TEST_BRAIN_ID.to_string(),
17060            home: None,
17061            anchor: remote.key.multikey.clone(),
17062            current: remote.key.multikey,
17063            head_seq: legacy_item.entry.seq,
17064            feed_hash: Some(legacy_item.hash.clone()),
17065            rotations: Vec::new(),
17066            hub_signer: None,
17067            protocol_profile: None,
17068        };
17069        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
17070        let state = tempfile::tempdir().unwrap();
17071        let cfg = test_hub_config(hub, state.path().to_path_buf());
17072        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
17073        server.join().unwrap();
17074
17075        let mut wrong = checkpoint;
17076        wrong.feed_hash = Some("0".repeat(64));
17077        let (hub, server) = scripted_json_hub(vec![(
17078            200,
17079            json!({
17080                "v": 2,
17081                "head_seq": 1,
17082                "head_commit_hash": pointer.commit_hash,
17083                "head_feed_hash": pointer.feed_hash,
17084                "entries": [{
17085                    "seq": 1,
17086                    "commit_hash": pointer.commit_hash,
17087                    "feed_hash": pointer.feed_hash,
17088                    "bytes_base64": STANDARD.encode(&raw),
17089                }],
17090                "next_after": 1,
17091                "complete": true,
17092            })
17093            .to_string(),
17094        )]);
17095        let state = tempfile::tempdir().unwrap();
17096        let cfg = test_hub_config(hub, state.path().to_path_buf());
17097        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
17098        server.join().unwrap();
17099    }
17100
17101    #[test]
17102    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
17103        let rng = ring::rand::SystemRandom::new();
17104        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17105        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17106        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17107        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17108        let (old_spki, old_multikey) = public_identity_for(&old);
17109        let (new_spki, new_multikey) = public_identity_for(&new);
17110        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
17111            v: 1,
17112            op: "rotate",
17113            brain: &old_multikey,
17114            public_key: &old_spki,
17115            new_brain: &new_multikey,
17116            new_public_key: &new_spki,
17117            prior_head_seq: 1,
17118            prior_feed_hash: Some(&"9".repeat(64)),
17119            ts: "2026-08-19T12:01:00.000Z".to_string(),
17120        })
17121        .unwrap();
17122        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
17123        let rotation = format!(
17124            "{},\"sig\":\"{}\"}}",
17125            &rotation_unsigned[..rotation_unsigned.len() - 1],
17126            rotation_sig
17127        );
17128        let identity = V2HeadIdentity {
17129            custody: "self".to_string(),
17130            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17131            public_key_spki: new_spki.clone(),
17132            previous: vec![V2PreviousIdentity {
17133                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17134                public_key_spki: old_spki.clone(),
17135            }],
17136            rotations: vec![rotation],
17137        };
17138        let commit = |seq: u64,
17139                      epoch: u64,
17140                      multikey: &str,
17141                      spki: &str,
17142                      pair: &ring::signature::Ed25519KeyPair| {
17143            let value = json!({
17144                "actor_ref": "a".repeat(64),
17145                "asset_root": Value::Null,
17146                "brain": multikey,
17147                "changes_sha256": "b".repeat(64),
17148                "control_revision": "c".repeat(64),
17149                "materializer": "dbmd-projection-v1",
17150                "op": "changeset",
17151                "parent_asset_root": Value::Null,
17152                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
17153                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
17154                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
17155                "public_key": spki,
17156                "seq": seq,
17157                "signer_epoch": epoch,
17158                "state_root": "1".repeat(64),
17159                "ts": "2026-08-19T12:00:00.000Z",
17160                "v": 2,
17161                "v1_bridge": Value::Null,
17162            });
17163            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17164            let mut object = value.as_object().unwrap().clone();
17165            object.insert(
17166                "sig".to_string(),
17167                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17168            );
17169            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17170        };
17171
17172        assert!(verified_v2_commit_object(
17173            &commit(1, 1, &old_multikey, &old_spki, &old),
17174            &identity,
17175        )
17176        .is_ok());
17177        assert!(verified_v2_commit_object(
17178            &commit(2, 2, &new_multikey, &new_spki, &new),
17179            &identity,
17180        )
17181        .is_ok());
17182        assert!(verified_v2_commit_object(
17183            &commit(2, 1, &old_multikey, &old_spki, &old),
17184            &identity,
17185        )
17186        .is_err());
17187        assert!(verified_v2_commit_object(
17188            &commit(1, 2, &new_multikey, &new_spki, &new),
17189            &identity,
17190        )
17191        .is_err());
17192    }
17193
17194    #[test]
17195    fn a_self_custody_entry_verifies_like_any_hub_entry() {
17196        let rng = ring::rand::SystemRandom::new();
17197        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17198        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17199        let (spki, multikey) = public_identity_for(&pair);
17200        let key = AgentSigningKey {
17201            pkcs8: pkcs8.as_ref().to_vec(),
17202            multikey: multikey.clone(),
17203            public_key_spki: spki.clone(),
17204        };
17205        let files = vec![WireFeedFile {
17206            path: "DB.md".to_string(),
17207            sha256: "a".repeat(64),
17208            bytes: 3,
17209        }];
17210        let raw = self_custody_entry(
17211            &key,
17212            1,
17213            "2026-07-23T12:00:00.000Z".to_string(),
17214            &"c".repeat(64),
17215            &files,
17216            None,
17217        )
17218        .unwrap();
17219        // The exact client serialization parses as a feed entry and passes the
17220        // SAME verifier every subscribe read runs — the self-custody path
17221        // produces first-class wire-profile-v1 entries.
17222        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
17223        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
17224        let item = FeedItem { hash, entry };
17225        let identity = FeedIdentity {
17226            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17227            public_key_spki: spki,
17228            previous: Vec::new(),
17229            rotations: Vec::new(),
17230        };
17231        assert!(verify_feed_item(&item, &identity).is_ok());
17232    }
17233
17234    #[test]
17235    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
17236        let rng = ring::rand::SystemRandom::new();
17237        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17238        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17239        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17240        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17241        let (old_spki, old_multikey) = public_identity_for(&old);
17242        let (new_spki, new_multikey) = public_identity_for(&new);
17243        let unsigned = serde_json::to_string(&UnsignedRotation {
17244            v: 1,
17245            op: "rotate",
17246            brain: &old_multikey,
17247            public_key: &old_spki,
17248            new_brain: &new_multikey,
17249            new_public_key: &new_spki,
17250            prior_head_seq: 1,
17251            prior_feed_hash: Some(&"a".repeat(64)),
17252            ts: "2026-07-30T12:00:00.000Z".to_string(),
17253        })
17254        .unwrap();
17255        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
17256        let rotation = format!(
17257            "{},\"sig\":\"{}\"}}",
17258            &unsigned[..unsigned.len() - 1],
17259            signature
17260        );
17261        let identity = FeedIdentity {
17262            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17263            public_key_spki: new_spki,
17264            previous: vec![PreviousIdentity {
17265                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17266                public_key_spki: old_spki,
17267            }],
17268            rotations: vec![rotation],
17269        };
17270        let pin = TrustState {
17271            v: 2,
17272            origin: "https://hub.example".to_string(),
17273            requested: "brain".to_string(),
17274            brain: "brain".to_string(),
17275            home: None,
17276            anchor: old_multikey.clone(),
17277            current: old_multikey.clone(),
17278            head_seq: 1,
17279            feed_hash: Some("a".repeat(64)),
17280            rotations: Vec::new(),
17281            hub_signer: None,
17282            protocol_profile: None,
17283        };
17284        assert_eq!(
17285            verify_identity_chain(&identity, Some(&pin)).unwrap(),
17286            old_multikey
17287        );
17288        let mut accepted = pin.clone();
17289        accepted.current = new_multikey.clone();
17290        accepted.rotations = identity.rotations.clone();
17291        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
17292            v: 1,
17293            op: "rotate",
17294            brain: &old_multikey,
17295            public_key: &identity.previous[0].public_key_spki,
17296            new_brain: &new_multikey,
17297            new_public_key: &identity.public_key_spki,
17298            prior_head_seq: 1,
17299            prior_feed_hash: Some(&"a".repeat(64)),
17300            ts: "2026-07-30T12:00:01.000Z".to_string(),
17301        })
17302        .unwrap();
17303        let alternate_signature =
17304            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
17305        let mut rewritten = identity.clone();
17306        rewritten.rotations[0] = format!(
17307            "{},\"sig\":\"{}\"}}",
17308            &alternate_unsigned[..alternate_unsigned.len() - 1],
17309            alternate_signature
17310        );
17311        assert!(
17312            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
17313            "an alternate valid statement must not rewrite accepted history"
17314        );
17315
17316        let mut stale_entry = FeedEntry {
17317            v: 1,
17318            seq: 2,
17319            ts: "2026-07-30T12:01:00.000Z".to_string(),
17320            brain: pin.current.clone(),
17321            public_key: identity.previous[0].public_key_spki.clone(),
17322            kind: "push".to_string(),
17323            op: "snapshot".to_string(),
17324            pack_sha256: "b".repeat(64),
17325            files: Vec::new(),
17326            removed: Vec::new(),
17327            prev_entry_hash: pin.feed_hash.clone(),
17328            sig: String::new(),
17329        };
17330        let stale_unsigned = UnsignedFeedEntry {
17331            v: stale_entry.v,
17332            seq: stale_entry.seq,
17333            ts: &stale_entry.ts,
17334            brain: &stale_entry.brain,
17335            public_key: &stale_entry.public_key,
17336            kind: &stale_entry.kind,
17337            op: &stale_entry.op,
17338            pack_sha256: &stale_entry.pack_sha256,
17339            files: &stale_entry.files,
17340            removed: &stale_entry.removed,
17341            prev_entry_hash: &stale_entry.prev_entry_hash,
17342        };
17343        stale_entry.sig = URL_SAFE_NO_PAD.encode(
17344            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
17345                .as_ref(),
17346        );
17347        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
17348        stale_exact.push(b'\n');
17349        let stale_item = FeedItem {
17350            hash: content_sha256(&stale_exact),
17351            entry: stale_entry,
17352        };
17353        assert!(
17354            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
17355                .is_err(),
17356            "a key retired before the checkpoint must never append after it"
17357        );
17358        assert!(
17359            verify_feed_item(&stale_item, &identity).is_err(),
17360            "an old key must never append after its signed rotation boundary"
17361        );
17362
17363        let mut missing = identity.clone();
17364        missing.rotations.clear();
17365        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
17366
17367        let mut tampered = identity;
17368        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
17369        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
17370    }
17371
17372    #[cfg(unix)]
17373    #[test]
17374    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
17375        use std::os::unix::fs::symlink;
17376
17377        let dir = tempfile::tempdir().unwrap();
17378        let target = dir.path().join("valuable.txt");
17379        let planted = dir.path().join("agent.key");
17380        std::fs::write(&target, "do not overwrite").unwrap();
17381        symlink(&target, &planted).unwrap();
17382
17383        assert!(matches!(
17384            generate_agent_key(&planted),
17385            Err(LinkError::BadAgentKey { .. })
17386        ));
17387        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
17388    }
17389
17390    #[cfg(unix)]
17391    #[test]
17392    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
17393        use std::os::unix::fs::symlink;
17394
17395        let root = tempfile::tempdir().unwrap();
17396        let outside = tempfile::tempdir().unwrap();
17397        symlink(outside.path(), root.path().join("redirect")).unwrap();
17398
17399        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
17400        assert!(!outside.path().join("agent.key").exists());
17401    }
17402
17403    // ── Address parsing ─────────────────────────────────────────────────────
17404
17405    #[test]
17406    fn address_bare_brain_with_and_without_sigil() {
17407        for raw in ["@acme-ops", "acme-ops"] {
17408            let a = Address::parse(raw).expect(raw);
17409            assert_eq!(a.brain, "acme-ops");
17410            assert_eq!(a.target, None);
17411        }
17412    }
17413
17414    #[test]
17415    fn address_ulid_target_parses_as_id() {
17416        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
17417        assert_eq!(a.brain, "acme");
17418        assert_eq!(
17419            a.target,
17420            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
17421        );
17422    }
17423
17424    #[test]
17425    fn address_md_path_target_parses_as_path() {
17426        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
17427        assert_eq!(
17428            a.target,
17429            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
17430        );
17431    }
17432
17433    #[test]
17434    fn address_rejects_malformed_forms() {
17435        for raw in [
17436            "",
17437            "@",
17438            "@/x",
17439            "@acme/",
17440            "@acme/../etc/passwd",
17441            "@acme/records/.hidden.md",
17442            "@ACME",             // uppercase is not a hub ref shape
17443            "@acme/notes/x.txt", // target is neither ULID nor .md path
17444            "@a b",              // whitespace
17445        ] {
17446            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
17447        }
17448    }
17449
17450    // ── Path safety ─────────────────────────────────────────────────────────
17451
17452    #[test]
17453    fn safe_paths_accept_store_shapes_and_reject_escapes() {
17454        for ok in [
17455            "DB.md",
17456            "assets.jsonl",
17457            "records/clients/lumio.md",
17458            "sources/emails/2026/07/x.md",
17459        ] {
17460            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
17461        }
17462        for bad in [
17463            "",
17464            "/etc/passwd",
17465            "../up.md",
17466            "records/../../up.md",
17467            "records//x.md",
17468            ".dbmd/config",
17469            "records/.hidden/x.md",
17470            "records/a b.md",
17471            "records\\win.md",
17472        ] {
17473            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
17474        }
17475    }
17476
17477    #[cfg(unix)]
17478    #[test]
17479    fn opened_destination_capability_survives_an_ancestor_path_swap() {
17480        use std::os::unix::fs::symlink;
17481
17482        let work = tempfile::tempdir().unwrap();
17483        let outside = tempfile::tempdir().unwrap();
17484        let original = work.path().join("destination");
17485        let moved = work.path().join("destination-moved");
17486        let directory = open_or_create_dir_nofollow(&original).unwrap();
17487
17488        std::fs::rename(&original, &moved).unwrap();
17489        symlink(outside.path(), &original).unwrap();
17490        write_pull_entries_beneath_dir(
17491            &directory,
17492            &[("records/note.md".to_string(), b"held inode".to_vec())],
17493        )
17494        .unwrap();
17495
17496        assert_eq!(
17497            std::fs::read(moved.join("records/note.md")).unwrap(),
17498            b"held inode"
17499        );
17500        assert!(!outside.path().join("records/note.md").exists());
17501    }
17502
17503    // ── Config resolution (flag + file precedence; env is covered by the CLI
17504    //    integration tests, where a child process isolates it) ───────────────
17505
17506    #[test]
17507    fn hub_config_flag_beats_file_and_requires_some_source() {
17508        let dir = tempfile::tempdir().unwrap();
17509        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
17510        std::fs::write(
17511            dir.path().join(CONFIG_REL_PATH),
17512            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
17513        )
17514        .unwrap();
17515
17516        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
17517        assert_eq!(from_flag.hub, "https://flag.example.com");
17518
17519        let from_file = hub_config(None, dir.path()).unwrap();
17520        assert_eq!(from_file.hub, "https://file.example.com");
17521
17522        let none = hub_config(None, tempfile::tempdir().unwrap().path());
17523        assert!(matches!(none, Err(LinkError::NoHub)));
17524    }
17525
17526    #[test]
17527    fn https_guard_allows_loopback_only_for_plain_http() {
17528        assert!(assert_safe_hub("https://hub.example.com").is_ok());
17529        assert!(assert_safe_hub("http://localhost:3000").is_ok());
17530        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
17531        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
17532        assert!(matches!(
17533            assert_safe_hub("http://hub.example.com"),
17534            Err(LinkError::UnsafeHub { .. })
17535        ));
17536        assert!(matches!(
17537            assert_safe_hub("hub.example.com"),
17538            Err(LinkError::UnsafeHub { .. })
17539        ));
17540        assert!(matches!(
17541            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
17542            Err(LinkError::UnsafeHub { .. })
17543        ));
17544        assert!(matches!(
17545            assert_safe_hub("https://hub.example.com@attacker.example"),
17546            Err(LinkError::UnsafeHub { .. })
17547        ));
17548        assert!(matches!(
17549            assert_safe_hub("https://hub.example.com/base"),
17550            Err(LinkError::UnsafeHub { .. })
17551        ));
17552    }
17553
17554    #[test]
17555    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17556        for blocked in [
17557            "127.0.0.1",
17558            "10.0.0.1",
17559            "100.64.0.1",
17560            "169.254.169.254",
17561            "172.16.0.1",
17562            "192.168.0.1",
17563            "192.88.99.1",
17564            "198.18.0.1",
17565            "203.0.113.1",
17566            "::1",
17567            "fe80::1",
17568            "fd00::1",
17569            "2001:db8::1",
17570            "2001:1::1",
17571            "2002:7f00:1::",
17572            "3fff::1",
17573        ] {
17574            assert!(
17575                !is_public_registry_ip(blocked.parse().unwrap()),
17576                "must block {blocked}"
17577            );
17578        }
17579        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17580        assert!(is_public_registry_ip(
17581            "2606:4700:4700::1111".parse().unwrap()
17582        ));
17583        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17584    }
17585
17586    #[test]
17587    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17588        use ureq::Resolver as _;
17589
17590        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17591        let resolver = PinnedRegistryResolver {
17592            netloc: "home.example:443".to_string(),
17593            addresses: vec![pinned],
17594        };
17595        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17596        assert!(resolver.resolve("127.0.0.1:443").is_err());
17597        assert_eq!(
17598            resolver.resolve("home.example:443").unwrap(),
17599            vec![pinned],
17600            "subsequent connects reuse the validated answer instead of DNS"
17601        );
17602    }
17603
17604    #[test]
17605    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17606        let cfg = HubConfig {
17607            hub: "https://hub.example".to_string(),
17608            key: None,
17609            agent_key: None,
17610            brain_key: None,
17611            state_dir: tempfile::tempdir().unwrap().keep(),
17612            store_selected: false,
17613        };
17614        assert!(
17615            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17616            "a production hub must not turn its presigned URL into an SSRF primitive"
17617        );
17618
17619        let store_selected = HubConfig {
17620            hub: "https://127.0.0.1".to_string(),
17621            store_selected: true,
17622            ..cfg
17623        };
17624        assert!(
17625            hub_agent(&store_selected).is_err(),
17626            "bytes in a cloned store must not select a private-network hub"
17627        );
17628    }
17629
17630    #[test]
17631    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17632        assert_eq!(
17633            one_past_bounded_limit(MAX_PACK_BYTES),
17634            Some(MAX_PACK_BYTES + 1),
17635            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17636        );
17637        assert_eq!(
17638            presigned_download_read_limit(),
17639            MAX_PACK_BYTES + 1,
17640            "the presigned reader is capped by the client constant, not a hub response"
17641        );
17642        assert_eq!(
17643            one_past_bounded_limit(u64::MAX),
17644            None,
17645            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17646        );
17647    }
17648
17649    #[test]
17650    fn https_guard_matches_the_scheme_case_insensitively() {
17651        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
17652        // hub is still HTTPS, never a misleading non-HTTPS refusal.
17653        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17654        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17655        // And an uppercase plain-HTTP hub is still refused outside loopback.
17656        assert!(matches!(
17657            assert_safe_hub("HTTP://hub.example.com"),
17658            Err(LinkError::UnsafeHub { .. })
17659        ));
17660    }
17661
17662    #[test]
17663    fn clean_key_refuses_paste_artifacts_without_echoing() {
17664        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
17665        for bad in ["vc account", "vc\naccount", "ключ", ""] {
17666            let err = clean_key(bad).unwrap_err();
17667            assert!(matches!(err, LinkError::BadKey));
17668            assert!(
17669                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17670                "error must not echo the key"
17671            );
17672        }
17673    }
17674
17675    // ── Verb entry gates: refs must never reshape the request path ──────────
17676
17677    /// A config whose hub passes the loopback guard but is never listened on:
17678    /// every refusal below must come from the entry gate BEFORE a request
17679    /// exists — a dial on this dead port would surface `Transport` instead.
17680    fn dead_hub() -> HubConfig {
17681        HubConfig {
17682            hub: "http://127.0.0.1:9".to_string(),
17683            key: Some("k".to_string()),
17684            agent_key: None,
17685            brain_key: None,
17686            state_dir: PathBuf::from("."),
17687            store_selected: false,
17688        }
17689    }
17690
17691    #[test]
17692    fn request_retries_a_connection_failure_before_sending() {
17693        use std::io::{Read as _, Write as _};
17694        use std::net::TcpListener;
17695        use std::thread;
17696        use std::time::Duration;
17697
17698        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17699        let address = probe.local_addr().unwrap();
17700        drop(probe);
17701        let server = thread::spawn(move || {
17702            thread::sleep(Duration::from_millis(40));
17703            let listener = TcpListener::bind(address).unwrap();
17704            let (mut stream, _) = listener.accept().unwrap();
17705            let mut request_bytes = [0_u8; 1024];
17706            let _ = stream.read(&mut request_bytes).unwrap();
17707            stream
17708                .write_all(
17709                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17710                )
17711                .unwrap();
17712        });
17713        let cfg = HubConfig {
17714            hub: format!("http://{address}"),
17715            key: None,
17716            agent_key: None,
17717            brain_key: None,
17718            state_dir: tempfile::tempdir().unwrap().keep(),
17719            store_selected: false,
17720        };
17721
17722        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17723        assert_eq!(response.status, 200);
17724        assert_eq!(response.body, Some(json!({ "ok": true })));
17725        server.join().unwrap();
17726    }
17727
17728    #[test]
17729    fn a_commit_goes_back_for_a_receipt_it_lost() {
17730        use std::io::{Read as _, Write as _};
17731        use std::net::TcpListener;
17732        use std::thread;
17733
17734        // The mutation coordinate is derived from the observed base and the
17735        // exact operation set so a repeat is the SAME effect. A commit that
17736        // lands durably and then loses its connection must therefore be asked
17737        // after, not reported as a failure — which is what happened to a
17738        // 32,338-file store that had already committed.
17739        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17740        let address = listener.local_addr().unwrap();
17741        let server = thread::spawn(move || {
17742            // First attempt: promise a body, hang up mid-way.
17743            let (mut first, _) = listener.accept().unwrap();
17744            let mut bytes = [0_u8; 4096];
17745            let _ = first.read(&mut bytes).unwrap();
17746            first
17747                .write_all(
17748                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17749                )
17750                .unwrap();
17751            drop(first);
17752            // Second attempt: the receipt for the commit that already landed.
17753            let (mut second, _) = listener.accept().unwrap();
17754            let _ = second.read(&mut bytes).unwrap();
17755            second
17756                .write_all(
17757                    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\"}",
17758                )
17759                .unwrap();
17760        });
17761        let cfg = HubConfig {
17762            hub: format!("http://{address}"),
17763            key: Some("k".to_string()),
17764            agent_key: None,
17765            brain_key: None,
17766            state_dir: tempfile::tempdir().unwrap().keep(),
17767            store_selected: false,
17768        };
17769
17770        let response = request_patient(
17771            &cfg,
17772            "POST",
17773            "/api/hub/brains/b/v2/commits",
17774            Some(&json!({ "mutation_id": "dbmd-1" })),
17775            Auth::Required,
17776        )
17777        .expect("the receipt is collected on the second ask");
17778        assert_eq!(response.status, 200);
17779        assert_eq!(
17780            response
17781                .body
17782                .as_ref()
17783                .and_then(|value| value.get("outcome"))
17784                .and_then(Value::as_str),
17785            Some("converged"),
17786            "an already-applied mutation answers with its receipt"
17787        );
17788        server.join().unwrap();
17789    }
17790
17791    #[test]
17792    fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
17793        use std::io::{Read as _, Write as _};
17794        use std::net::TcpListener;
17795        use std::thread;
17796
17797        // A connection that fails while the body is arriving is the same class
17798        // of problem as one that fails before it arrives at all. Typed as a
17799        // bare io error it matched no retry path and named no peer, and one
17800        // stalled body ended a whole migration reporting only "timed out
17801        // reading response".
17802        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17803        let address = listener.local_addr().unwrap();
17804        let server = thread::spawn(move || {
17805            let (mut stream, _) = listener.accept().unwrap();
17806            let mut request_bytes = [0_u8; 1024];
17807            let _ = stream.read(&mut request_bytes).unwrap();
17808            // Promise a hundred bytes, send ten, hang up.
17809            stream
17810                .write_all(
17811                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17812                )
17813                .unwrap();
17814        });
17815        let cfg = HubConfig {
17816            hub: format!("http://{address}"),
17817            key: None,
17818            agent_key: None,
17819            brain_key: None,
17820            state_dir: tempfile::tempdir().unwrap().keep(),
17821            store_selected: false,
17822        };
17823
17824        let error = request(&cfg, "POST", "/truncated", None, Auth::None)
17825            .expect_err("a truncated body must not read as success");
17826        match error {
17827            LinkError::Transport { hub, .. } => {
17828                assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17829            }
17830            other => panic!("expected a transport failure, got {other:?}"),
17831        }
17832        server.join().unwrap();
17833    }
17834
17835    #[test]
17836    fn a_safe_get_retries_when_its_body_dies_mid_stream() {
17837        use std::io::{Read as _, Write as _};
17838        use std::net::{TcpListener, TcpStream};
17839        use std::thread;
17840
17841        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17842        let address = listener.local_addr().unwrap();
17843        let server = thread::spawn(move || {
17844            let read_request = |stream: &mut TcpStream| {
17845                let mut request = Vec::new();
17846                let mut bytes = [0_u8; 1024];
17847                loop {
17848                    let read = stream.read(&mut bytes).unwrap();
17849                    if read == 0 {
17850                        break;
17851                    }
17852                    request.extend_from_slice(&bytes[..read]);
17853                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17854                    else {
17855                        continue;
17856                    };
17857                    let headers = String::from_utf8_lossy(&request[..header_end]);
17858                    let content_length = headers
17859                        .lines()
17860                        .find_map(|line| {
17861                            let (name, value) = line.split_once(':')?;
17862                            name.eq_ignore_ascii_case("content-length")
17863                                .then(|| value.trim().parse::<usize>().ok())
17864                                .flatten()
17865                        })
17866                        .unwrap_or(0);
17867                    if request.len() >= header_end + 4 + content_length {
17868                        break;
17869                    }
17870                }
17871            };
17872            let (mut first, _) = listener.accept().unwrap();
17873            read_request(&mut first);
17874            first
17875                .write_all(
17876                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17877                )
17878                .unwrap();
17879            drop(first);
17880
17881            let (mut second, _) = listener.accept().unwrap();
17882            read_request(&mut second);
17883            second
17884                .write_all(
17885                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17886                )
17887                .unwrap();
17888        });
17889        let cfg = HubConfig {
17890            hub: format!("http://{address}"),
17891            key: None,
17892            agent_key: None,
17893            brain_key: None,
17894            state_dir: tempfile::tempdir().unwrap().keep(),
17895            store_selected: false,
17896        };
17897
17898        let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
17899            .expect("a safe read retries the interrupted body");
17900        assert_eq!(response.status, 200);
17901        assert_eq!(response.body, Some(json!({ "ok": true })));
17902        server.join().unwrap();
17903    }
17904
17905    #[test]
17906    fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
17907        use std::io::{Read as _, Write as _};
17908        use std::net::{TcpListener, TcpStream};
17909        use std::thread;
17910
17911        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17912        let address = listener.local_addr().unwrap();
17913        let server = thread::spawn(move || {
17914            let read_request = |stream: &mut TcpStream| {
17915                let mut request = Vec::new();
17916                let mut bytes = [0_u8; 1024];
17917                loop {
17918                    let read = stream.read(&mut bytes).unwrap();
17919                    if read == 0 {
17920                        break;
17921                    }
17922                    request.extend_from_slice(&bytes[..read]);
17923                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17924                    else {
17925                        continue;
17926                    };
17927                    let headers = String::from_utf8_lossy(&request[..header_end]);
17928                    let content_length = headers
17929                        .lines()
17930                        .find_map(|line| {
17931                            let (name, value) = line.split_once(':')?;
17932                            name.eq_ignore_ascii_case("content-length")
17933                                .then(|| value.trim().parse::<usize>().ok())
17934                                .flatten()
17935                        })
17936                        .unwrap_or(0);
17937                    if request.len() >= header_end + 4 + content_length {
17938                        break;
17939                    }
17940                }
17941            };
17942            let (mut first, _) = listener.accept().unwrap();
17943            read_request(&mut first);
17944            first
17945                .write_all(
17946                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17947                )
17948                .unwrap();
17949            drop(first);
17950
17951            let (mut second, _) = listener.accept().unwrap();
17952            read_request(&mut second);
17953            second
17954                .write_all(
17955                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17956                )
17957                .unwrap();
17958        });
17959        let cfg = HubConfig {
17960            hub: format!("http://{address}"),
17961            key: None,
17962            agent_key: None,
17963            brain_key: None,
17964            state_dir: tempfile::tempdir().unwrap().keep(),
17965            store_selected: false,
17966        };
17967
17968        let response = request_raw_retryable_read(
17969            &cfg,
17970            "POST",
17971            "/v2/stream",
17972            Some(&json!({ "files": ["proof"] })),
17973            Auth::None,
17974            1_024,
17975        )
17976        .expect("an explicitly safe POST retries the interrupted body");
17977        assert_eq!(response.status, 200);
17978        assert_eq!(
17979            serde_json::from_slice::<Value>(&response.body).unwrap(),
17980            json!({ "ok": true })
17981        );
17982        server.join().unwrap();
17983    }
17984
17985    #[test]
17986    fn object_store_transport_errors_never_render_presigned_urls() {
17987        use std::net::TcpListener;
17988
17989        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17990        let address = listener.local_addr().unwrap();
17991        drop(listener);
17992        let signature = "do-not-render-this-presigned-signature";
17993        let raw =
17994            format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
17995        let error = ureq::get(&raw)
17996            .timeout(std::time::Duration::from_millis(250))
17997            .call()
17998            .expect_err("the closed local port must fail");
17999        let ureq::Error::Transport(transport) = error else {
18000            panic!("expected a transport failure");
18001        };
18002
18003        let rendered = object_store_transport_error(transport).to_string();
18004        assert!(rendered.contains("the object store"));
18005        assert!(rendered.contains("network error"));
18006        assert!(!rendered.contains(&raw));
18007        assert!(!rendered.contains(signature));
18008        assert!(!rendered.contains("X-Amz-"));
18009    }
18010
18011    #[test]
18012    fn endpoint_cap_refuses_a_body_before_json_parsing() {
18013        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
18014        let cfg = HubConfig {
18015            hub,
18016            key: None,
18017            agent_key: None,
18018            brain_key: None,
18019            state_dir: tempfile::tempdir().unwrap().keep(),
18020            store_selected: false,
18021        };
18022
18023        assert!(matches!(
18024            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
18025            Err(LinkError::ResponseTooLarge { .. })
18026        ));
18027        server.join().unwrap();
18028    }
18029
18030    #[test]
18031    fn overall_deadline_stops_a_dribbled_response_body() {
18032        use std::io::{Read as _, Write as _};
18033        use std::net::TcpListener;
18034        use std::time::{Duration, Instant};
18035
18036        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18037        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
18038        let server = std::thread::spawn(move || {
18039            let (mut stream, _) = listener.accept().unwrap();
18040            let mut request = [0_u8; 1024];
18041            let _ = stream.read(&mut request);
18042            stream
18043                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
18044                .unwrap();
18045            for byte in [b'x'; 32] {
18046                if stream.write_all(&[byte]).is_err() {
18047                    break;
18048                }
18049                std::thread::sleep(Duration::from_millis(40));
18050            }
18051        });
18052        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
18053        let started = Instant::now();
18054        let response = http.get(&url).call().unwrap();
18055        let mut body = Vec::new();
18056        let error = response
18057            .into_reader()
18058            .read_to_end(&mut body)
18059            .expect_err("per-read progress must not reset the overall deadline");
18060        assert!(
18061            started.elapsed() < Duration::from_millis(700),
18062            "dribbled body exceeded the wall-clock budget: {error}"
18063        );
18064        server.join().unwrap();
18065    }
18066
18067    #[test]
18068    fn overall_deadline_stops_a_stalled_upload() {
18069        use std::net::TcpListener;
18070        use std::time::{Duration, Instant};
18071
18072        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18073        let url = format!("http://{}/upload", listener.local_addr().unwrap());
18074        let server = std::thread::spawn(move || {
18075            let (_stream, _) = listener.accept().unwrap();
18076            // Never consume the request body. Once the kernel send buffer fills,
18077            // the client must leave on its absolute write deadline.
18078            std::thread::sleep(Duration::from_millis(600));
18079        });
18080        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
18081        let body = vec![0x5a; 32 * 1024 * 1024];
18082        let started = Instant::now();
18083        let error = http
18084            .put(&url)
18085            .send_bytes(&body)
18086            .expect_err("stalled request-body writes must time out");
18087        assert!(
18088            started.elapsed() < Duration::from_millis(700),
18089            "stalled upload exceeded the wall-clock budget: {error}"
18090        );
18091        server.join().unwrap();
18092    }
18093
18094    #[test]
18095    fn presigned_source_retries_share_one_upload_deadline() {
18096        use std::net::TcpListener;
18097        use std::time::{Duration, Instant};
18098
18099        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18100        let address = listener.local_addr().unwrap();
18101        let signature = "do-not-render-this-stalled-upload-signature";
18102        let url = format!(
18103            "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
18104        );
18105        let server = std::thread::spawn(move || {
18106            let (_stream, _) = listener.accept().unwrap();
18107            // Keep the socket open without consuming the request. The first
18108            // attempt must spend the complete shared budget; retries may not
18109            // each restart that budget.
18110            std::thread::sleep(Duration::from_millis(600));
18111        });
18112
18113        let directory = tempfile::tempdir().unwrap();
18114        std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
18115        std::fs::create_dir(directory.path().join("records")).unwrap();
18116        let relative = "records/stalled.bin";
18117        let bytes = vec![0x5a; 32 * 1024 * 1024];
18118        std::fs::write(directory.path().join(relative), &bytes).unwrap();
18119        let store = Store::open_strict(directory.path()).unwrap();
18120        let cfg = HubConfig {
18121            hub: format!("http://{address}"),
18122            key: None,
18123            agent_key: None,
18124            brain_key: None,
18125            state_dir: tempfile::tempdir().unwrap().keep(),
18126            store_selected: false,
18127        };
18128        let source = V2UploadSource {
18129            path: relative.to_string(),
18130            bytes: bytes.len() as u64,
18131        };
18132
18133        let started = Instant::now();
18134        let error = put_presigned_source_with_budget(
18135            &cfg,
18136            &url,
18137            &json!({ "content-length": source.bytes.to_string() }),
18138            &store,
18139            &source,
18140            None,
18141            Duration::from_millis(150),
18142        )
18143        .expect_err("a black-holed upload must leave at its shared deadline");
18144        assert!(
18145            started.elapsed() < Duration::from_millis(700),
18146            "presigned retries exceeded their shared budget: {error}"
18147        );
18148        let rendered = error.to_string();
18149        assert!(rendered.contains("the object store"));
18150        assert!(!rendered.contains(&url));
18151        assert!(!rendered.contains(signature));
18152        server.join().unwrap();
18153    }
18154
18155    #[test]
18156    fn verb_entry_gates_accept_the_hub_ref_shapes() {
18157        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
18158            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
18159            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
18160            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
18161        }
18162    }
18163
18164    #[test]
18165    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
18166        let cfg = dead_hub();
18167        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
18168            assert!(
18169                matches!(
18170                    sync_pull(&cfg, bad, None),
18171                    Err(LinkError::BadAddress { .. })
18172                ),
18173                "sync_pull must refuse {bad:?}"
18174            );
18175            assert!(
18176                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
18177                "sync_push must refuse {bad:?}"
18178            );
18179            assert!(
18180                matches!(
18181                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
18182                    Err(LinkError::BadAddress { .. })
18183                ),
18184                "grant_issue must refuse {bad:?}"
18185            );
18186            assert!(
18187                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
18188                "grant_list must refuse {bad:?}"
18189            );
18190            assert!(
18191                matches!(
18192                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
18193                    Err(LinkError::BadAddress { .. })
18194                ),
18195                "grant_revoke must refuse brain {bad:?}"
18196            );
18197            assert!(
18198                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
18199                "head must refuse {bad:?}"
18200            );
18201        }
18202    }
18203
18204    #[test]
18205    fn grant_revoke_refuses_url_reshaping_grant_ids() {
18206        let cfg = dead_hub();
18207        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
18208            assert!(
18209                matches!(
18210                    grant_revoke(&cfg, "acme", bad),
18211                    Err(LinkError::BadGrantId { .. })
18212                ),
18213                "grant_revoke must refuse grant id {bad:?}"
18214            );
18215        }
18216    }
18217
18218    #[test]
18219    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
18220        let cfg = dead_hub();
18221        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
18222            assert!(
18223                matches!(
18224                    propose(&cfg, bad, "intake", "hi"),
18225                    Err(LinkError::BadAddress { .. })
18226                ),
18227                "propose must refuse handle {bad:?}"
18228            );
18229        }
18230        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
18231        assert!(matches!(
18232            propose(&cfg, "acme-site", "intake", &oversize),
18233            Err(LinkError::ProposeTooLarge { .. })
18234        ));
18235        // A clean handle + in-cap body passes both gates: the failure is now
18236        // the (dead) wire, proving the gates refuse shape, not the verb.
18237        assert!(matches!(
18238            propose(&cfg, "acme-site", "intake", "hi"),
18239            Err(LinkError::Transport { .. })
18240        ));
18241    }
18242
18243    #[test]
18244    fn resolve_refuses_a_hand_built_unsafe_address() {
18245        let cfg = dead_hub();
18246        for brain in ["../up", "a/b", "a?x", "a#f"] {
18247            let addr = Address {
18248                brain: brain.to_string(),
18249                target: None,
18250            };
18251            assert!(
18252                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18253                "resolve must refuse brain {brain:?}"
18254            );
18255        }
18256        for target in [
18257            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
18258            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
18259            AddressTarget::Path("../up.md".to_string()),
18260            AddressTarget::Path("records/x.md#frag".to_string()),
18261        ] {
18262            let addr = Address {
18263                brain: "acme".to_string(),
18264                target: Some(target.clone()),
18265            };
18266            assert!(
18267                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18268                "resolve must refuse target {target:?}"
18269            );
18270        }
18271    }
18272
18273    #[test]
18274    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
18275        let mut local = std::collections::BTreeMap::new();
18276        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
18277        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
18278        let mut remote = std::collections::BTreeMap::new();
18279        remote.insert(
18280            "records/a.md".to_string(),
18281            V2BaselineFile {
18282                sha256: "c".repeat(64),
18283                bytes: 1,
18284                proof: None,
18285            },
18286        );
18287        remote.insert(
18288            "records/b.md".to_string(),
18289            V2BaselineFile {
18290                sha256: "b".repeat(64),
18291                bytes: 1,
18292                proof: None,
18293            },
18294        );
18295        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18296    }
18297
18298    #[test]
18299    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
18300        let local = std::collections::BTreeMap::new();
18301        let mut remote = std::collections::BTreeMap::new();
18302        remote.insert(
18303            "private/local.md".to_string(),
18304            V2BaselineFile {
18305                sha256: "d".repeat(64),
18306                bytes: 1,
18307                proof: None,
18308            },
18309        );
18310        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
18311        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18312    }
18313
18314    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
18315        V2VerifiedHead {
18316            requested: TEST_BRAIN_ID.to_string(),
18317            brain_id: TEST_BRAIN_ID.to_string(),
18318            view_kind: "scoped".to_string(),
18319            view_revision: revision.to_string(),
18320            control_revision: revision.to_string(),
18321            identity: V2HeadIdentity {
18322                custody: "hub".to_string(),
18323                fingerprint: "test".to_string(),
18324                public_key_spki: "test".to_string(),
18325                previous: Vec::new(),
18326                rotations: Vec::new(),
18327            },
18328            pointer: None,
18329            trust: TrustState {
18330                v: 2,
18331                origin: "https://hub.example".to_string(),
18332                requested: TEST_BRAIN_ID.to_string(),
18333                brain: TEST_BRAIN_ID.to_string(),
18334                home: None,
18335                anchor: "ed25519:test".to_string(),
18336                current: "ed25519:test".to_string(),
18337                head_seq: 0,
18338                feed_hash: None,
18339                rotations: Vec::new(),
18340                hub_signer: None,
18341                protocol_profile: Some("link-v2".to_string()),
18342            },
18343            alias: None,
18344        }
18345    }
18346
18347    #[test]
18348    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
18349        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
18350        assert!(accepted_as_v2(&trust));
18351
18352        trust.protocol_profile = None;
18353        trust.hub_signer = Some("ed25519:hub".to_string());
18354        assert!(accepted_as_v2(&trust));
18355
18356        trust.hub_signer = None;
18357        assert!(!accepted_as_v2(&trust));
18358    }
18359
18360    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
18361        V2SyncBaseline {
18362            v: 2,
18363            origin: "https://hub.example".to_string(),
18364            brain: TEST_BRAIN_ID.to_string(),
18365            checkout_id: Some("c".repeat(64)),
18366            head_seq: Some(0),
18367            commit_hash: None,
18368            content_root: None,
18369            asset_root: None,
18370            assets: std::collections::BTreeMap::new(),
18371            view_kind: Some("scoped".to_string()),
18372            view_revision: Some(revision.to_string()),
18373            control_revision: Some(revision.to_string()),
18374            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
18375            files: std::collections::BTreeMap::new(),
18376            local_policy_digest: None,
18377            local_eligibility: std::collections::BTreeMap::new(),
18378            remote_copy_remains: std::collections::BTreeMap::new(),
18379        }
18380    }
18381
18382    #[test]
18383    fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
18384        let cfg = test_hub_config(
18385            "https://hub.example".to_string(),
18386            tempfile::tempdir().unwrap().keep(),
18387        );
18388        let mut baseline = scoped_test_baseline(&"a".repeat(64));
18389        baseline.assets.insert(
18390            "assets/archive.bin".to_string(),
18391            V2BaselineAsset {
18392                blob_sha256: "b".repeat(64),
18393                bytes: MAX_STORE_BYTES + 1,
18394                media_type: "application/octet-stream".to_string(),
18395                wrappers: vec!["records/archive.md".to_string()],
18396                required: true,
18397                disposition: "hosted".to_string(),
18398                leaf_hash: "c".repeat(64),
18399            },
18400        );
18401
18402        let accepted = serde_json::to_vec(&baseline).unwrap();
18403        assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
18404
18405        baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
18406        let refused = serde_json::to_vec(&baseline).unwrap();
18407        assert!(matches!(
18408            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
18409            Err(LinkError::InvalidFeed { .. })
18410        ));
18411    }
18412
18413    #[test]
18414    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
18415        let directory = tempfile::tempdir().unwrap();
18416        std::fs::write(
18417            directory.path().join("DB.md"),
18418            scoped_projection_bytes(TEST_BRAIN_ID),
18419        )
18420        .unwrap();
18421        let store = Store::open_strict(directory.path()).unwrap();
18422        let head = scoped_test_head(&"a".repeat(64));
18423        let baseline = scoped_test_baseline(&"a".repeat(64));
18424        let mut view = v2_local_files(&store).unwrap();
18425        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
18426        assert!(!view.riding.contains_key("DB.md"));
18427        assert!(!view.eligibility.contains_key("DB.md"));
18428    }
18429
18430    #[test]
18431    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
18432        let directory = tempfile::tempdir().unwrap();
18433        std::fs::write(
18434            directory.path().join("DB.md"),
18435            scoped_projection_bytes(TEST_BRAIN_ID),
18436        )
18437        .unwrap();
18438        let store = Store::open_strict(directory.path()).unwrap();
18439        let head = scoped_test_head(&"a".repeat(64));
18440        let baseline = scoped_test_baseline(&"a".repeat(64));
18441
18442        let mut carried = v2_local_files(&store).unwrap();
18443        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
18444        let handed_off =
18445            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
18446        assert!(!handed_off.riding.contains_key("DB.md"));
18447
18448        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
18449        assert!(!freshly_scanned.riding.contains_key("DB.md"));
18450
18451        std::fs::write(
18452            directory.path().join("DB.md"),
18453            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18454        )
18455        .unwrap();
18456        let tampered = Store::open_strict(directory.path()).unwrap();
18457        assert!(matches!(
18458            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
18459            Err(LinkError::ScopedProjectionModified)
18460        ));
18461    }
18462
18463    #[test]
18464    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
18465        let directory = tempfile::tempdir().unwrap();
18466        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18467        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18468        std::fs::write(
18469            directory.path().join("DB.md"),
18470            b"---\nname: Kept home test\n---\n",
18471        )
18472        .unwrap();
18473        std::fs::write(
18474            directory.path().join("records/notes/a.md"),
18475            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
18476        )
18477        .unwrap();
18478        std::fs::write(
18479            directory.path().join("sources/private/secret.md"),
18480            b"---\ntype: note\n---\nlocal only\n",
18481        )
18482        .unwrap();
18483        std::fs::write(
18484            directory.path().join("sources/private/unlinked.md"),
18485            b"---\ntype: note\n---\nnot disclosed\n",
18486        )
18487        .unwrap();
18488        std::fs::write(
18489            directory.path().join(".sevralocal"),
18490            b"sources/private/**\n",
18491        )
18492        .unwrap();
18493
18494        let store = Store::open_strict(directory.path()).unwrap();
18495        let view = v2_local_files(&store).unwrap();
18496        assert!(!view.riding.contains_key("sources/private/secret.md"));
18497        assert_eq!(
18498            view.withheld_links,
18499            vec![V2WithheldLink {
18500                source: "records/notes/a.md".to_string(),
18501                target: "sources/private/secret.md".to_string(),
18502            }]
18503        );
18504    }
18505
18506    #[test]
18507    fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
18508        // An export omits kept-home content by construction, so a store
18509        // restored from one links to names it does not carry. Without this the
18510        // owner's own export could never be pushed back — the links would read
18511        // as broken and the commit would be refused.
18512        let directory = tempfile::tempdir().unwrap();
18513        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18514        std::fs::write(
18515            directory.path().join("DB.md"),
18516            b"---\nname: Restored export\n---\n",
18517        )
18518        .unwrap();
18519        std::fs::write(
18520            directory.path().join("records/notes/a.md"),
18521            b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
18522        )
18523        .unwrap();
18524        std::fs::write(
18525            directory.path().join(".sevralocal"),
18526            b"sources/private/**\n",
18527        )
18528        .unwrap();
18529
18530        let store = Store::open_strict(directory.path()).unwrap();
18531        let view = v2_local_files(&store).unwrap();
18532        assert_eq!(
18533            view.withheld_links,
18534            vec![V2WithheldLink {
18535                source: "records/notes/a.md".to_string(),
18536                target: "sources/private/absent.md".to_string(),
18537            }]
18538        );
18539        // A dangling link the owner never claimed is still dangling.
18540        std::fs::write(
18541            directory.path().join("records/notes/b.md"),
18542            b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
18543        )
18544        .unwrap();
18545        let store = Store::open_strict(directory.path()).unwrap();
18546        let view = v2_local_files(&store).unwrap();
18547        assert!(
18548            !view
18549                .withheld_links
18550                .iter()
18551                .any(|link| link.target == "records/notes/nowhere.md"),
18552            "an unclaimed dangling target must not be declared withheld"
18553        );
18554    }
18555
18556    #[test]
18557    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
18558        let directory = tempfile::tempdir().unwrap();
18559        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18560        std::fs::write(
18561            directory.path().join("DB.md"),
18562            b"---\nname: Withdrawal test\n---\n",
18563        )
18564        .unwrap();
18565        let source = b"---\ntype: note\n---\nlocal evidence\n";
18566        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
18567        std::fs::write(
18568            directory.path().join(".sevralocal"),
18569            b"sources/private/**\n",
18570        )
18571        .unwrap();
18572        let store = Store::open_strict(directory.path()).unwrap();
18573        let view = v2_local_files(&store).unwrap();
18574        let mut remote = std::collections::BTreeMap::new();
18575        remote.insert(
18576            "sources/private/evidence.md".to_string(),
18577            V2BaselineFile {
18578                sha256: content_sha256(source),
18579                bytes: source.len() as u64,
18580                proof: None,
18581            },
18582        );
18583        assert_eq!(
18584            v2_content_withdrawal_operation(
18585                &store,
18586                &view,
18587                &remote,
18588                "sources/private/evidence.md",
18589                "approved retention change",
18590            )
18591            .unwrap(),
18592            json!({
18593                "op": "withdraw_from_hosting",
18594                "path": "sources/private/evidence.md",
18595                "expected": { "kind": "blob", "hash": content_sha256(source) },
18596                "reason": "approved retention change",
18597            })
18598        );
18599
18600        std::fs::write(
18601            directory.path().join("sources/private/evidence.md"),
18602            b"changed after review",
18603        )
18604        .unwrap();
18605        assert!(matches!(
18606            v2_content_withdrawal_operation(
18607                &store,
18608                &view,
18609                &remote,
18610                "sources/private/evidence.md",
18611                "approved retention change",
18612            ),
18613            Err(LinkError::InvalidPack { .. })
18614        ));
18615    }
18616
18617    #[test]
18618    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
18619        let directory = tempfile::tempdir().unwrap();
18620        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
18621        std::fs::write(
18622            directory.path().join("DB.md"),
18623            b"---\nname: Asset withdrawal test\n---\n",
18624        )
18625        .unwrap();
18626        let bytes = b"private binary";
18627        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
18628        std::fs::write(
18629            directory.path().join(".sevralocal"),
18630            b"sources/files/private.pdf\n",
18631        )
18632        .unwrap();
18633        let store = Store::open_strict(directory.path()).unwrap();
18634        let view = v2_local_files(&store).unwrap();
18635        let local = crate::AssetRecord {
18636            path: "sources/files/private.pdf".to_string(),
18637            sha256: content_sha256(bytes),
18638            bytes: bytes.len() as u64,
18639            media_type: "application/pdf".to_string(),
18640            wrappers: vec!["sources/files/private.md".to_string()],
18641            required: true,
18642        };
18643        let current = V2BaselineAsset {
18644            blob_sha256: local.sha256.clone(),
18645            bytes: local.bytes,
18646            media_type: local.media_type.clone(),
18647            wrappers: local.wrappers.clone(),
18648            required: local.required,
18649            disposition: "hosted".to_string(),
18650            leaf_hash: "d".repeat(64),
18651        };
18652        assert_eq!(
18653            v2_asset_withdrawal_operation(
18654                &store,
18655                &view,
18656                &local.path,
18657                &local,
18658                &current,
18659                "approved retention change",
18660            )
18661            .unwrap(),
18662            json!({
18663                "op": "asset_withdraw",
18664                "path": local.path,
18665                "expected": { "kind": "asset", "hash": "d".repeat(64) },
18666                "reason": "approved retention change",
18667            })
18668        );
18669
18670        let mut mismatched = current.clone();
18671        mismatched.required = false;
18672        assert!(matches!(
18673            v2_asset_withdrawal_operation(
18674                &store,
18675                &view,
18676                &local.path,
18677                &local,
18678                &mismatched,
18679                "approved retention change",
18680            ),
18681            Err(LinkError::InvalidPack { .. })
18682        ));
18683    }
18684
18685    #[test]
18686    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
18687        let first = v2_checkout_id(None).unwrap();
18688        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
18689        assert_ne!(first, v2_checkout_id(None).unwrap());
18690        assert!(is_sha256(&first));
18691    }
18692
18693    #[test]
18694    fn scoped_projection_edit_and_scope_transition_fail_closed() {
18695        let directory = tempfile::tempdir().unwrap();
18696        std::fs::write(
18697            directory.path().join("DB.md"),
18698            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
18699        )
18700        .unwrap();
18701        let store = Store::open_strict(directory.path()).unwrap();
18702        let head = scoped_test_head(&"a".repeat(64));
18703        let baseline = scoped_test_baseline(&"a".repeat(64));
18704        let mut view = v2_local_files(&store).unwrap();
18705        assert!(matches!(
18706            remove_scoped_projection(&head, Some(&baseline), &mut view),
18707            Err(LinkError::ScopedProjectionModified)
18708        ));
18709
18710        let changed = scoped_test_head(&"b".repeat(64));
18711        assert!(matches!(
18712            ensure_v2_view_compatible(&changed, Some(&baseline)),
18713            Err(LinkError::ScopedViewChanged)
18714        ));
18715
18716        let mut same_view_new_control = head.clone();
18717        same_view_new_control.control_revision = "c".repeat(64);
18718        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
18719        assert!(!same_v2_head(&head, &same_view_new_control));
18720    }
18721
18722    #[test]
18723    fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
18724        let mut head = scoped_test_head(&"a".repeat(64));
18725        head.control_revision = "b".repeat(64);
18726        head.pointer = Some(V2PointerBody {
18727            v: 2,
18728            brain: TEST_BRAIN_ID.to_string(),
18729            seq: 7,
18730            commit_hash: "c".repeat(64),
18731            feed_hash: "d".repeat(64),
18732            content_root: Some("e".repeat(64)),
18733            asset_root: Some("f".repeat(64)),
18734            materializer: "dbmd-projection-v1".to_string(),
18735            signer_epoch: 1,
18736            control_revision: head.control_revision.clone(),
18737            backup_preparation: "0".repeat(64),
18738            prior_pointer_hash: Some("1".repeat(64)),
18739            signed_at: "2026-08-24T00:00:00.000Z".to_string(),
18740        });
18741        let mut baseline = scoped_test_baseline(&head.view_revision);
18742        baseline.head_seq = Some(7);
18743        baseline.commit_hash = Some("c".repeat(64));
18744        baseline.content_root = Some("e".repeat(64));
18745        baseline.asset_root = Some("f".repeat(64));
18746        baseline.control_revision = Some(head.control_revision.clone());
18747        assert!(v2_baseline_matches_head(&head, &baseline));
18748
18749        let mut changed = baseline.clone();
18750        changed.head_seq = Some(8);
18751        assert!(!v2_baseline_matches_head(&head, &changed));
18752        let mut changed = baseline.clone();
18753        changed.commit_hash = Some("2".repeat(64));
18754        assert!(!v2_baseline_matches_head(&head, &changed));
18755        let mut changed = baseline.clone();
18756        changed.content_root = Some("3".repeat(64));
18757        assert!(!v2_baseline_matches_head(&head, &changed));
18758        let mut changed = baseline.clone();
18759        changed.asset_root = Some("4".repeat(64));
18760        assert!(!v2_baseline_matches_head(&head, &changed));
18761        let mut changed = baseline.clone();
18762        changed.view_revision = Some("5".repeat(64));
18763        assert!(!v2_baseline_matches_head(&head, &changed));
18764        let mut changed = baseline.clone();
18765        changed.control_revision = Some("6".repeat(64));
18766        assert!(!v2_baseline_matches_head(&head, &changed));
18767
18768        let mut changed_head = head.clone();
18769        changed_head.view_kind = "full".to_string();
18770        assert!(!v2_baseline_matches_head(&changed_head, &baseline));
18771    }
18772
18773    #[test]
18774    fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
18775        let sandbox = tempfile::tempdir().unwrap();
18776        let cfg = test_hub_config(
18777            "https://hub.example".to_string(),
18778            sandbox.path().to_path_buf(),
18779        );
18780        let head = scoped_test_head(&"a".repeat(64));
18781        let baseline = scoped_test_baseline(&head.view_revision);
18782        let mut encoded = serde_json::to_value(&baseline).unwrap();
18783        encoded.as_object_mut().unwrap().remove("control_revision");
18784        let parsed =
18785            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
18786        assert!(parsed.control_revision.is_none());
18787        assert!(!v2_baseline_matches_head(&head, &parsed));
18788    }
18789
18790    #[test]
18791    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
18792        let scoped = scoped_test_head(&"a".repeat(64));
18793        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
18794        assert!(matches!(
18795            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18796            Err(LinkError::ScopedProjectionModified)
18797        ));
18798
18799        let mut full = scoped.clone();
18800        full.view_kind = "full".to_string();
18801        let mut full_baseline = scoped_baseline.clone();
18802        full_baseline.view_kind = Some("full".to_string());
18803        full_baseline.projection_sha256 = None;
18804        assert!(matches!(
18805            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18806            Err(LinkError::InvalidPack { .. })
18807        ));
18808
18809        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18810        assert!(
18811            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18812        );
18813    }
18814
18815    #[test]
18816    fn scoped_view_metadata_is_explicitly_non_authoritative() {
18817        let head = scoped_test_head(&"a".repeat(64));
18818        let value: Value =
18819            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18820        assert_eq!(value["kind"], "link.md-scoped-view");
18821        assert_eq!(value["authoritative"], false);
18822        assert_eq!(value["visible_files"], 7);
18823        assert_eq!(value["brain"], TEST_BRAIN_ID);
18824    }
18825
18826    #[test]
18827    fn local_scoped_marker_requires_the_exact_generated_projection() {
18828        let directory = tempfile::tempdir().unwrap();
18829        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18830        std::fs::write(
18831            directory.path().join("DB.md"),
18832            scoped_projection_bytes(TEST_BRAIN_ID),
18833        )
18834        .unwrap();
18835        let head = scoped_test_head(&"a".repeat(64));
18836        std::fs::write(
18837            directory.path().join(".dbmd/view.json"),
18838            scoped_view_metadata(&head, 0).unwrap(),
18839        )
18840        .unwrap();
18841        let store = Store::open_strict(directory.path()).unwrap();
18842        assert!(has_verified_local_scoped_view(&store));
18843
18844        std::fs::write(
18845            directory.path().join("DB.md"),
18846            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18847        )
18848        .unwrap();
18849        let altered = Store::open_strict(directory.path()).unwrap();
18850        assert!(!has_verified_local_scoped_view(&altered));
18851    }
18852
18853    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18854        use ring::signature::KeyPair as _;
18855
18856        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18857        let rng = ring::rand::SystemRandom::new();
18858        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18859        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18860        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18861        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18862        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18863        let blob = b"new";
18864        let blob_hash = content_sha256(blob);
18865        let changes = json!({
18866            "mutation_id": "sync:proposal-fixture",
18867            "operations": [{
18868                "blob": blob_hash,
18869                "bytes": blob.len(),
18870                "expected": null,
18871                "op": "put",
18872                "path": "records/new.md",
18873            }],
18874            "reason": "fixture",
18875            "v": 2,
18876        });
18877        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18878        let changes_base64 = STANDARD.encode(&changes_bytes);
18879        let descriptor = json!({
18880            "base": null,
18881            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18882            "changes_base64": changes_base64,
18883            "rebase": "strict",
18884            "v": 2,
18885        });
18886        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18887        let payload_hash = "b".repeat(64);
18888        let submitted_at = "2026-08-19T12:00:00.000Z";
18889        let claim = json!({
18890            "actor_root": {
18891                "actor_class": "foreign_key",
18892                "credential": "ed25519:fixture",
18893                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18894                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18895                "principal": "key:fixture",
18896                "role": null,
18897            },
18898            "brain": TEST_BRAIN_ID,
18899            "clear_sha256": clear_hash,
18900            "control_revision": "c".repeat(64),
18901            "mutation_id": "sync:proposal-fixture",
18902            "payload_sha256": payload_hash,
18903            "proposal_id": proposal_id,
18904            "submitted_at": submitted_at,
18905            "v": 2,
18906        });
18907        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
18908        let envelope = json!({
18909            "claim": claim,
18910            "fingerprint": fingerprint,
18911            "public_key": public_key,
18912            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
18913        });
18914        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18915        let submission_hash =
18916            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
18917        let mut head = scoped_test_head(&"c".repeat(64));
18918        head.view_kind = "full".to_string();
18919        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
18920        let value = json!({
18921            "proposal": {
18922                "base": null,
18923                "blobs": [{
18924                    "bytes": blob.len(),
18925                    "endpoint": format!(
18926                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
18927                    ),
18928                    "sha256": blob_hash,
18929                }],
18930                "changes_base64": changes_base64,
18931                "clear_sha256": clear_hash,
18932                "expires_at": "2026-08-26T12:00:00.000Z",
18933                "id": proposal_id,
18934                "payload_sha256": payload_hash,
18935                "proposer": { "class": "foreign_key" },
18936                "rebase": "strict",
18937                "state": "pending",
18938                "submission_claim_base64": STANDARD.encode(envelope_bytes),
18939                "submission_claim_sha256": submission_hash,
18940                "submitted_at": submitted_at,
18941            },
18942            "v": 2,
18943        });
18944        (head, proposal_id, value)
18945    }
18946
18947    #[test]
18948    fn v2_proposal_verifier_accepts_exact_signed_payload() {
18949        let (head, proposal_id, value) = signed_proposal_fixture();
18950        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18951        assert_eq!(verified.blobs.len(), 1);
18952        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18953    }
18954
18955    #[test]
18956    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18957        let (head, proposal_id, value) = signed_proposal_fixture();
18958
18959        let mut changed = value.clone();
18960        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18961        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18962
18963        let mut redirected = value.clone();
18964        redirected["proposal"]["blobs"][0]["endpoint"] =
18965            Value::String("https://attacker.example/blob".to_string());
18966        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18967
18968        let mut forged = value;
18969        let encoded = forged["proposal"]["submission_claim_base64"]
18970            .as_str()
18971            .unwrap();
18972        let mut envelope: Value =
18973            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18974        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18975        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18976        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18977        forged["proposal"]["submission_claim_sha256"] = Value::String(
18978            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18979        );
18980        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18981    }
18982
18983    #[cfg(unix)]
18984    #[test]
18985    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18986        let sandbox = tempfile::tempdir().unwrap();
18987        let destination = sandbox.path().join("brain");
18988        let entries = vec![
18989            (
18990                "DB.md".to_string(),
18991                scoped_projection_bytes(TEST_BRAIN_ID),
18992            ),
18993            (
18994                "records/contacts/a.md".to_string(),
18995                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18996                    .to_vec(),
18997            ),
18998        ];
18999        install_pulled_delta(&destination, &entries, &[], true).unwrap();
19000        assert!(destination.join("index.md").is_file());
19001        assert!(destination.join("records/index.md").is_file());
19002        assert!(destination.join("records/contacts/index.md").is_file());
19003        assert!(destination.join("records/contacts/index.jsonl").is_file());
19004    }
19005
19006    #[cfg(unix)]
19007    #[test]
19008    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
19009        let sandbox = tempfile::tempdir().unwrap();
19010        let destination = sandbox.path().join("brain");
19011        let cache = sandbox.path().join("cache");
19012        std::fs::create_dir(&cache).unwrap();
19013        let db = scoped_projection_bytes(TEST_BRAIN_ID);
19014        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
19015        let db_source = cache.join("db");
19016        let shared_source = cache.join("shared");
19017        crate::fsx::write_atomic(&db_source, &db).unwrap();
19018        crate::fsx::write_atomic(&shared_source, shared).unwrap();
19019        let mut entries = vec![V2StagedFile {
19020            path: "DB.md".to_string(),
19021            source: db_source,
19022            sha256: content_sha256(&db),
19023            bytes: db.len() as u64,
19024        }];
19025        for index in 0..512 {
19026            entries.push(V2StagedFile {
19027                path: format!("records/items/{index:05}.md"),
19028                source: shared_source.clone(),
19029                sha256: content_sha256(shared),
19030                bytes: shared.len() as u64,
19031            });
19032        }
19033        install_pulled_delta_sources(
19034            &destination,
19035            &entries,
19036            &[],
19037            false,
19038            None,
19039            &scoped_test_head(&"c".repeat(64)),
19040        )
19041        .unwrap();
19042        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
19043        for index in 0..512 {
19044            assert_eq!(
19045                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
19046                shared
19047            );
19048        }
19049        assert!(
19050            std::fs::read_dir(sandbox.path())
19051                .unwrap()
19052                .all(|entry| !entry
19053                    .unwrap()
19054                    .file_name()
19055                    .to_string_lossy()
19056                    .contains("pull-stage")),
19057            "the private stage must be atomically installed or removed"
19058        );
19059    }
19060
19061    #[cfg(unix)]
19062    #[test]
19063    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
19064        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
19065
19066        let sandbox = tempfile::tempdir().unwrap();
19067        let root = sandbox.path().join("brain");
19068        std::fs::create_dir_all(root.join("records/items")).unwrap();
19069        let db = scoped_projection_bytes(TEST_BRAIN_ID);
19070        let old = b"---\ntype: note\n---\n\nold\n";
19071        let new = b"---\ntype: note\n---\n\nnew\n";
19072        let removed = b"---\ntype: note\n---\n\nremove me\n";
19073        std::fs::write(root.join("DB.md"), &db).unwrap();
19074        std::fs::write(root.join("records/items/change.md"), old).unwrap();
19075        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
19076        for index in 0..512 {
19077            std::fs::write(
19078                root.join(format!("records/items/untouched-{index:04}.md")),
19079                old,
19080            )
19081            .unwrap();
19082        }
19083        let untouched = root.join("records/items/untouched-0256.md");
19084        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
19085        let source = sandbox.path().join("changed-source");
19086        crate::fsx::write_atomic(&source, new).unwrap();
19087        let same_source = sandbox.path().join("unchanged-source");
19088        crate::fsx::write_atomic(&same_source, old).unwrap();
19089        let same_entry = V2StagedFile {
19090            path: "records/items/change.md".to_string(),
19091            source: same_source,
19092            sha256: content_sha256(old),
19093            bytes: old.len() as u64,
19094        };
19095        let entry = V2StagedFile {
19096            path: "records/items/change.md".to_string(),
19097            source,
19098            sha256: content_sha256(new),
19099            bytes: new.len() as u64,
19100        };
19101        let head = scoped_test_head(&"c".repeat(64));
19102
19103        // A no-op established pull neither clones the checkout nor arms a
19104        // journal. Inode identity is a direct regression guard against the
19105        // former brain-wide private-copy implementation.
19106        install_established_v2_delta(
19107            Store::open_strict(&root).unwrap(),
19108            &[same_entry],
19109            &["records/items/already-absent.md".to_string()],
19110            true,
19111            None,
19112            &head,
19113        )
19114        .unwrap();
19115        assert_eq!(
19116            std::fs::metadata(&untouched).unwrap().ino(),
19117            untouched_inode
19118        );
19119        assert!(!root.join(V2_PULL_JOURNAL).exists());
19120
19121        install_established_v2_delta(
19122            Store::open_strict(&root).unwrap(),
19123            &[entry],
19124            &["records/items/delete.md".to_string()],
19125            false,
19126            None,
19127            &head,
19128        )
19129        .unwrap();
19130        assert_eq!(
19131            std::fs::read(root.join("records/items/change.md")).unwrap(),
19132            new
19133        );
19134        assert!(!root.join("records/items/delete.md").exists());
19135        assert_eq!(
19136            std::fs::metadata(&untouched).unwrap().ino(),
19137            untouched_inode
19138        );
19139        assert!(root.join(V2_PULL_JOURNAL).is_file());
19140        assert_eq!(
19141            std::fs::metadata(root.join(V2_PULL_JOURNAL))
19142                .unwrap()
19143                .permissions()
19144                .mode()
19145                & 0o777,
19146            0o600
19147        );
19148        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
19149            .unwrap()
19150            .unwrap();
19151        assert_eq!(
19152            std::fs::metadata(root.join(&journal.backup_dir))
19153                .unwrap()
19154                .permissions()
19155                .mode()
19156                & 0o777,
19157            0o700
19158        );
19159        for entry in &journal.entries {
19160            if let Some(backup) = &entry.backup {
19161                assert_eq!(
19162                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
19163                        .unwrap()
19164                        .permissions()
19165                        .mode()
19166                        & 0o777,
19167                    0o600
19168                );
19169            }
19170        }
19171
19172        let cfg = test_hub_config(
19173            "https://example.test".to_string(),
19174            sandbox.path().join("state"),
19175        );
19176        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19177        assert_eq!(
19178            std::fs::read(root.join("records/items/change.md")).unwrap(),
19179            old
19180        );
19181        assert_eq!(
19182            std::fs::read(root.join("records/items/delete.md")).unwrap(),
19183            removed
19184        );
19185        assert_eq!(
19186            std::fs::metadata(&untouched).unwrap().ino(),
19187            untouched_inode
19188        );
19189        assert!(!root.join(V2_PULL_JOURNAL).exists());
19190    }
19191
19192    #[test]
19193    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
19194        let body = b"bounded bytes";
19195        let path = "records/example.md".to_string();
19196        let file = V2BaselineFile {
19197            sha256: content_sha256(body),
19198            bytes: body.len() as u64,
19199            proof: None,
19200        };
19201        let header = serde_json::to_vec(&json!({
19202            "bytes": body.len(),
19203            "path": path,
19204            "sha256": file.sha256,
19205            "v": 2,
19206        }))
19207        .unwrap();
19208        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
19209        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
19210        stream.extend_from_slice(&header);
19211        stream.extend_from_slice(body);
19212        stream.extend_from_slice(&0_u32.to_be_bytes());
19213        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
19214        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
19215
19216        let mut tampered = stream.clone();
19217        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
19218        tampered[body_offset] ^= 1;
19219        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
19220
19221        let mut trailing = stream;
19222        trailing.push(0);
19223        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
19224    }
19225
19226    #[test]
19227    fn first_checkout_resolution_does_not_recreate_the_same_conflict() {
19228        let path = "records/value.md".to_string();
19229        let mut local = std::collections::BTreeMap::new();
19230        local.insert(path.clone(), (content_sha256(b"local"), 5));
19231        let mut remote = std::collections::BTreeMap::new();
19232        remote.insert(
19233            path.clone(),
19234            V2BaselineFile {
19235                sha256: content_sha256(b"remote"),
19236                bytes: 6,
19237                proof: None,
19238            },
19239        );
19240
19241        assert_eq!(
19242            v2_initial_content_conflicts(&local, &remote, false),
19243            vec![path]
19244        );
19245        assert!(v2_initial_content_conflicts(&local, &remote, true).is_empty());
19246
19247        let mut resolution = std::collections::BTreeMap::new();
19248        resolution.insert(
19249            "records/value.md".to_string(),
19250            V2ResolutionOverride {
19251                expected_remote: Some(content_sha256(b"remote")),
19252                selected_local: Some(content_sha256(b"local")),
19253            },
19254        );
19255        assert!(v2_resolution_allows_path(
19256            Some(&resolution),
19257            "records/value.md",
19258            true
19259        ));
19260        assert!(v2_resolution_allows_path(
19261            Some(&resolution),
19262            "records/new-target.md",
19263            false
19264        ));
19265        assert!(!v2_resolution_allows_path(
19266            Some(&resolution),
19267            "records/unreviewed-remote.md",
19268            true
19269        ));
19270    }
19271
19272    #[test]
19273    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
19274        let sandbox = tempfile::TempDir::new().unwrap();
19275        let root = sandbox.path().join("brain");
19276        std::fs::create_dir_all(&root).unwrap();
19277        std::fs::write(
19278            root.join("DB.md"),
19279            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19280        )
19281        .unwrap();
19282        let store = Store::open_strict(&root).unwrap();
19283        let incomplete = crate::ulid::mint();
19284        store
19285            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
19286            .unwrap();
19287        let expired = crate::ulid::mint();
19288        store
19289            .create_dir_all(&v2_conflict_relative(&expired, "files"))
19290            .unwrap();
19291        let plan = V2ConflictPlan {
19292            v: 2,
19293            class: "content_resolution_required".to_string(),
19294            bundle: expired.clone(),
19295            brain: TEST_BRAIN_ID.to_string(),
19296            origin: "https://example.test".to_string(),
19297            created_unix: 0,
19298            expires_unix: 0,
19299            base_seq: None,
19300            base_commit: None,
19301            remote_seq: 0,
19302            remote_commit: None,
19303            remote_content_root: None,
19304            view_kind: "full".to_string(),
19305            view_revision: "a".repeat(64),
19306            files: vec![V2ConflictFile {
19307                path: "records/value.md".to_string(),
19308                base: V2ConflictCoordinate {
19309                    sha256: None,
19310                    bytes: None,
19311                    file: None,
19312                },
19313                local: V2ConflictCoordinate {
19314                    sha256: None,
19315                    bytes: None,
19316                    file: None,
19317                },
19318                remote: V2ConflictCoordinate {
19319                    sha256: None,
19320                    bytes: None,
19321                    file: None,
19322                },
19323            }],
19324        };
19325        let mut bytes = serde_json::to_vec(&plan).unwrap();
19326        bytes.push(b'\n');
19327        store
19328            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
19329            .unwrap();
19330
19331        let listed = sync_conflicts(&root, false, false).unwrap();
19332        assert_eq!(listed["bundles"], 2);
19333        assert_eq!(listed["pruned"], 0);
19334        let pruned = sync_conflicts(&root, true, false).unwrap();
19335        assert_eq!(pruned["bundles"], 0);
19336        assert_eq!(pruned["pruned"], 2);
19337        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
19338        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
19339    }
19340
19341    #[test]
19342    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
19343        let sandbox = tempfile::TempDir::new().unwrap();
19344        let root = sandbox.path().join("brain");
19345        std::fs::create_dir_all(&root).unwrap();
19346        std::fs::write(
19347            root.join("DB.md"),
19348            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19349        )
19350        .unwrap();
19351        let store = Store::open_strict(&root).unwrap();
19352        let bundle = crate::ulid::mint();
19353        store
19354            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
19355            .unwrap();
19356        store
19357            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
19358            .unwrap();
19359
19360        assert!(sync_conflicts(&root, true, false).is_err());
19361        assert!(sync_conflicts(&root, false, true).is_err());
19362        let pruned = sync_conflicts(&root, true, true).unwrap();
19363        assert_eq!(pruned["pruned"], 1);
19364        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
19365    }
19366
19367    #[test]
19368    fn ready_pull_journal_rolls_back_exact_preimages() {
19369        let sandbox = tempfile::TempDir::new().unwrap();
19370        let root = sandbox.path().join("brain");
19371        std::fs::create_dir_all(root.join("records")).unwrap();
19372        std::fs::write(
19373            root.join("DB.md"),
19374            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19375        )
19376        .unwrap();
19377        let path = "records/value.md";
19378        let old = b"---\ntype: note\n---\n\nold\n";
19379        let new = b"---\ntype: note\n---\n\nnew\n";
19380        std::fs::write(root.join(path), old).unwrap();
19381        let store = Store::open_strict(&root).unwrap();
19382        let bundle = crate::ulid::mint();
19383        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19384        store
19385            .create_private_dir_all(Path::new(&backup_dir))
19386            .unwrap();
19387        store
19388            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
19389            .unwrap();
19390        let journal = V2PullJournal {
19391            v: 1,
19392            phase: V2PullPhase::Ready,
19393            brain: TEST_BRAIN_ID.to_string(),
19394            previous: V2PullCoordinate {
19395                head_seq: None,
19396                commit_hash: None,
19397                view_kind: None,
19398                view_revision: None,
19399            },
19400            next: V2PullCoordinate {
19401                head_seq: Some(2),
19402                commit_hash: Some("c".repeat(64)),
19403                view_kind: Some("full".to_string()),
19404                view_revision: Some("d".repeat(64)),
19405            },
19406            backup_dir: backup_dir.clone(),
19407            entries: vec![V2PullJournalEntry {
19408                path: path.to_string(),
19409                old: Some(V2PullFileCoordinate {
19410                    sha256: content_sha256(old),
19411                    bytes: old.len() as u64,
19412                }),
19413                new: Some(V2PullFileCoordinate {
19414                    sha256: content_sha256(new),
19415                    bytes: new.len() as u64,
19416                }),
19417                backup: Some("00000000".to_string()),
19418            }],
19419        };
19420        validate_v2_pull_journal(&journal).unwrap();
19421        store
19422            .write_private_atomic_new(
19423                Path::new(V2_PULL_JOURNAL),
19424                &v2_pull_journal_bytes(&journal).unwrap(),
19425            )
19426            .unwrap();
19427        store.write_atomic(Path::new(path), new).unwrap();
19428
19429        let cfg = test_hub_config(
19430            "https://example.test".to_string(),
19431            sandbox.path().join("state"),
19432        );
19433        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19434        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
19435        assert!(!root.join(V2_PULL_JOURNAL).exists());
19436        assert!(!root.join(backup_dir).exists());
19437    }
19438
19439    #[test]
19440    fn preparing_pull_journal_discards_only_private_staging() {
19441        let sandbox = tempfile::TempDir::new().unwrap();
19442        let root = sandbox.path().join("brain");
19443        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
19444        std::fs::write(
19445            root.join("DB.md"),
19446            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19447        )
19448        .unwrap();
19449        let store = Store::open_strict(&root).unwrap();
19450        let bundle = crate::ulid::mint();
19451        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19452        store
19453            .create_private_dir_all(Path::new(&backup_dir))
19454            .unwrap();
19455        let journal = V2PullJournal {
19456            v: 1,
19457            phase: V2PullPhase::Preparing,
19458            brain: TEST_BRAIN_ID.to_string(),
19459            previous: V2PullCoordinate {
19460                head_seq: None,
19461                commit_hash: None,
19462                view_kind: None,
19463                view_revision: None,
19464            },
19465            next: V2PullCoordinate {
19466                head_seq: Some(1),
19467                commit_hash: Some("a".repeat(64)),
19468                view_kind: Some("full".to_string()),
19469                view_revision: Some("b".repeat(64)),
19470            },
19471            backup_dir: backup_dir.clone(),
19472            entries: vec![V2PullJournalEntry {
19473                path: "records/new.md".to_string(),
19474                old: None,
19475                new: Some(V2PullFileCoordinate {
19476                    sha256: "c".repeat(64),
19477                    bytes: 1,
19478                }),
19479                backup: None,
19480            }],
19481        };
19482        store
19483            .write_private_atomic_new(
19484                Path::new(V2_PULL_JOURNAL),
19485                &v2_pull_journal_bytes(&journal).unwrap(),
19486            )
19487            .unwrap();
19488        let cfg = test_hub_config(
19489            "https://example.test".to_string(),
19490            sandbox.path().join("state"),
19491        );
19492
19493        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19494
19495        assert!(root.join("DB.md").is_file());
19496        assert!(!root.join(V2_PULL_JOURNAL).exists());
19497        assert!(!root.join(backup_dir).exists());
19498    }
19499
19500    #[test]
19501    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
19502        let sandbox = tempfile::TempDir::new().unwrap();
19503        let root = sandbox.path().join("brain");
19504        std::fs::create_dir_all(root.join("records")).unwrap();
19505        std::fs::write(
19506            root.join("DB.md"),
19507            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19508        )
19509        .unwrap();
19510        let new = b"---\ntype: note\n---\n\nnew\n";
19511        std::fs::write(root.join("records/value.md"), new).unwrap();
19512        let store = Store::open_strict(&root).unwrap();
19513        let bundle = crate::ulid::mint();
19514        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19515        store
19516            .create_private_dir_all(Path::new(&backup_dir))
19517            .unwrap();
19518        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
19519        store.create_private_dir_all(Path::new(&orphan)).unwrap();
19520        let next = V2PullCoordinate {
19521            head_seq: Some(2),
19522            commit_hash: Some("c".repeat(64)),
19523            view_kind: Some("full".to_string()),
19524            view_revision: Some("d".repeat(64)),
19525        };
19526        let journal = V2PullJournal {
19527            v: 1,
19528            phase: V2PullPhase::Ready,
19529            brain: TEST_BRAIN_ID.to_string(),
19530            previous: V2PullCoordinate {
19531                head_seq: Some(1),
19532                commit_hash: Some("a".repeat(64)),
19533                view_kind: Some("full".to_string()),
19534                view_revision: Some("b".repeat(64)),
19535            },
19536            next: next.clone(),
19537            backup_dir: backup_dir.clone(),
19538            entries: vec![V2PullJournalEntry {
19539                path: "records/value.md".to_string(),
19540                old: Some(V2PullFileCoordinate {
19541                    sha256: "e".repeat(64),
19542                    bytes: new.len() as u64,
19543                }),
19544                new: Some(V2PullFileCoordinate {
19545                    sha256: content_sha256(new),
19546                    bytes: new.len() as u64,
19547                }),
19548                backup: Some("00000000".to_string()),
19549            }],
19550        };
19551        store
19552            .write_private_atomic_new(
19553                Path::new(V2_PULL_JOURNAL),
19554                &v2_pull_journal_bytes(&journal).unwrap(),
19555            )
19556            .unwrap();
19557        let cfg = test_hub_config(
19558            "https://example.test".to_string(),
19559            sandbox.path().join("state"),
19560        );
19561        save_v2_baseline(
19562            &cfg,
19563            TEST_BRAIN_ID,
19564            &root,
19565            &V2SyncBaseline {
19566                v: 2,
19567                origin: "https://example.test".to_string(),
19568                brain: TEST_BRAIN_ID.to_string(),
19569                checkout_id: Some("c".repeat(64)),
19570                head_seq: next.head_seq,
19571                commit_hash: next.commit_hash.clone(),
19572                content_root: Some("f".repeat(64)),
19573                asset_root: None,
19574                assets: Default::default(),
19575                view_kind: next.view_kind.clone(),
19576                view_revision: next.view_revision.clone(),
19577                control_revision: Some("d".repeat(64)),
19578                projection_sha256: None,
19579                files: Default::default(),
19580                local_policy_digest: None,
19581                local_eligibility: Default::default(),
19582                remote_copy_remains: Default::default(),
19583            },
19584        )
19585        .unwrap();
19586
19587        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19588
19589        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
19590        assert!(!root.join(V2_PULL_JOURNAL).exists());
19591        assert!(!root.join(backup_dir).exists());
19592        assert!(!root.join(orphan).exists());
19593    }
19594}