Skip to main content

dbmd_core/
linkmd.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The **link.md client** — the five interconnect verbs `dbmd` speaks against
4//! a hub: `resolve`, `sync`, `grant`, `propose`, `subscribe`.
5//!
6//! One binary, two specs (the git precedent: one binary carries both the
7//! object format and the wire protocol). The db.md FORMAT is untouched by this
8//! module: a store never needs link.md to be valid db.md, record files stay
9//! plain markdown, and SPEC.md reserves only the `@brain/id` address *shape*.
10//! Everything with a wire or a trust boundary — addressing across stores,
11//! pulling/pushing a hosted copy, capability grants, the propose door, feed
12//! polling and signed-entry verification — lives here, as a *client
13//! capability*, never a format requirement.
14//!
15//! # What this client speaks
16//!
17//! The v0 HTTP binding a hub serves under its base URL:
18//!
19//! | verb | binding |
20//! | --- | --- |
21//! | `resolve` | `GET /api/hub/brains/<brain>` (the brain card); records are read from the exact signed snapshot pack named by that card's verified feed head |
22//! | `sync` (pull) | `GET /api/hub/brains/<brain>/export?format=pack&atSeq=<n>&feedHash=<hash>` — the exact verified snapshot |
23//! | `sync` (push) | `POST /api/hub/brains/<brain>/push` for small snapshots; presign/upload/commit for large snapshots |
24//! | `grant` | `GET` / `POST /api/hub/brains/<brain>/grants`, `DELETE /api/hub/brains/<brain>/grants/<id>` |
25//! | `propose` | `POST /api/hub/sites/<handle>/inbox` — evidence in, without trust (unauthenticated by design) |
26//! | `subscribe` | `GET /api/hub/brains/<brain>` + `/feed` for a locally verified signed head |
27//!
28//! # Configuration — no default hub, credential never in the store
29//!
30//! There is **no built-in hub endpoint**: the toolkit is neutral and a hub is
31//! whatever the user points it at. Resolution order for the hub URL:
32//!
33//! 1. the `--hub <URL>` flag,
34//! 2. the `DBMD_HUB_URL` environment variable,
35//! 3. the `hub = <URL>` line in the store-local `.dbmd/config` file
36//!    (toolkit state, not store content — the walkers already skip hidden
37//!    directories, so `.dbmd/` never syncs, indexes, or validates).
38//!
39//! The credential is the `DBMD_HUB_KEY` environment variable, full stop. It is
40//! deliberately **not** read from `.dbmd/config`: a secret inside the store
41//! tree is one commit or one push away from leaking. A store-selected hub may
42//! receive an ambient bearer or agent key only when
43//! `DBMD_HUB_CREDENTIAL_ORIGIN` binds it to that exact origin. Identity pins
44//! and monotonic feed checkpoints live in the user's global state directory,
45//! never in store-controlled `.dbmd/`.
46//!
47//! Non-HTTPS hubs are refused (the bearer key must never travel in cleartext)
48//! with a loopback exemption for local development.
49//!
50//! # v0 honesty
51//!
52//! This client binds to what a hub enforces **today**: grantees are hub
53//! principals (an email), grant scopes are store-path prefixes, pushes are
54//! whole-store snapshots, and `subscribe` reports feed-head movement. The hub
55//! signs each committed snapshot in a hash-chained feed with a per-brain
56//! Ed25519 identity. This client verifies the rotation chain, signer epochs,
57//! monotonic feed checkpoint, snapshot token, and content-addressed pack before
58//! untrusted bytes touch their destination.
59
60use std::collections::{BTreeMap, BTreeSet};
61use std::io::{Cursor, Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{SystemTime, UNIX_EPOCH};
64
65use base64::{
66    engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
67    Engine as _,
68};
69use ring::signature::{UnparsedPublicKey, ED25519};
70use serde::{Deserialize, Serialize};
71use serde_json::{json, Value};
72use sha2::{Digest, Sha256};
73
74use crate::store::Store;
75
76/// Environment variable naming the hub base URL (e.g. `https://hub.example.com`).
77pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
78
79/// Environment variable carrying the hub bearer credential. The bearer
80/// credential source — see the module docs for why it is never store-based.
81pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
82
83/// Explicit origin binding required before an ambient bearer or agent key may
84/// be sent to a hub selected by untrusted store-local configuration. The value
85/// is an origin (`https://host[:port]`), not an arbitrary URL path.
86pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
87
88/// Override for dbmd's user-owned trust/checkpoint root. Must be absolute.
89/// Intended for managed installations and hermetic tests; untrusted stores
90/// cannot select it.
91pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
92
93/// Explicit test/development escape hatch for registry homes on loopback or
94/// private networks. Production federation rejects every non-public resolved
95/// address and pins the validated DNS answer into the HTTP agent.
96pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
97
98/// Explicit development escape hatch for object-store URLs on loopback/private
99/// networks. Production hubs must return public HTTPS URLs. A local loopback hub
100/// is allowed to use local object URLs without this switch.
101pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
102
103/// Environment variable naming the PATH of a self-custodied BRAIN key file
104/// (link.md §2.4). When set, `sync --push` signs each feed entry locally and
105/// ships it through the pack flow — the hub verifies and stores the exact
106/// client bytes and can never sign for the brain. Same file format as agent
107/// keys (`dbmd key generate`).
108pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
109
110/// Environment variable naming the PATH of an agent signing key file
111/// (link.md §8 `LinkMD-Sig` proof of possession). When set, authenticated
112/// requests are signed per-request with the agent's Ed25519 key instead of
113/// carrying a bearer: the signature binds method + path + body + a ±60s
114/// window, so nothing reusable ever crosses the wire or lands in a log or an
115/// agent transcript. The file holds the base64url PKCS#8 key minted by
116/// `dbmd key generate`; the path is not a secret, the file is (mode 0600).
117pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
118
119/// The store-local config file, relative to the store root. Holds non-secret
120/// toolkit state (`hub = <URL>`); hidden, so every store walk skips it.
121pub const CONFIG_REL_PATH: &str = ".dbmd/config";
122
123/// Control-plane JSON is metadata, never the store itself. Exact snapshot
124/// bytes travel through the separately bounded pack lane.
125const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
126/// A feed page can legitimately carry a large snapshot manifest, but remains
127/// far below a full pack and is parsed with count-bounded sequence visitors.
128const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
129/// Private v2 baselines also contain the local stat/hash cache for large
130/// checkouts. They never cross the wire, but remain bounded before parsing and
131/// writing so a corrupt cache cannot cause unbounded allocation.
132const MAX_V2_BASELINE_BYTES: u64 = 64 * 1024 * 1024;
133/// Foreign registry cards are identity metadata only.
134const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
135
136/// Direct JSON pushes stay below the serverless request-body cap. Larger
137/// snapshots switch to the bounded object-store pack lane.
138const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
139/// Ceiling for a staged change manifest. Mirrors the hub's bound so an
140/// oversized push is refused before it stages anything.
141const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
142
143/// Canonical raw ZIP32 uses a u16 entry count and stores each UTF-8 name twice.
144const MAX_PUSH_FILES: usize = u16::MAX as usize;
145const MAX_STORE_PATH_BYTES: usize = 1_024;
146const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
147/// Assets travel outside the Markdown store/pack lane and have their own
148/// protocol ceiling. Keep this distinct from `MAX_STORE_BYTES`: applying the
149/// pack bound to a signed asset leaf makes an otherwise valid checkout unable
150/// to load its own saved sync baseline after the first commit.
151const MAX_ASSET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
152/// Exact worst case for the canonical STORED profile:
153/// payload + (local 30 + central 46 + name twice) per entry + EOCD 22.
154const MAX_PACK_BYTES: u64 =
155    MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
156/// An upload reservation body carries one entry per changed blob, so a real
157/// store's first push does not fit in a single request: the hub bounds the body
158/// and the hosting platform rejects an oversized payload before the route sees
159/// it. Reservations are therefore batched. The budget is deliberately well
160/// under the hub's own ceiling, and both bounds are enforced because a single
161/// hash can carry many coordinates and a path may be long.
162/// The count matches the reservation window a hub grants per request; batching
163/// above it only fails deeper in, after the body has already been accepted.
164const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
165const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
166
167/// A legitimate brain should rotate rarely. Bound adversarial identity
168/// histories before allocating and repeatedly verifying an unbounded chain.
169const MAX_IDENTITY_ROTATIONS: usize = 1_024;
170
171/// Split blob declarations into request-sized batches, by serialized size and
172/// by count. Order is preserved so a caller can concatenate the responses.
173fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
174    let mut batches: Vec<Vec<Value>> = Vec::new();
175    let mut current: Vec<Value> = Vec::new();
176    let mut current_bytes = 0usize;
177    for declaration in declarations {
178        let declared_bytes = serde_json::to_string(&declaration)
179            .map(|text| text.len())
180            .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
181            + 1;
182        if !current.is_empty()
183            && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
184                || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
185        {
186            batches.push(std::mem::take(&mut current));
187            current_bytes = 0;
188        }
189        current_bytes += declared_bytes;
190        current.push(declaration);
191    }
192    if !current.is_empty() {
193        batches.push(current);
194    }
195    batches
196}
197/// A client never needs to replay an unbounded feed in one invocation. Large
198/// histories are mirrored incrementally; a first mirror beyond this cap needs a
199/// checkpoint/export rather than allocating attacker-controlled metadata.
200const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
201const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
202const FEED_PAGE_LIMIT: usize = 100;
203
204/// The hub's inbox cap on one `propose` submission body, mirrored client-side
205/// so an oversized body fails before the upload, not after (the same
206/// fail-before-upload contract as the push caps). Public so the CLI can
207/// pre-check a `--body-file` from file metadata without reading it.
208pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
209
210/// Bounded connect so a dead hub fails fast; a generous read window so a
211/// large export on a slow link still completes.
212const CONNECT_TIMEOUT_SECS: u64 = 10;
213const READ_TIMEOUT_SECS: u64 = 120;
214/// Hard wall-clock budget for one HTTP attempt, spanning socket writes,
215/// response headers, and the complete response body. DNS performed for pinned
216/// agents has its own interruptible deadline below.
217const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
218/// A commit of a whole store is brain-sized work the hub is allowed to spend
219/// its full route budget on, so the client has to outwait the server rather
220/// than the other way round. At the default 120 seconds a first push of a real
221/// store abandoned a commit the hub was still completing and reported a
222/// transport failure for work that had not failed. This sits above the hub's
223/// own genesis-commit ceiling so the client always learns the outcome.
224const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
225/// A commit is idempotent by mutation coordinate, so a lost response is worth
226/// asking after — and the work it lost the receipt for may still be finishing,
227/// which is why the waits are long enough to let a brain-sized commit land.
228const COMMIT_ATTEMPTS: usize = 4;
229const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
230const CONNECT_ATTEMPTS: usize = 3;
231const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
232/// Safe reads are idempotent, including authenticated GETs and the explicitly
233/// marked read-only POSTs whose proof lists do not fit in a URL. Retry the
234/// whole read when a connection dies after headers or part-way through its
235/// body; connect-only retries cannot recover that common large-export failure.
236const SAFE_READ_ATTEMPTS: usize = 4;
237const SAFE_READ_RETRY_BACKOFF_MS: [u64; SAFE_READ_ATTEMPTS - 1] = [200, 1_000, 3_000];
238
239/// Staging a real store means tens of thousands of immutable object PUTs in a
240/// row. A single refusal under sustained load must not fail the migration, so
241/// the upload lane retries further and waits longer than a connect blip does.
242const UPLOAD_ATTEMPTS: usize = 6;
243const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
244/// Retries share one wall-clock budget. Without this outer deadline, one
245/// black-holed object can consume the per-attempt timeout six times, outlive
246/// its presigned capability, and make a large migration appear hung.
247const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
248
249fn upload_retry_backoff_ms(attempt: usize) -> u64 {
250    UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
251}
252
253fn upload_deadline_error() -> LinkError {
254    LinkError::Transport {
255        hub: "the object store".to_string(),
256        message: "network error (upload deadline exceeded)".to_string(),
257    }
258}
259
260fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
261    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
262    if remaining.is_zero() {
263        return Err(upload_deadline_error());
264    }
265    Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
266}
267
268fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
269    if attempt + 1 >= UPLOAD_ATTEMPTS {
270        return false;
271    }
272    let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
273    if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
274        return false;
275    }
276    std::thread::sleep(pause);
277    true
278}
279
280/// A migration opens dozens of staging windows back to back, so it is ordinary
281/// for a shared limiter or a briefly busy hub to answer one of them with 429 or
282/// 5xx. Failing the push there throws away every byte already staged, and the
283/// waits have to reach past a whole limiter window to be worth anything.
284const RESERVATION_ATTEMPTS: usize = 7;
285const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
286    [500, 2_000, 5_000, 15_000, 30_000, 60_000];
287
288/// Answers a busy or briefly unhealthy hub gives, as opposed to a refusal that
289/// would repeat identically. A 4xx other than these states something about the
290/// request itself and is never retried.
291fn is_retryable_hub_status(status: u16) -> bool {
292    matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
293}
294
295/// Statuses worth replaying an immutable, content-addressed upload for: edge
296/// and object-store refusals that are load-shaped rather than a statement about
297/// these bytes. A checksum or precondition failure is never retried.
298fn is_retryable_upload_status(status: u16) -> bool {
299    matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
300}
301/// Cold checkouts may need every visible blob, but short-lived direct object
302/// capabilities must not create an unbounded connection storm. Keep the
303/// verified downloads inside this fixed worker count.
304const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
305/// File durability barriers are independent inside the private pull stage.
306/// Keep enough in flight to hide storage flush latency without approaching
307/// ordinary process descriptor limits.
308#[cfg(unix)]
309const V2_PULL_INSTALL_WORKERS: usize = 16;
310/// One authenticated link.md v2 bulk frame remains small enough for bounded
311/// retries and serverless streaming while amortizing tens of thousands of
312/// immutable blobs over a few hundred requests instead of one request each.
313const V2_BULK_STREAM_FILES: usize = 256;
314const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
315const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
316const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
317/// A read capability lives for five minutes. Ask only for the files the
318/// bounded worker pool can start immediately; collecting hundreds of signed
319/// asset URLs before consuming the first one deterministically expires the
320/// oldest capabilities on a company-sized brain.
321const V2_DOWNLOAD_CAPABILITY_FILES: usize = V2_BLOB_DOWNLOAD_WORKERS;
322const V2_DOWNLOAD_CAPABILITY_BYTES: u64 = 512 * 1024 * 1024;
323const V2_DOWNLOAD_CAPABILITY_ATTEMPTS: usize = 4;
324const V2_DOWNLOAD_CAPABILITY_BACKOFF_MS: [u64; V2_DOWNLOAD_CAPABILITY_ATTEMPTS - 1] =
325    [200, 1_000, 3_000];
326
327/// Everything that can go wrong on the wire or at its edges. Each variant maps
328/// onto one stable CLI error code; messages are single-line and never echo the
329/// credential.
330#[derive(Debug, thiserror::Error)]
331pub enum LinkError {
332    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
333    #[error(
334        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
335    )]
336    NoHub,
337
338    /// The verb needs a credential and none was present.
339    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
340    NoCredential,
341
342    /// The credential contains whitespace / non-ASCII (a paste artifact). The
343    /// key is deliberately not echoed.
344    #[error(
345        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
346    )]
347    BadKey,
348
349    /// A store selected the destination while an ambient credential was
350    /// present, but the operator did not bind that credential to the same
351    /// origin. This is a hard refusal, not an anonymous fallback: silently
352    /// dropping a credential can turn an intended private operation into a
353    /// confusing public one.
354    #[error(
355        "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}"
356    )]
357    UnboundCredential,
358
359    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
360    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
361    /// echoed.
362    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
363    BadAgentKey {
364        /// What failed, without any key material.
365        message: String,
366    },
367
368    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
369    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
370    UnsafeHub {
371        /// The offending hub URL.
372        hub: String,
373    },
374
375    /// TCP/TLS-level failure: the hub never answered.
376    #[error("hub unreachable at {hub}: {message}")]
377    Transport {
378        /// The hub base URL.
379        hub: String,
380        /// The transport-layer error text.
381        message: String,
382    },
383
384    /// The hub answered with an HTTP error status.
385    #[error("{what} failed (HTTP {status}): {message}")]
386    Http {
387        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
388        what: &'static str,
389        /// The HTTP status code.
390        status: u16,
391        /// The hub's own `error` string when it sent one, else a placeholder.
392        message: String,
393        /// The hub's machine `code` field when it sent one.
394        code: Option<String>,
395        /// Permission-filtered structured refusal details, when supplied.
396        details: Option<Value>,
397    },
398
399    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
400    /// URL — refused here rather than deserializing into nothing downstream.
401    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
402    NotJson {
403        /// What the client was doing.
404        what: &'static str,
405        /// The (2xx) status that carried the non-JSON body.
406        status: u16,
407    },
408
409    /// The hub response exceeded the selected endpoint's byte cap.
410    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
411    ResponseTooLarge {
412        /// The endpoint-specific cap applied before JSON parsing.
413        limit_bytes: u64,
414    },
415
416    /// A malformed `@brain/id` address.
417    #[error("invalid address `{given}`: {reason}")]
418    BadAddress {
419        /// The raw address as typed.
420        given: String,
421        /// Why it did not parse.
422        reason: String,
423    },
424
425    /// A grant id whose shape cannot travel as a URL path segment.
426    #[error(
427        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
428    )]
429    BadGrantId {
430        /// The raw id as typed.
431        given: String,
432    },
433
434    /// An exported file path that would escape or pollute the destination
435    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
436    /// hub is not trusted with local path layout.
437    #[error("refusing unsafe path from the hub: `{path}`")]
438    UnsafePath {
439        /// The offending path as received.
440        path: String,
441    },
442
443    /// The store exceeds the hub's bounded whole-snapshot caps.
444    #[error(
445        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
446        MAX_STORE_BYTES / (1024 * 1024),
447        MAX_PACK_BYTES / (1024 * 1024)
448    )]
449    PushTooLarge {
450        /// Which cap was hit, human-readable.
451        detail: String,
452    },
453
454    /// The propose body exceeds the hub's inbox cap.
455    #[error(
456        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
457        MAX_PROPOSE_BYTES / 1024
458    )]
459    ProposeTooLarge {
460        /// The offending body size in bytes.
461        bytes: u64,
462    },
463
464    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
465    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
466    NotUtf8 {
467        /// The store-relative path of the offending file.
468        path: String,
469    },
470
471    /// A downloaded pack failed validation before any local write.
472    #[error("invalid store pack: {message}")]
473    InvalidPack {
474        /// Hash, ZIP, path, count, or expansion failure.
475        message: String,
476    },
477
478    /// A signed feed entry, hash chain, or advertised feed head did not verify.
479    #[error("invalid signed feed: {message}")]
480    InvalidFeed {
481        /// The failed integrity condition, without untrusted secret material.
482        message: String,
483    },
484
485    /// A mutable alias moved to another canonical brain. Ordinary sync fails
486    /// closed; only the exact old/new ids can authorize replacing that one
487    /// alias binding while preserving both canonical trust histories.
488    #[error(
489        "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}`"
490    )]
491    AliasRebindRequired {
492        alias: String,
493        from: String,
494        to: String,
495    },
496
497    /// Local and remote both changed one or more coordinates since the last
498    /// verified sync baseline. No side was overwritten.
499    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
500    Conflict {
501        /// Bounded, portable paths safe to show to the agent/operator.
502        paths: Vec<String>,
503    },
504
505    /// A readable v2 conflict was preserved as a private, exact-head bundle.
506    /// The bundle is local control state; none of its bytes ride with the
507    /// brain, and resolving it always creates a fresh explicit mutation.
508    #[error(
509        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
510    )]
511    ConflictBundle {
512        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
513        bundle: String,
514        /// Bounded, readable conflict paths.
515        paths: Vec<String>,
516    },
517
518    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
519    /// explicit adoption boundary, never an accidental side effect of editing
520    /// or removing a local policy file.
521    #[error(
522        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
523    )]
524    LocalPolicyTransition {
525        /// Bounded local-only paths. They are never sent in telemetry.
526        paths: Vec<String>,
527    },
528
529    /// A currently hosted asset would become withheld. That custody change
530    /// requires an exact coordinate and a bounded company audit reason; a
531    /// manifest edit or `.sevralocal` rule cannot imply it.
532    #[error(
533        "hosted assets require explicit withdrawal {paths:?} — retry with one --withdraw-from-hosting <path> per asset and a non-empty --withdraw-reason"
534    )]
535    AssetWithdrawalRequired {
536        /// Bounded asset paths. They are never sent in telemetry.
537        paths: Vec<String>,
538    },
539
540    /// The exact mutation crosses a permissioned bulk-impact boundary. The
541    /// hub has not committed it; `preview` is the bounded, permission-filtered
542    /// receipt an agent must inspect before explicitly confirming the same
543    /// request.
544    #[error(
545        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
546    )]
547    BulkPreviewRequired {
548        /// Stable structured preview returned by the hub.
549        preview: Value,
550    },
551
552    /// A scoped checkout's generated store marker was edited or removed.
553    /// It is local projection metadata and is never accepted as brain data.
554    #[error(
555        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
556    )]
557    ScopedProjectionModified,
558
559    /// The effective permission slice changed after this checkout was pinned.
560    /// Reusing the directory could conceal removals or accidentally adopt files
561    /// revealed by a wider grant, so a new checkout is required.
562    #[error(
563        "the checkout's permission scope changed — clone into a new directory to accept the new view"
564    )]
565    ScopedViewChanged,
566
567    /// A v2 identity/head was previously accepted for this ref, but the hub
568    /// now hides it. Never reinterpret that as a v1 downgrade.
569    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
570    BrainUnavailable,
571
572    /// The verified remote head advanced while a pull or post-commit barrier
573    /// was in flight. The old baseline is deliberately retained.
574    #[error(
575        "the remote brain advanced during sync — retry to converge from the new verified head"
576    )]
577    RemoteAdvancedDuringSync,
578
579    /// This build cannot provide the no-follow, directory-handle-relative
580    /// filesystem semantics required for trust/key/snapshot state.
581    #[error(
582        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
583    )]
584    UnsupportedPlatform {
585        /// The operation that requires hardened local filesystem primitives.
586        operation: &'static str,
587    },
588
589    /// Local filesystem failure while materializing a pull or reading a push.
590    #[error(transparent)]
591    Io(#[from] std::io::Error),
592
593    /// A store-level failure (walking the local store for a push).
594    #[error(transparent)]
595    Store(#[from] crate::StoreError),
596}
597
598/// Result alias for link.md client operations.
599pub type LinkResult<T> = std::result::Result<T, LinkError>;
600
601/// Exact approval token returned by a permissioned v2 bulk preview.
602#[derive(Debug, Clone, PartialEq, Eq)]
603pub struct V2BulkConfirmation {
604    /// Opaque preview receipt id.
605    pub id: String,
606    /// Digest binding the receipt to principal, head, control revision, and
607    /// the exact strict mutation.
608    pub digest: String,
609}
610
611impl V2BulkConfirmation {
612    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
613    /// outside the protocol's exact lowercase shapes before network I/O.
614    pub fn parse(value: &str) -> LinkResult<Self> {
615        let (id, digest) = value
616            .split_once(':')
617            .ok_or_else(|| LinkError::InvalidPack {
618                message: "bulk confirmation must be <id>:<digest>".to_string(),
619            })?;
620        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
621            return Err(LinkError::InvalidPack {
622                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
623                    .to_string(),
624            });
625        }
626        Ok(Self {
627            id: id.to_string(),
628            digest: digest.to_string(),
629        })
630    }
631}
632
633/// Security-sensitive link.md state and destination writes require a hardened
634/// descriptor/handle-relative filesystem implementation. Unix uses
635/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
636/// refusal, exclusive share modes, and write-through replacement.
637fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
638    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
639    {
640        let _ = operation;
641        Ok(())
642    }
643    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
644    {
645        Err(LinkError::UnsupportedPlatform { operation })
646    }
647}
648
649// ─────────────────────────────────────────────────────────────────────────────
650// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
651// ─────────────────────────────────────────────────────────────────────────────
652
653/// What the part after `@brain/` names.
654#[derive(Debug, Clone, PartialEq, Eq)]
655pub enum AddressTarget {
656    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
657    Id(String),
658    /// A store-relative `.md` path — a client-side convenience the hub's
659    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
660    /// shape; unambiguous because a ULID is never a path.
661    Path(String),
662}
663
664/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
665/// and the per-verb entry gates so the two surfaces never drift.
666const BAD_BRAIN_REASON: &str =
667    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
668
669/// Why an address target failed its shape check — shared by [`Address::parse`]
670/// and the [`resolve`] entry gate.
671const BAD_TARGET_REASON: &str =
672    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
673
674/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
675/// brain's ULID id (works for any caller, including cross-party on a public
676/// brain) or a slug (which a hub resolves only against the caller's own
677/// brains; slugs are unique per owner, not globally).
678#[derive(Debug, Clone, PartialEq, Eq)]
679pub struct Address {
680    /// The brain reference (leading `@` stripped).
681    pub brain: String,
682    /// The record target, when the address names one.
683    pub target: Option<AddressTarget>,
684}
685
686impl Address {
687    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
688    /// sigil is optional (an agent piping ids around should not have to quote
689    /// it back on). Whitespace and empty segments are malformed.
690    pub fn parse(raw: &str) -> LinkResult<Address> {
691        let bad = |reason: &str| LinkError::BadAddress {
692            given: raw.to_string(),
693            reason: reason.to_string(),
694        };
695
696        let trimmed = raw.trim();
697        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
698        if body.is_empty() {
699            return Err(bad("empty address"));
700        }
701
702        let (brain, rest) = match body.split_once('/') {
703            Some((b, r)) => (b, Some(r)),
704            None => (body, None),
705        };
706
707        if brain.is_empty() {
708            return Err(bad("missing brain reference before `/`"));
709        }
710        if !is_safe_ref(brain) {
711            return Err(bad(BAD_BRAIN_REASON));
712        }
713
714        let target = match rest {
715            None => None,
716            Some("") => return Err(bad("trailing `/` with no record id or path")),
717            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
718            Some(r) => {
719                if !safe_store_rel_path(r) || !r.ends_with(".md") {
720                    return Err(bad(BAD_TARGET_REASON));
721                }
722                Some(AddressTarget::Path(r.to_string()))
723            }
724        };
725
726        Ok(Address {
727            brain: brain.to_string(),
728            target,
729        })
730    }
731}
732
733/// A brain reference safe to embed in a URL path segment: the shapes a hub
734/// accepts (ULID id or slug), which are also exactly URL-path-clean.
735fn is_safe_ref(s: &str) -> bool {
736    !s.is_empty()
737        && s.len() <= 64
738        && s.bytes()
739            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
740}
741
742/// A published-site handle (the `propose` target). Same lexical shape as a
743/// slug.
744pub fn is_valid_handle(s: &str) -> bool {
745    is_safe_ref(s)
746}
747
748/// True when `p` is a store-relative path this client will read from or write
749/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
750/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
751/// every path an export hands us (the hub is not trusted with local layout)
752/// and to every path a push sends (mirroring the hub's own gate).
753pub fn safe_store_rel_path(p: &str) -> bool {
754    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
755        return false;
756    }
757    if !p
758        .bytes()
759        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
760    {
761        return false;
762    }
763    p.split('/')
764        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
765}
766
767/// Entry gate for every verb that embeds a caller-supplied brain reference in
768/// a URL path segment. `resolve` reaches the same check through
769/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
770/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
771/// URL-reshaping byte is refused before a request exists (the `url` crate
772/// normalizes dot segments, so an unvalidated ref would redirect the
773/// authenticated request to a different hub path).
774fn require_safe_ref(brain: &str) -> LinkResult<()> {
775    if is_safe_ref(brain) {
776        Ok(())
777    } else {
778        Err(LinkError::BadAddress {
779            given: brain.to_string(),
780            reason: BAD_BRAIN_REASON.to_string(),
781        })
782    }
783}
784
785/// Entry gate for the published-site handle `propose` embeds in its URL path.
786fn require_valid_handle(handle: &str) -> LinkResult<()> {
787    if is_valid_handle(handle) {
788        Ok(())
789    } else {
790        Err(LinkError::BadAddress {
791            given: handle.to_string(),
792            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
793        })
794    }
795}
796
797/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
798/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
799/// shape as a brain ref rather than pinning one mint scheme.
800fn require_safe_grant_id(id: &str) -> LinkResult<()> {
801    if is_safe_ref(id) {
802        Ok(())
803    } else {
804        Err(LinkError::BadGrantId {
805            given: id.to_string(),
806        })
807    }
808}
809
810// ─────────────────────────────────────────────────────────────────────────────
811// Configuration — flag > env > .dbmd/config; credential from env only
812// ─────────────────────────────────────────────────────────────────────────────
813
814/// The resolved client configuration for one invocation.
815#[derive(Debug, Clone)]
816pub struct HubConfig {
817    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
818    pub hub: String,
819    /// The bearer credential, when the environment carries one.
820    pub key: Option<String>,
821    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
822    /// over the bearer for authenticated requests (link.md §8).
823    pub agent_key: Option<AgentSigningKey>,
824    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
825    /// names one — `sync --push` then signs feed entries locally (§2.4).
826    pub brain_key: Option<AgentSigningKey>,
827    /// User-owned global toolkit state root. Identity pins and monotonic feed
828    /// checkpoints live below `<state_dir>/trust/`, never under a store.
829    pub state_dir: PathBuf,
830    /// True only when the origin came from untrusted store-local configuration.
831    /// Such origins are resolved and public-IP-pinned for every request.
832    store_selected: bool,
833}
834
835/// A loaded agent signing key: the PKCS#8 secret plus its derived public
836/// multikey. Debug never prints key material.
837#[derive(Clone)]
838pub struct AgentSigningKey {
839    pkcs8: Vec<u8>,
840    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
841    pub multikey: String,
842    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
843    pub public_key_spki: String,
844}
845
846impl std::fmt::Debug for AgentSigningKey {
847    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
848        f.debug_struct("AgentSigningKey")
849            .field("multikey", &self.multikey)
850            .field("pkcs8", &"<redacted>")
851            .finish()
852    }
853}
854
855impl HubConfig {
856    /// The credential, or the canonical "not configured" error. Verbs that
857    /// authenticate call this; `propose` never does.
858    pub fn require_key(&self) -> LinkResult<&str> {
859        self.key.as_deref().ok_or(LinkError::NoCredential)
860    }
861}
862
863/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
864/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
865/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
866/// header token (never echoed on failure).
867pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
868    let explicit_hub = flag_hub
869        .map(str::to_string)
870        .or_else(|| env_nonempty(HUB_URL_ENV));
871    let selected_by_store = explicit_hub.is_none();
872    let hub = explicit_hub
873        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
874        .ok_or(LinkError::NoHub)?;
875    let hub = hub.trim().trim_end_matches('/').to_string();
876    assert_safe_hub(&hub)?;
877    if selected_by_store {
878        let parsed =
879            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
880        // A cloned store is not an operator opt-in to local-network access.
881        // Local/private development hubs must be selected explicitly by flag or
882        // environment, never by bytes inside the store.
883        if !parsed.scheme().eq_ignore_ascii_case("https")
884            || (parsed.path() != "/" && !parsed.path().is_empty())
885        {
886            return Err(LinkError::UnsafeHub { hub });
887        }
888    }
889
890    let key = match env_nonempty(HUB_KEY_ENV) {
891        Some(raw) => Some(clean_key(&raw)?),
892        None => None,
893    };
894
895    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
896        Some(path) => Some(load_agent_key(Path::new(&path))?),
897        None => None,
898    };
899
900    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
901        Some(path) => Some(load_agent_key(Path::new(&path))?),
902        None => None,
903    };
904
905    // A cloned store controls `.dbmd/config`. It must never be able to point
906    // the process at an attacker origin and harvest the user's ambient account
907    // bearer, signed agent identity, or self-custodied brain signatures/content.
908    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
909    // in the invocation environment; a store-selected target additionally
910    // needs an exact origin binding.
911    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
912        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
913            .and_then(|value| normalized_origin(&value).ok());
914        let selected_origin = normalized_origin(&hub)?;
915        if bound.as_deref() != Some(selected_origin.as_str()) {
916            return Err(LinkError::UnboundCredential);
917        }
918    }
919
920    Ok(HubConfig {
921        hub,
922        key,
923        agent_key,
924        brain_key,
925        state_dir: toolkit_state_dir()?,
926        store_selected: selected_by_store,
927    })
928}
929
930fn toolkit_state_dir() -> LinkResult<PathBuf> {
931    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
932        let path = PathBuf::from(path);
933        if !path.is_absolute() {
934            return Err(LinkError::UnsafePath {
935                path: path.display().to_string(),
936            });
937        }
938        return Ok(path);
939    }
940    #[cfg(windows)]
941    if let Some(base) = env_nonempty("LOCALAPPDATA") {
942        let base = PathBuf::from(base);
943        if base.is_absolute() {
944            return Ok(base.join("dbmd").join("state"));
945        }
946    }
947    #[cfg(windows)]
948    {
949        Err(LinkError::Io(std::io::Error::new(
950            std::io::ErrorKind::NotFound,
951            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
952        )))
953    }
954    #[cfg(not(windows))]
955    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
956        let base = PathBuf::from(base);
957        if base.is_absolute() {
958            return Ok(base.join("dbmd"));
959        }
960    }
961    #[cfg(not(windows))]
962    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
963        LinkError::Io(std::io::Error::new(
964            std::io::ErrorKind::NotFound,
965            format!("cannot locate user state; set {STATE_DIR_ENV}"),
966        ))
967    })?);
968    #[cfg(not(windows))]
969    if !home.is_absolute() {
970        return Err(LinkError::UnsafePath {
971            path: home.display().to_string(),
972        });
973    }
974    #[cfg(target_os = "macos")]
975    {
976        Ok(home
977            .join("Library")
978            .join("Application Support")
979            .join("dbmd")
980            .join("state"))
981    }
982    #[cfg(all(not(target_os = "macos"), not(windows)))]
983    {
984        Ok(home.join(".local").join("state").join("dbmd"))
985    }
986}
987
988fn normalized_origin(value: &str) -> LinkResult<String> {
989    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
990        hub: value.to_string(),
991    })?;
992    if !(parsed.scheme().eq_ignore_ascii_case("https")
993        || parsed.scheme().eq_ignore_ascii_case("http"))
994        || !parsed.username().is_empty()
995        || parsed.password().is_some()
996        || (parsed.path() != "/" && !parsed.path().is_empty())
997        || parsed.query().is_some()
998        || parsed.fragment().is_some()
999    {
1000        return Err(LinkError::UnsafeHub {
1001            hub: value.to_string(),
1002        });
1003    }
1004    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
1005        hub: value.to_string(),
1006    })?;
1007    let host = if host.contains(':') {
1008        format!("[{host}]")
1009    } else {
1010        host.to_ascii_lowercase()
1011    };
1012    let port = parsed
1013        .port_or_known_default()
1014        .ok_or_else(|| LinkError::UnsafeHub {
1015            hub: value.to_string(),
1016        })?;
1017    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
1018        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
1019    Ok(format!(
1020        "{}://{}{}",
1021        parsed.scheme().to_ascii_lowercase(),
1022        host,
1023        if default {
1024            String::new()
1025        } else {
1026            format!(":{port}")
1027        }
1028    ))
1029}
1030
1031// ─────────────────────────────────────────────────────────────────────────────
1032// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
1033// ─────────────────────────────────────────────────────────────────────────────
1034
1035/// The DER prefix that wraps a raw Ed25519 public key into a
1036/// SubjectPublicKeyInfo (RFC 8410).
1037const ED25519_SPKI_PREFIX: [u8; 12] = [
1038    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1039];
1040
1041fn bad_agent_key(message: &str) -> LinkError {
1042    LinkError::BadAgentKey {
1043        message: message.to_string(),
1044    }
1045}
1046
1047fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1048    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
1049    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
1050    // deriving the public half itself.
1051    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1052        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1053        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1054}
1055
1056/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
1057fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1058    use ring::signature::KeyPair as _;
1059    let mut spki = Vec::with_capacity(44);
1060    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1061    spki.extend_from_slice(pair.public_key().as_ref());
1062    (
1063        URL_SAFE_NO_PAD.encode(&spki),
1064        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1065    )
1066}
1067
1068/// Load and validate a signing-key file (agent or brain — same format):
1069/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
1070/// old key explicitly.
1071pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1072    load_agent_key(path)
1073}
1074
1075/// Load and validate the agent key file: one base64url line of PKCS#8.
1076fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1077    #[cfg(unix)]
1078    let file = {
1079        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1080        use std::os::unix::ffi::OsStrExt as _;
1081        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1082            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1083        let leaf = path
1084            .file_name()
1085            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1086        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1087        let fd = unsafe {
1088            libc::openat(
1089                parent.as_raw_fd(),
1090                leaf.as_ptr(),
1091                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1092            )
1093        };
1094        if fd < 0 {
1095            return Err(bad_agent_key(
1096                "the key path must be an existing regular file without symlink ancestors",
1097            ));
1098        }
1099        unsafe { std::fs::File::from_raw_fd(fd) }
1100    };
1101    #[cfg(not(unix))]
1102    let file = std::fs::File::open(path)
1103        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1104    let metadata = file
1105        .metadata()
1106        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1107    if !metadata.is_file() {
1108        return Err(bad_agent_key("the key path must be a regular file"));
1109    }
1110    #[cfg(unix)]
1111    {
1112        use std::os::unix::fs::PermissionsExt as _;
1113        if metadata.permissions().mode() & 0o077 != 0 {
1114            return Err(bad_agent_key(
1115                "the key file is accessible to group/other; set mode 0600",
1116            ));
1117        }
1118    }
1119    let mut text = String::new();
1120    file.take(1024 * 1024 + 1)
1121        .read_to_string(&mut text)
1122        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1123    if text.len() > 1024 * 1024 {
1124        return Err(bad_agent_key("the key file exceeds the size limit"));
1125    }
1126    let pkcs8 = URL_SAFE_NO_PAD
1127        .decode(text.trim())
1128        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1129    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1130    Ok(AgentSigningKey {
1131        pkcs8,
1132        multikey,
1133        public_key_spki,
1134    })
1135}
1136
1137/// Durably create a new secret file without an exists→write race and without
1138/// ever exposing a default-mode (commonly 0644) key between write and chmod.
1139/// `create_new` also refuses a planted symlink. The file and its parent
1140/// directory are synced before the caller can publish the corresponding
1141/// public identity remotely.
1142fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1143    #[cfg(unix)]
1144    let (mut file, parent, leaf) = {
1145        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1146        use std::os::unix::ffi::OsStrExt as _;
1147        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1148        let leaf_name = path
1149            .file_name()
1150            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1151        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1152        let fd = unsafe {
1153            libc::openat(
1154                parent.as_raw_fd(),
1155                leaf.as_ptr(),
1156                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1157                0o600,
1158            )
1159        };
1160        if fd < 0 {
1161            let error = std::io::Error::last_os_error();
1162            if error.kind() == std::io::ErrorKind::AlreadyExists {
1163                return Err(bad_agent_key(
1164                    "the output file already exists — refusing to overwrite a key",
1165                ));
1166            }
1167            return Err(error.into());
1168        }
1169        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1170    };
1171    #[cfg(not(unix))]
1172    let mut file = std::fs::OpenOptions::new()
1173        .write(true)
1174        .create_new(true)
1175        .open(path)
1176        .map_err(|error| {
1177            if error.kind() == std::io::ErrorKind::AlreadyExists {
1178                bad_agent_key("the output file already exists — refusing to overwrite a key")
1179            } else {
1180                LinkError::Io(error)
1181            }
1182        })?;
1183    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1184        drop(file);
1185        #[cfg(unix)]
1186        let _ =
1187            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1188        #[cfg(not(unix))]
1189        let _ = std::fs::remove_file(path);
1190        return Err(LinkError::Io(error));
1191    }
1192    drop(file);
1193    #[cfg(unix)]
1194    parent.sync_all()?;
1195    Ok(())
1196}
1197
1198/// What `dbmd key generate` returns: the public identity to register plus
1199/// where the secret landed.
1200#[derive(Debug, Serialize)]
1201pub struct GeneratedAgentKey {
1202    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1203    pub multikey: String,
1204    /// base64url SPKI DER — what a hub's register endpoint takes.
1205    #[serde(rename = "publicKeySpki")]
1206    pub public_key_spki: String,
1207    /// Where the PKCS#8 secret was written (mode 0600).
1208    #[serde(rename = "keyFile")]
1209    pub key_file: String,
1210}
1211
1212/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1213/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1214/// identity is returned. The private key never enters a store and never
1215/// travels — requests carry per-request signatures instead (link.md §8).
1216pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1217    require_hardened_filesystem("key generation")?;
1218    let rng = ring::rand::SystemRandom::new();
1219    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1220        .map_err(|_| bad_agent_key("key generation failed"))?;
1221    let pair = agent_keypair(pkcs8.as_ref())?;
1222    let (spki_b64u, multikey) = public_identity_for(&pair);
1223
1224    write_secret_new(
1225        out,
1226        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1227    )?;
1228
1229    Ok(GeneratedAgentKey {
1230        multikey,
1231        public_key_spki: spki_b64u,
1232        key_file: out.display().to_string(),
1233    })
1234}
1235
1236/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1237/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1238/// (sha256hex(body) | "-")`.
1239///
1240/// The origin is derived from the already-validated hub URL, never from
1241/// attacker-controlled request metadata. Binding it closes the v1 replay
1242/// class where a proof captured by one hub could be replayed to another hub
1243/// serving the same path inside the timestamp window.
1244fn linkmd_sig_header(
1245    key: &AgentSigningKey,
1246    origin: &str,
1247    method: &str,
1248    path: &str,
1249    body: Option<&str>,
1250) -> LinkResult<String> {
1251    let ts = std::time::SystemTime::now()
1252        .duration_since(std::time::UNIX_EPOCH)
1253        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1254        .as_secs();
1255    let body_hash = match body {
1256        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1257        None => "-".to_string(),
1258    };
1259    let canonical = format!(
1260        "v2\n{}\n{}\n{}\n{}\n{}",
1261        origin,
1262        method.to_uppercase(),
1263        path,
1264        ts,
1265        body_hash
1266    );
1267    let pair = agent_keypair(&key.pkcs8)?;
1268    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1269    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1270    Ok(format!(
1271        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1272    ))
1273}
1274
1275// ─────────────────────────────────────────────────────────────────────────────
1276// Self-custody feed entries — the client signs what the hub only verifies
1277// ─────────────────────────────────────────────────────────────────────────────
1278
1279/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1280/// exactly this order).
1281#[derive(Serialize)]
1282struct WireFeedFile {
1283    path: String,
1284    sha256: String,
1285    bytes: u64,
1286}
1287
1288/// The unsigned entry in the normative §5.1 field order — serde serializes
1289/// struct fields in declaration order, which IS the wire contract.
1290#[derive(Serialize)]
1291struct UnsignedWireEntry<'a> {
1292    v: u8,
1293    seq: u64,
1294    ts: String,
1295    brain: &'a str,
1296    public_key: &'a str,
1297    kind: &'a str,
1298    op: &'a str,
1299    pack_sha256: &'a str,
1300    files: &'a [WireFeedFile],
1301    removed: &'a [String],
1302    prev_entry_hash: Option<&'a str>,
1303}
1304
1305/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1306/// brain key: serialize the unsigned entry compactly in the normative order,
1307/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1308/// returned string is the exact serialization the hub stores verbatim (plus
1309/// one trailing newline) and every independent reader re-derives.
1310fn self_custody_entry(
1311    key: &AgentSigningKey,
1312    seq: u64,
1313    ts: String,
1314    pack_sha256: &str,
1315    files: &[WireFeedFile],
1316    prev_entry_hash: Option<&str>,
1317) -> LinkResult<String> {
1318    let removed: [String; 0] = [];
1319    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1320        v: 1,
1321        seq,
1322        ts,
1323        brain: &key.multikey,
1324        public_key: &key.public_key_spki,
1325        kind: "push",
1326        op: "snapshot",
1327        pack_sha256,
1328        files,
1329        removed: &removed,
1330        prev_entry_hash,
1331    })
1332    .expect("serialize feed entry");
1333    let pair = agent_keypair(&key.pkcs8)?;
1334    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1335    Ok(format!(
1336        "{},\"sig\":\"{}\"}}",
1337        &unsigned[..unsigned.len() - 1],
1338        sig
1339    ))
1340}
1341
1342/// An env var, treated as absent when unset or empty (an empty
1343/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1344fn env_nonempty(name: &str) -> Option<String> {
1345    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1346}
1347
1348/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1349/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1350/// ignored (forward-compatible). A missing or unreadable file is simply "not
1351/// configured here".
1352fn config_file_hub(path: &Path) -> Option<String> {
1353    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1354    #[cfg(unix)]
1355    let file = {
1356        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1357        use std::os::unix::ffi::OsStrExt as _;
1358        let parent =
1359            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1360        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1361        let fd = unsafe {
1362            libc::openat(
1363                parent.as_raw_fd(),
1364                leaf.as_ptr(),
1365                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1366            )
1367        };
1368        if fd < 0 {
1369            return None;
1370        }
1371        unsafe { std::fs::File::from_raw_fd(fd) }
1372    };
1373    #[cfg(not(unix))]
1374    let file = std::fs::File::open(path).ok()?;
1375    let metadata = file.metadata().ok()?;
1376    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1377        return None;
1378    }
1379    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1380    file.take(MAX_CONFIG_BYTES + 1)
1381        .read_to_end(&mut bytes)
1382        .ok()?;
1383    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1384        return None;
1385    }
1386    let text = String::from_utf8(bytes).ok()?;
1387    for line in text.lines() {
1388        let line = line.trim();
1389        if line.is_empty() || line.starts_with('#') {
1390            continue;
1391        }
1392        if let Some((k, v)) = line.split_once('=') {
1393            if k.trim() == "hub" {
1394                let v = v.trim();
1395                if !v.is_empty() {
1396                    return Some(v.to_string());
1397                }
1398            }
1399        }
1400    }
1401    None
1402}
1403
1404/// The bearer key must never travel in cleartext; only loopback hosts may
1405/// skip TLS (local development against a hub on localhost).
1406fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1407    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1408        hub: hub.to_string(),
1409    })?;
1410    if !(parsed.scheme().eq_ignore_ascii_case("https")
1411        || parsed.scheme().eq_ignore_ascii_case("http"))
1412        || !parsed.username().is_empty()
1413        || parsed.password().is_some()
1414        || (parsed.path() != "/" && !parsed.path().is_empty())
1415        || parsed.query().is_some()
1416        || parsed.fragment().is_some()
1417    {
1418        return Err(LinkError::UnsafeHub {
1419            hub: hub.to_string(),
1420        });
1421    }
1422    let loopback = match parsed.host() {
1423        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1424        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1425        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1426        None => false,
1427    };
1428    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1429        Ok(())
1430    } else {
1431        Err(LinkError::UnsafeHub {
1432            hub: hub.to_string(),
1433        })
1434    }
1435}
1436
1437/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1438/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1439/// value tends to echo the whole header line, credential included, so the
1440/// gate sits here instead.
1441fn clean_key(raw: &str) -> LinkResult<String> {
1442    let k = raw.trim();
1443    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1444        return Err(LinkError::BadKey);
1445    }
1446    Ok(k.to_string())
1447}
1448
1449// ─────────────────────────────────────────────────────────────────────────────
1450// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1451// ─────────────────────────────────────────────────────────────────────────────
1452
1453/// One hub response: the status plus the parsed JSON body when there was one.
1454#[derive(Debug)]
1455pub struct HubResponse {
1456    /// The HTTP status code.
1457    pub status: u16,
1458    /// The parsed JSON body, `None` when the body was empty or not JSON.
1459    pub body: Option<Value>,
1460}
1461
1462struct RawHubResponse {
1463    status: u16,
1464    body: Vec<u8>,
1465}
1466
1467/// Whether a request carries the bearer credential.
1468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1469enum Auth {
1470    /// Send `authorization: Bearer <key>`; error without a key.
1471    Required,
1472    /// Send no credential — the propose door is unauthenticated by design.
1473    None,
1474    /// Send the configured credential when one exists, otherwise nothing —
1475    /// brain-addressed propose works anonymously on public brains, and an
1476    /// authenticated caller earns a bigger actor-class budget.
1477    Optional,
1478}
1479
1480fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1481    ureq::AgentBuilder::new()
1482        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1483        // Never follow a redirect while a bearer, a store pack, or a signed
1484        // response is in flight. Callers see the 3xx as a non-success instead
1485        // of letting an origin steer sensitive material elsewhere.
1486        .redirects(0)
1487        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1488        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1489        .timeout_write(overall)
1490        .timeout(overall)
1491}
1492
1493fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1494    hub_agent_with_timeout(
1495        cfg,
1496        std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1497    )
1498}
1499
1500fn hub_agent_with_timeout(
1501    cfg: &HubConfig,
1502    overall: std::time::Duration,
1503) -> LinkResult<ureq::Agent> {
1504    if !cfg.store_selected {
1505        return Ok(agent_builder_with_timeout(overall).build());
1506    }
1507    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1508        hub: cfg.hub.clone(),
1509    })?;
1510    pinned_public_agent_pooled(
1511        &parsed,
1512        false,
1513        "store-selected hub",
1514        AgentShape {
1515            overall,
1516            ..AgentShape::default()
1517        },
1518    )
1519}
1520
1521/// Perform one hub request. `path` is the binding path (starts with `/`);
1522/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1523/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1524/// the [`HubResponse`] for [`ensure_ok`] to shape.
1525fn request_raw(
1526    cfg: &HubConfig,
1527    method: &str,
1528    path: &str,
1529    body: Option<&Value>,
1530    auth: Auth,
1531    max_response_bytes: u64,
1532) -> LinkResult<RawHubResponse> {
1533    let http = hub_agent(cfg)?;
1534    request_raw_with_agent(
1535        cfg,
1536        &http,
1537        method,
1538        path,
1539        body,
1540        RawRequestOptions {
1541            auth,
1542            max_response_bytes,
1543            request_id: None,
1544            retry_transport: false,
1545        },
1546    )
1547}
1548
1549/// A body-bearing request that reads immutable state without changing hub
1550/// authority. Some proof lists are too large for a URL, so link.md transports
1551/// them with POST even though repeating the request is side-effect free.
1552fn request_raw_retryable_read(
1553    cfg: &HubConfig,
1554    method: &str,
1555    path: &str,
1556    body: Option<&Value>,
1557    auth: Auth,
1558    max_response_bytes: u64,
1559) -> LinkResult<RawHubResponse> {
1560    let http = hub_agent(cfg)?;
1561    request_raw_with_agent(
1562        cfg,
1563        &http,
1564        method,
1565        path,
1566        body,
1567        RawRequestOptions {
1568            auth,
1569            max_response_bytes,
1570            request_id: None,
1571            retry_transport: true,
1572        },
1573    )
1574}
1575
1576struct RawRequestOptions<'a> {
1577    auth: Auth,
1578    max_response_bytes: u64,
1579    request_id: Option<&'a str>,
1580    retry_transport: bool,
1581}
1582
1583fn request_raw_with_agent(
1584    cfg: &HubConfig,
1585    http: &ureq::Agent,
1586    method: &str,
1587    path: &str,
1588    body: Option<&Value>,
1589    options: RawRequestOptions<'_>,
1590) -> LinkResult<RawHubResponse> {
1591    let url = format!("{}{}", cfg.hub, path);
1592    let encoded_body = body.map(Value::to_string);
1593    let origin = normalized_origin(&cfg.hub)?;
1594    let safe_read = (method == "GET" && encoded_body.is_none()) || options.retry_transport;
1595    let mut read_attempt = 0;
1596    loop {
1597        // An agent signing key outranks the bearer: possession proofs put
1598        // nothing reusable on the wire, so when both are configured the
1599        // stronger one wins. Regenerate its timestamp for a whole-request GET
1600        // retry; a long stalled read must not age the replacement signature
1601        // outside the hub's acceptance window. This applies only to GETs and
1602        // the explicitly marked read-only POST bindings above.
1603        let credential = match options.auth {
1604            Auth::Required => Some(match &cfg.agent_key {
1605                Some(key) => {
1606                    linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?
1607                }
1608                None => format!("Bearer {}", cfg.require_key()?),
1609            }),
1610            Auth::Optional => match &cfg.agent_key {
1611                Some(key) => Some(linkmd_sig_header(
1612                    key,
1613                    &origin,
1614                    method,
1615                    path,
1616                    encoded_body.as_deref(),
1617                )?),
1618                None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1619            },
1620            Auth::None => None,
1621        };
1622        let result = with_connect_retries(|| {
1623            let mut req = http.request(method, &url);
1624            if let Some(value) = &credential {
1625                req = req.set("authorization", value);
1626            }
1627            if let Some(value) = options.request_id {
1628                req = req.set("x-request-id", value);
1629            }
1630            match &encoded_body {
1631                Some(value) => req
1632                    .set("content-type", "application/json")
1633                    .send_string(value)
1634                    .map_err(Box::new),
1635                None => req.call().map_err(Box::new),
1636            }
1637        });
1638        let resp = match result {
1639            Ok(resp) => resp,
1640            Err(error) => match *error {
1641                ureq::Error::Status(_, resp) => resp,
1642                ureq::Error::Transport(error) => {
1643                    if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS {
1644                        std::thread::sleep(std::time::Duration::from_millis(
1645                            SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1646                        ));
1647                        read_attempt += 1;
1648                        continue;
1649                    }
1650                    return Err(LinkError::Transport {
1651                        hub: cfg.hub.clone(),
1652                        message: error.to_string(),
1653                    });
1654                }
1655            },
1656        };
1657
1658        let status = resp.status();
1659        let buf = match read_response_body(resp, options.max_response_bytes + 1, &cfg.hub) {
1660            Ok(buf) => buf,
1661            Err(LinkError::Transport { .. })
1662                if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS =>
1663            {
1664                std::thread::sleep(std::time::Duration::from_millis(
1665                    SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1666                ));
1667                read_attempt += 1;
1668                continue;
1669            }
1670            Err(error) => return Err(error),
1671        };
1672        if buf.len() as u64 > options.max_response_bytes {
1673            return Err(LinkError::ResponseTooLarge {
1674                limit_bytes: options.max_response_bytes,
1675            });
1676        }
1677        return Ok(RawHubResponse { status, body: buf });
1678    }
1679}
1680
1681fn request_capped(
1682    cfg: &HubConfig,
1683    method: &str,
1684    path: &str,
1685    body: Option<&Value>,
1686    auth: Auth,
1687    max_response_bytes: u64,
1688) -> LinkResult<HubResponse> {
1689    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1690    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1691    Ok(HubResponse {
1692        status: raw.status,
1693        body: parsed,
1694    })
1695}
1696
1697/// One request the hub is allowed to take brain-sized time over. Used for the
1698/// commit lane, where abandoning the attempt early does not cancel the work —
1699/// it only loses the receipt for it.
1700///
1701/// Which is why a lost connection is asked again rather than reported. The
1702/// mutation coordinate is derived from the observed base and the exact
1703/// operation set precisely so a repeat is the SAME effect: the hub answers a
1704/// mutation it already applied with that commit's receipt instead of a second
1705/// commit. Measured on a 32,338-file store: the commit landed durably and the
1706/// push reported a transport failure, because nobody went back for the
1707/// receipt.
1708fn request_patient(
1709    cfg: &HubConfig,
1710    method: &str,
1711    path: &str,
1712    body: Option<&Value>,
1713    auth: Auth,
1714) -> LinkResult<HubResponse> {
1715    let http = hub_agent_with_timeout(
1716        cfg,
1717        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1718    )?;
1719    let mut attempt = 0;
1720    let validation_started = std::time::Instant::now();
1721    let mut validation_wait = std::time::Duration::from_millis(250);
1722    loop {
1723        let sent = request_raw_with_agent(
1724            cfg,
1725            &http,
1726            method,
1727            path,
1728            body,
1729            RawRequestOptions {
1730                auth,
1731                max_response_bytes: MAX_RESPONSE_BYTES,
1732                request_id: None,
1733                retry_transport: false,
1734            },
1735        );
1736        match sent {
1737            Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1738                std::thread::sleep(std::time::Duration::from_millis(
1739                    COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1740                ));
1741                attempt += 1;
1742            }
1743            Err(error) => return Err(error),
1744            Ok(raw) => {
1745                let response = HubResponse {
1746                    status: raw.status,
1747                    body: serde_json::from_slice(&raw.body).ok(),
1748                };
1749                // A patient mutation can land, lose its response, and then be
1750                // retried while validation recovery is still behind the new
1751                // source head. The hub returns the same typed lag signal as
1752                // the head endpoint. Repeating the exact content-derived
1753                // mutation id is idempotent and is the only way to recover its
1754                // receipt without guessing whether the first attempt landed.
1755                if v2_validation_catching_up(&response) {
1756                    if validation_started.elapsed() >= std::time::Duration::from_secs(15 * 60) {
1757                        return Err(LinkError::Http {
1758                            what: "v2 commit receipt",
1759                            status: response.status,
1760                            message: "validation/index recovery did not make the exact mutation receipt available within 15 minutes".to_string(),
1761                            code: Some("validation_index_catching_up".to_string()),
1762                            details: response.body,
1763                        });
1764                    }
1765                    std::thread::sleep(validation_wait);
1766                    validation_wait = validation_wait
1767                        .saturating_mul(2)
1768                        .min(std::time::Duration::from_secs(5));
1769                    continue;
1770                }
1771                return Ok(response);
1772            }
1773        }
1774    }
1775}
1776
1777fn request(
1778    cfg: &HubConfig,
1779    method: &str,
1780    path: &str,
1781    body: Option<&Value>,
1782    auth: Auth,
1783) -> LinkResult<HubResponse> {
1784    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1785}
1786
1787/// Repeat one challenge-response mutation with the exact request identity the
1788/// hub bound into its actor claim. A new request id would create a new actor
1789/// hash and therefore a different canonical commit candidate, making the
1790/// otherwise correct self-custody signature unusable.
1791fn request_with_request_id(
1792    cfg: &HubConfig,
1793    method: &str,
1794    path: &str,
1795    body: Option<&Value>,
1796    auth: Auth,
1797    request_id: &str,
1798) -> LinkResult<HubResponse> {
1799    if request_id.is_empty()
1800        || request_id.len() > 128
1801        || !request_id
1802            .bytes()
1803            .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1804    {
1805        return Err(invalid_feed("hub returned an unsafe request id"));
1806    }
1807    // Both callers are commit responses, so this shares the commit lane's
1808    // patience: the hub is finishing the same brain-sized work either way.
1809    let http = hub_agent_with_timeout(
1810        cfg,
1811        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1812    )?;
1813    let raw = request_raw_with_agent(
1814        cfg,
1815        &http,
1816        method,
1817        path,
1818        body,
1819        RawRequestOptions {
1820            auth,
1821            max_response_bytes: MAX_RESPONSE_BYTES,
1822            request_id: Some(request_id),
1823            retry_transport: false,
1824        },
1825    )?;
1826    Ok(HubResponse {
1827        status: raw.status,
1828        body: serde_json::from_slice(&raw.body).ok(),
1829    })
1830}
1831
1832fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1833    if (200..300).contains(&r.status) {
1834        return Ok(r.body);
1835    }
1836    ensure_ok(
1837        HubResponse {
1838            status: r.status,
1839            body: serde_json::from_slice(&r.body).ok(),
1840        },
1841        what,
1842    )
1843    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1844}
1845
1846/// These failures happen before any HTTP request reaches the hub, so retrying
1847/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1848/// bytes may have crossed the wire, the caller must rely on the verb's own
1849/// idempotency contract instead of guessing.
1850fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1851    matches!(
1852        kind,
1853        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1854    )
1855}
1856
1857fn with_connect_retries(
1858    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1859) -> Result<ureq::Response, Box<ureq::Error>> {
1860    let mut attempt = 0;
1861    loop {
1862        match send() {
1863            Err(error)
1864                if matches!(
1865                    error.as_ref(),
1866                    ureq::Error::Transport(transport)
1867                        if is_pre_request_transport(transport.kind())
1868                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1869            {
1870                std::thread::sleep(std::time::Duration::from_millis(
1871                    CONNECT_RETRY_BACKOFF_MS[attempt],
1872                ));
1873                attempt += 1;
1874            }
1875            result => return result,
1876        }
1877    }
1878}
1879
1880fn hub_is_loopback(hub: &str) -> bool {
1881    url::Url::parse(hub).ok().is_some_and(|parsed| {
1882        parsed.host().is_some_and(|host| match host {
1883            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1884            url::Host::Ipv4(ip) => ip.is_loopback(),
1885            url::Host::Ipv6(ip) => ip.is_loopback(),
1886        })
1887    })
1888}
1889
1890/// Every safety property a presigned object-store URL must carry, checked
1891/// before anything is sent to it. Split out from agent construction so a batch
1892/// can share one connection pool without ever skipping a per-URL check.
1893fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1894    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1895        message: "the hub returned an invalid object-store URL".to_string(),
1896    })?;
1897    let allow_private = hub_is_loopback(&cfg.hub)
1898        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1899    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1900        || !parsed.username().is_empty()
1901        || parsed.password().is_some()
1902        || parsed.fragment().is_some()
1903    {
1904        return Err(LinkError::InvalidPack {
1905            message: "the hub returned an unsafe object-store URL".to_string(),
1906        });
1907    }
1908    Ok((parsed, allow_private))
1909}
1910
1911fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1912    let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1913    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1914        LinkError::InvalidPack {
1915            message: "the hub returned an object-store URL with an unsafe network target"
1916                .to_string(),
1917        }
1918    })
1919}
1920
1921/// One pinned agent for a whole staging batch.
1922///
1923/// Every object in a batch is presigned against the same object-store
1924/// authority, so one agent can serve them all — and must: building one per
1925/// object costs a DNS resolution and a fresh TLS handshake per object, which is
1926/// the difference between a real store staging in minutes and in an hour. The
1927/// shared authority is verified rather than assumed, and a batch that ever
1928/// spans two of them falls back to a per-object agent.
1929fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1930    let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1931    let authority = (
1932        first.host_str()?.to_string(),
1933        first.port_or_known_default()?,
1934    );
1935    for raw in &urls[1..] {
1936        let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1937        if (parsed.host_str()?, parsed.port_or_known_default()?)
1938            != (authority.0.as_str(), authority.1)
1939        {
1940            return None;
1941        }
1942    }
1943    pinned_public_agent_pooled(
1944        &first,
1945        allow_private,
1946        "object-store URL",
1947        AgentShape {
1948            idle_per_host: V2_UPLOAD_CONCURRENCY,
1949            ..AgentShape::default()
1950        },
1951    )
1952    .ok()
1953}
1954
1955/// Turn an object-store transport failure into a useful but credential-safe
1956/// diagnostic. `ureq::Transport`'s Display implementation includes the full
1957/// request URL; for a presigned URL that means the short-lived signature and
1958/// every signed query coordinate. The error kind is enough to distinguish a
1959/// network failure without copying bearer material into terminals or logs.
1960fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1961    LinkError::Transport {
1962        hub: "the object store".to_string(),
1963        message: format!("network error ({:?})", error.kind()),
1964    }
1965}
1966
1967fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1968    let http = presigned_agent(cfg, raw)?;
1969    let deadline = std::time::Instant::now()
1970        .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1971        .ok_or_else(upload_deadline_error)?;
1972    let mut attempt = 0;
1973    let result = loop {
1974        // The reservation states content-length itself. This client sends the
1975        // body from memory, so it never needs to add one, and adding one under
1976        // a different capitalization would leave both on the wire.
1977        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1978        if let Some(map) = headers.as_object() {
1979            for (name, value) in map {
1980                if let Some(value) = value.as_str() {
1981                    req = req.set(name, value);
1982                }
1983            }
1984        }
1985        match req.send_bytes(bytes) {
1986            // An upload is idempotent here: fixed content address, exact
1987            // length and checksum, write-once precondition. A dropped
1988            // connection or a single overloaded refusal is worth replaying
1989            // rather than failing a whole migration on. 412 already means
1990            // "someone else stored these exact bytes".
1991            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1992                attempt += 1;
1993            }
1994            Err(ureq::Error::Status(status, _))
1995                if status != 412
1996                    && is_retryable_upload_status(status)
1997                    && wait_for_upload_retry(deadline, attempt) =>
1998            {
1999                attempt += 1;
2000            }
2001            result => break result,
2002        }
2003    };
2004    match result {
2005        Ok(resp) if (200..300).contains(&resp.status()) => {
2006            drain_presigned_response(resp);
2007            Ok(())
2008        }
2009        Ok(resp) => Err(presigned_upload_refusal(resp)),
2010        Err(error) => match error {
2011            // Immutable uploads use If-None-Match. A concurrent writer may
2012            // win the same content address; the commit/confirm endpoint reads
2013            // and hashes that object before accepting it, so 412 safely means
2014            // "continue to verification", never "trust the upload".
2015            ureq::Error::Status(412, _) => Ok(()),
2016            ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
2017            ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
2018        },
2019    }
2020}
2021
2022/// Read a response body, typed as the transport failure it is.
2023///
2024/// A connection that fails while the body is still arriving is the same class
2025/// of problem as one that fails before it arrives at all — but a bare `?` on
2026/// the read turned it into an anonymous io error that no retry path recognised
2027/// and no message attributed to a peer. On a migration making tens of
2028/// thousands of requests, one stalled body ended the whole push and reported
2029/// only "timed out reading response".
2030fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
2031    let mut buf = Vec::new();
2032    response
2033        .into_reader()
2034        .take(limit)
2035        .read_to_end(&mut buf)
2036        .map_err(|error| LinkError::Transport {
2037            hub: peer.to_string(),
2038            message: error.to_string(),
2039        })?;
2040    Ok(buf)
2041}
2042
2043/// Draining a response is what returns its connection to the pool: the client
2044/// cannot recycle a socket whose body may still have bytes pending, so a body
2045/// that is dropped instead of read costs the next object a fresh handshake.
2046/// An object-store PUT answers with almost nothing, so this is nearly free.
2047fn drain_presigned_response(response: ureq::Response) {
2048    let mut reader = response.into_reader().take(64 * 1024);
2049    let _ = std::io::copy(&mut reader, &mut std::io::sink());
2050}
2051
2052/// Carry the object store's own reason for a refusal. An unexplained status is
2053/// unactionable; the body states checksum mismatch, precondition, or expiry.
2054fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
2055    let status = response.status();
2056    let detail = response
2057        .into_string()
2058        .ok()
2059        .map(|body| body.chars().take(400).collect::<String>())
2060        .filter(|body| !body.trim().is_empty());
2061    LinkError::Http {
2062        what: "pack upload",
2063        status,
2064        message: match detail {
2065            Some(body) => format!(
2066                "object store rejected the upload: {}",
2067                body.replace('\n', " ")
2068            ),
2069            None => "object store rejected the upload".to_string(),
2070        },
2071        code: None,
2072        details: None,
2073    }
2074}
2075
2076fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
2077    max_bytes.checked_add(1)
2078}
2079
2080fn presigned_download_read_limit() -> u64 {
2081    one_past_bounded_limit(MAX_PACK_BYTES)
2082        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
2083}
2084
2085fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
2086    let http = presigned_agent(cfg, raw)?;
2087    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
2088        Ok(resp) => resp,
2089        Err(error) => match *error {
2090            ureq::Error::Status(_, resp) => {
2091                return Err(LinkError::Http {
2092                    what: "pack download",
2093                    status: resp.status(),
2094                    message: "object store rejected the download".to_string(),
2095                    code: None,
2096                    details: None,
2097                });
2098            }
2099            ureq::Error::Transport(err) => {
2100                return Err(LinkError::Transport {
2101                    hub: "the object store".to_string(),
2102                    message: err.to_string(),
2103                });
2104            }
2105        },
2106    };
2107    if !(200..300).contains(&resp.status()) {
2108        return Err(LinkError::Http {
2109            what: "pack download",
2110            status: resp.status(),
2111            message: "object store rejected the download".to_string(),
2112            code: None,
2113            details: None,
2114        });
2115    }
2116    let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2117    if bytes.len() as u64 > MAX_PACK_BYTES {
2118        return Err(LinkError::InvalidPack {
2119            message: "download exceeds the compressed-size limit".to_string(),
2120        });
2121    }
2122    Ok(bytes)
2123}
2124
2125/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
2126/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
2127/// answer.
2128fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2129    if !(200..300).contains(&r.status) {
2130        let message = r
2131            .body
2132            .as_ref()
2133            .and_then(|b| b.get("error"))
2134            .and_then(Value::as_str)
2135            .unwrap_or("unknown error")
2136            .to_string();
2137        let code = r
2138            .body
2139            .as_ref()
2140            .and_then(|b| b.get("code"))
2141            .and_then(Value::as_str)
2142            .map(str::to_string);
2143        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2144        return Err(LinkError::Http {
2145            what,
2146            status: r.status,
2147            message,
2148            code,
2149            details,
2150        });
2151    }
2152    r.body.ok_or(LinkError::NotJson {
2153        what,
2154        status: r.status,
2155    })
2156}
2157
2158// ─────────────────────────────────────────────────────────────────────────────
2159// resolve — handle → brain card; @brain/id → the record
2160// ─────────────────────────────────────────────────────────────────────────────
2161
2162/// Resolve an address. A bare `@brain` returns the brain card (metadata +
2163/// index stats — the v0 form of the card; keys arrive with the protocol's
2164/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
2165/// record, frontmatter + body.
2166fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2167    match ip {
2168        std::net::IpAddr::V4(ip) => {
2169            let [a, b, c, _] = ip.octets();
2170            !(a == 0
2171                || a == 10
2172                || a == 127
2173                || (a == 100 && (64..=127).contains(&b))
2174                || (a == 169 && b == 254)
2175                || (a == 172 && (16..=31).contains(&b))
2176                || (a == 192 && b == 0 && c == 0)
2177                || (a == 192 && b == 0 && c == 2)
2178                || (a == 192 && b == 88 && c == 99)
2179                || (a == 192 && b == 168)
2180                || (a == 198 && (b == 18 || b == 19))
2181                || (a == 198 && b == 51 && c == 100)
2182                || (a == 203 && b == 0 && c == 113)
2183                || a >= 224)
2184        }
2185        std::net::IpAddr::V6(ip) => {
2186            let segments = ip.segments();
2187            // Conservatively accept only global unicast 2000::/3, excluding
2188            // special-purpose/transition blocks. In particular, 6to4 embeds
2189            // an IPv4 destination and must not tunnel a validated public
2190            // connect to 127/8 or RFC1918.
2191            (segments[0] & 0xe000) == 0x2000
2192                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2193                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2194                && segments[0] != 0x2002
2195                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2196        }
2197    }
2198}
2199
2200#[derive(Clone)]
2201struct PinnedRegistryResolver {
2202    netloc: String,
2203    addresses: Vec<std::net::SocketAddr>,
2204}
2205
2206impl ureq::Resolver for PinnedRegistryResolver {
2207    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2208        if requested == self.netloc {
2209            Ok(self.addresses.clone())
2210        } else {
2211            Err(std::io::Error::new(
2212                std::io::ErrorKind::PermissionDenied,
2213                "registry request attempted to resolve an unvalidated authority",
2214            ))
2215        }
2216    }
2217}
2218
2219fn pinned_public_agent(
2220    url: &url::Url,
2221    allow_private: bool,
2222    label: &str,
2223) -> LinkResult<ureq::Agent> {
2224    pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2225}
2226
2227/// How one agent is built: `idle_per_host` sizes the connection pool (the
2228/// default of one is right for a lone request but throttles a concurrent batch
2229/// back to a single warm connection), and `overall` is the wall-clock budget
2230/// for one attempt.
2231struct AgentShape {
2232    idle_per_host: usize,
2233    overall: std::time::Duration,
2234}
2235
2236impl Default for AgentShape {
2237    fn default() -> Self {
2238        Self {
2239            idle_per_host: 1,
2240            overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2241        }
2242    }
2243}
2244
2245fn pinned_public_agent_pooled(
2246    url: &url::Url,
2247    allow_private: bool,
2248    label: &str,
2249    shape: AgentShape,
2250) -> LinkResult<ureq::Agent> {
2251    let host = url
2252        .host_str()
2253        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2254    let port = url
2255        .port_or_known_default()
2256        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2257    let addresses = resolve_addresses_with_deadline(
2258        host,
2259        port,
2260        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2261    )
2262    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2263    if addresses.is_empty() {
2264        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2265    }
2266    if !allow_private
2267        && addresses
2268            .iter()
2269            .any(|address| !is_public_registry_ip(address.ip()))
2270    {
2271        return Err(invalid_feed(format!(
2272            "{label} resolves to a non-public address"
2273        )));
2274    }
2275    let netloc = if host.contains(':') {
2276        format!("[{host}]:{port}")
2277    } else {
2278        format!("{host}:{port}")
2279    };
2280    Ok(agent_builder_with_timeout(shape.overall)
2281        .max_idle_connections_per_host(shape.idle_per_host.max(1))
2282        .resolver(PinnedRegistryResolver { netloc, addresses })
2283        .build())
2284}
2285
2286/// Resolve one authority without allowing libc DNS to hold the caller forever.
2287/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
2288/// worker and only its result channel is awaited. A late resolver result has no
2289/// side effects and is discarded after the deadline.
2290fn resolve_addresses_with_deadline(
2291    host: &str,
2292    port: u16,
2293    timeout: std::time::Duration,
2294) -> std::io::Result<Vec<std::net::SocketAddr>> {
2295    use std::net::ToSocketAddrs as _;
2296
2297    let host = host.to_string();
2298    let (send, receive) = std::sync::mpsc::sync_channel(1);
2299    std::thread::Builder::new()
2300        .name("dbmd-dns".to_string())
2301        .spawn(move || {
2302            let result = (host.as_str(), port)
2303                .to_socket_addrs()
2304                .map(|addresses| addresses.collect());
2305            let _ = send.send(result);
2306        })
2307        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2308    match receive.recv_timeout(timeout) {
2309        Ok(result) => result,
2310        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2311            std::io::ErrorKind::TimedOut,
2312            "resolution exceeded its deadline",
2313        )),
2314        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2315            "resolver stopped without returning a result",
2316        )),
2317    }
2318}
2319
2320fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2321    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2322    pinned_public_agent(url, allow_private, "registry home")
2323}
2324
2325/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
2326/// from a FOREIGN home during registry resolution. DNS is resolved once,
2327/// every answer is required to be public, and that exact answer set is pinned
2328/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
2329fn get_json_absolute(url: &str) -> LinkResult<Value> {
2330    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2331    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2332    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2333        || !parsed.username().is_empty()
2334        || parsed.password().is_some()
2335        || parsed.query().is_some()
2336        || parsed.fragment().is_some()
2337    {
2338        return Err(invalid_feed("unsafe registry home URL"));
2339    }
2340    let http = registry_agent(&parsed)?;
2341    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2342        Ok(resp) => resp,
2343        Err(error) => match *error {
2344            ureq::Error::Status(status, resp) => {
2345                let _ = resp;
2346                return Err(LinkError::Http {
2347                    what: "registry home fetch",
2348                    status,
2349                    message: "the home node rejected the card request".to_string(),
2350                    code: None,
2351                    details: None,
2352                });
2353            }
2354            ureq::Error::Transport(err) => {
2355                return Err(LinkError::Transport {
2356                    hub: url.to_string(),
2357                    message: err.to_string(),
2358                });
2359            }
2360        },
2361    };
2362    if !(200..300).contains(&resp.status()) {
2363        return Err(LinkError::Http {
2364            what: "registry home fetch",
2365            status: resp.status(),
2366            message: "the home node returned a redirect or error".to_string(),
2367            code: None,
2368            details: None,
2369        });
2370    }
2371    let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2372    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2373        return Err(LinkError::ResponseTooLarge {
2374            limit_bytes: MAX_REGISTRY_CARD_BYTES,
2375        });
2376    }
2377    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2378        message: "the home node returned invalid JSON".to_string(),
2379    })
2380}
2381
2382/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
2383/// E5): look the handle up in the hub's registry, fetch the brain card from
2384/// the returned HOME node, and PIN — the card's identity fingerprint must
2385/// equal the registry's, or resolution fails. Returns the card enriched with
2386/// the resolved `home`, or `Ok(None)` when the registry has no such handle
2387/// (so the caller can fall back to a direct lookup).
2388pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2389    require_safe_ref(handle)?;
2390    // Hold the validated trust-directory capability before any network I/O.
2391    // Every lock/load/save below remains relative to this exact inode even if
2392    // an attacker swaps an ancestor while the registry or home is answering.
2393    let trust_directory = open_trust_dir(cfg)?;
2394    let reg = request_capped(
2395        cfg,
2396        "GET",
2397        &format!("/api/hub/registry/{handle}"),
2398        None,
2399        Auth::None,
2400        MAX_REGISTRY_CARD_BYTES,
2401    )?;
2402    if reg.status == 404 {
2403        return Ok(None);
2404    }
2405    let body = ensure_ok(reg, "registry resolve")?;
2406    let home = body
2407        .get("home")
2408        .and_then(Value::as_str)
2409        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2410    let brain = body
2411        .get("brain")
2412        .and_then(Value::as_str)
2413        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2414    if !crate::ulid::is_ulid(brain) {
2415        return Err(invalid_feed(
2416            "registry entry brain is not a canonical lowercase ULID",
2417        ));
2418    }
2419    let want_fp = body
2420        .get("identity")
2421        .and_then(|i| i.get("fingerprint"))
2422        .and_then(Value::as_str)
2423        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2424
2425    let home = home.trim_end_matches('/');
2426    let origin = normalized_origin(home)?;
2427    if origin != home {
2428        return Err(invalid_feed(
2429            "registry home must be an origin without a path, query, or fragment",
2430        ));
2431    }
2432    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2433    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2434    if let Some(binding) = &alias_binding {
2435        if binding
2436            .home
2437            .as_deref()
2438            .is_some_and(|pinned_home| pinned_home != home)
2439        {
2440            return Err(invalid_feed(
2441                "registry relocated a pinned handle to a different home",
2442            ));
2443        }
2444    }
2445    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2446    if card.get("id").and_then(Value::as_str) != Some(brain) {
2447        return Err(invalid_feed(
2448            "the home node served a card for a different brain",
2449        ));
2450    }
2451    let identity: FeedIdentity = serde_json::from_value(
2452        card.get("identity")
2453            .cloned()
2454            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2455    )
2456    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2457    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2458    let got_fp = card
2459        .get("identity")
2460        .and_then(|i| i.get("fingerprint"))
2461        .and_then(Value::as_str)
2462        .unwrap_or_default();
2463    if got_fp != want_fp {
2464        return Err(invalid_feed(
2465            "the home node served an identity that does not match the registry — refusing",
2466        ));
2467    }
2468    let current = format!("ed25519:{}", identity.fingerprint);
2469    let advertised_seq = card
2470        .get("headSeq")
2471        .and_then(Value::as_u64)
2472        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2473    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2474    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2475        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2476    {
2477        return Err(invalid_feed(
2478            "the home node served an invalid feed head boundary",
2479        ));
2480    }
2481    // This is required even on first contact. Otherwise a syntactically valid
2482    // identity may claim that a rotation happened after a head the home has
2483    // never reached, then become the permanent TOFU anchor.
2484    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2485    let registry_alias = AliasBinding {
2486        v: 1,
2487        origin: normalized_origin(&cfg.hub)?,
2488        requested: handle.to_string(),
2489        brain: brain.to_string(),
2490        home: Some(home.to_string()),
2491    };
2492    save_canonical_pin_and_alias(
2493        cfg,
2494        &trust_directory,
2495        handle,
2496        brain,
2497        TrustState {
2498            v: 2,
2499            origin: normalized_origin(&cfg.hub)?,
2500            requested: brain.to_string(),
2501            brain: brain.to_string(),
2502            home: None,
2503            anchor,
2504            current,
2505            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2506            feed_hash: pinned
2507                .as_ref()
2508                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2509            rotations: identity.rotations.clone(),
2510            hub_signer: None,
2511            protocol_profile: None,
2512        },
2513        Some(&registry_alias),
2514    )?;
2515    let mut out = card;
2516    if let Value::Object(map) = &mut out {
2517        map.insert("home".to_string(), Value::String(home.to_string()));
2518        map.insert(
2519            "resolvedVia".to_string(),
2520            Value::String("registry".to_string()),
2521        );
2522    }
2523    Ok(Some(out))
2524}
2525
2526pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2527    // `Address::parse` refuses these shapes already, but `Address` has public
2528    // fields — re-assert at the wire so a hand-built address can never
2529    // reshape the request path.
2530    require_safe_ref(&addr.brain)?;
2531    if let Some(target) = &addr.target {
2532        let (given, ok) = match target {
2533            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2534            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2535        };
2536        if !ok {
2537            return Err(LinkError::BadAddress {
2538                given: given.clone(),
2539                reason: BAD_TARGET_REASON.to_string(),
2540            });
2541        }
2542    }
2543
2544    // A record is never accepted from the hub's mutable query/index response.
2545    // Under v2, an id lookup may suggest a path, but the client accepts only
2546    // bytes whose exact path proof reaches the verified content root and whose
2547    // parsed id still matches. Under v1, the signed snapshot pack remains the
2548    // binding. Both profiles therefore reject a forged query response.
2549    if let Some(target) = &addr.target {
2550        if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2551            let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2552                what: "resolve",
2553                status: 404,
2554                message: "record not found".to_string(),
2555                code: Some("NOT_FOUND".to_string()),
2556                details: None,
2557            })?;
2558            let (path, file) = match target {
2559                AddressTarget::Path(path) => {
2560                    let file =
2561                        v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2562                            LinkError::Http {
2563                                what: "resolve",
2564                                status: 404,
2565                                message: "record not found".to_string(),
2566                                code: Some("NOT_FOUND".to_string()),
2567                                details: None,
2568                            }
2569                        })?;
2570                    (path.clone(), file)
2571                }
2572                AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2573            };
2574            let mut downloaded =
2575                download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2576            let (_, bytes) = downloaded
2577                .pop()
2578                .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2579            let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2580            accept_v2_head(cfg, &head)?;
2581            return Ok(resolved);
2582        }
2583        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2584        if !remote.head.verified {
2585            return Err(invalid_feed(
2586                "a path-scoped feed cannot prove a record against the full signed snapshot",
2587            ));
2588        }
2589        if remote.head.seq == 0 {
2590            return Err(LinkError::Http {
2591                what: "resolve",
2592                status: 404,
2593                message: "record not found".to_string(),
2594                code: Some("NOT_FOUND".to_string()),
2595                details: None,
2596            });
2597        }
2598        let brain = remote.head.brain.clone();
2599        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2600        return resolve_from_verified_pack(&brain, target, pack);
2601    }
2602
2603    let path = format!("/api/hub/brains/{}", addr.brain);
2604    // Direct first: the caller's own slug and hub-hosted public handles resolve
2605    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2606    // (404) falls through to the federation registry — how a handle reaches a
2607    // brain on ANOTHER node (link.md §7.1, E5).
2608    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2609    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2610        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2611            return Ok(card);
2612        }
2613    }
2614    let mut resolved = ensure_ok(direct, "resolve")?;
2615    if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2616        let v2 = v2_verified_head(cfg, &addr.brain)?
2617            .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2618        if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2619            return Err(invalid_feed(
2620                "resolve card is not bound to the verified v2 brain",
2621            ));
2622        }
2623        let card_identity: FeedIdentity = serde_json::from_value(
2624            resolved
2625                .get("identity")
2626                .cloned()
2627                .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2628        )
2629        .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2630        if card_identity != v2_identity(&v2.identity) {
2631            return Err(invalid_feed(
2632                "resolve card identity differs from the verified v2 identity",
2633            ));
2634        }
2635        accept_v2_head(cfg, &v2)?;
2636        if let Value::Object(card) = &mut resolved {
2637            card.insert(
2638                "headSeq".to_string(),
2639                json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2640            );
2641            card.insert(
2642                "feedHash".to_string(),
2643                v2.pointer
2644                    .as_ref()
2645                    .map(|pointer| Value::String(pointer.feed_hash.clone()))
2646                    .unwrap_or(Value::Null),
2647            );
2648            card.insert(
2649                "storageProfile".to_string(),
2650                Value::String("v2".to_string()),
2651            );
2652            if let Some(pointer) = &v2.pointer {
2653                card.insert(
2654                    "updatedAt".to_string(),
2655                    Value::String(pointer.signed_at.clone()),
2656                );
2657            }
2658        }
2659        return Ok(resolved);
2660    }
2661    // A successful direct response is accepted only after the same centralized
2662    // identity/rotation/feed checkpoint verification used by sync and
2663    // subscribe. No verb gets a weaker ad-hoc pinning path.
2664    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2665    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2666        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2667        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2668    {
2669        return Err(invalid_feed(
2670            "resolve card is not bound to the exact verified feed checkpoint",
2671        ));
2672    }
2673    let card_identity: FeedIdentity = serde_json::from_value(
2674        resolved
2675            .get("identity")
2676            .cloned()
2677            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2678    )
2679    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2680    if remote.identity.as_ref() != Some(&card_identity) {
2681        return Err(invalid_feed(
2682            "resolve card identity differs from the verified feed identity",
2683        ));
2684    }
2685    Ok(resolved)
2686}
2687
2688/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2689/// mutable query/index result is intentionally not consulted: a signed pack
2690/// digest is the only cryptographic binding between a feed checkpoint and
2691/// record bytes in wire profile v1.
2692fn resolve_from_verified_pack(
2693    brain: &str,
2694    target: &AddressTarget,
2695    pack: Vec<u8>,
2696) -> LinkResult<Value> {
2697    let entries = parse_store_pack(pack)?;
2698    let mut matched: Option<(String, Vec<u8>)> = None;
2699
2700    for (path, bytes) in entries {
2701        let is_candidate = match target {
2702            AddressTarget::Path(want) => &path == want,
2703            AddressTarget::Id(_) => {
2704                path.ends_with(".md")
2705                    && (path.starts_with("records/") || path.starts_with("sources/"))
2706            }
2707        };
2708        if !is_candidate {
2709            continue;
2710        }
2711        let text = std::str::from_utf8(&bytes)
2712            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2713        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2714            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2715        if let AddressTarget::Id(want) = target {
2716            let frontmatter =
2717                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2718                    .map_err(|_| {
2719                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2720                    })?;
2721            if frontmatter.id.as_deref() != Some(want) {
2722                continue;
2723            }
2724        }
2725        if matched.is_some() {
2726            return Err(invalid_feed(
2727                "signed snapshot contains more than one record for the requested target",
2728            ));
2729        }
2730        matched = Some((path, bytes));
2731    }
2732
2733    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2734        what: "resolve",
2735        status: 404,
2736        message: "record not found".to_string(),
2737        code: Some("NOT_FOUND".to_string()),
2738        details: None,
2739    })?;
2740    resolve_from_verified_record_bytes(brain, target, path, bytes)
2741}
2742
2743fn resolve_from_verified_record_bytes(
2744    brain: &str,
2745    target: &AddressTarget,
2746    path: String,
2747    bytes: Vec<u8>,
2748) -> LinkResult<Value> {
2749    match target {
2750        AddressTarget::Path(expected) if expected != &path => {
2751            return Err(invalid_feed(
2752                "verified record path differs from the requested path",
2753            ));
2754        }
2755        AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2756            return Err(invalid_feed(
2757                "verified id resolved outside records or sources",
2758            ));
2759        }
2760        _ => {}
2761    }
2762    let text = std::str::from_utf8(&bytes)
2763        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2764    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2765        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2766    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2767        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2768    let Value::Object(fields) = frontmatter else {
2769        return Err(invalid_feed(format!(
2770            "signed snapshot record `{path}` frontmatter is not a mapping"
2771        )));
2772    };
2773    if let AddressTarget::Id(expected) = target {
2774        if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2775            return Err(invalid_feed(
2776                "verified record id differs from the requested id",
2777            ));
2778        }
2779    }
2780    let mut document = serde_json::Map::new();
2781    document.insert("path".to_string(), Value::String(path));
2782    for (key, value) in fields {
2783        document.insert(key, value);
2784    }
2785    document.insert("body".to_string(), Value::String(parsed.body));
2786    document.insert(
2787        "contentSha".to_string(),
2788        Value::String(content_sha256(&bytes)),
2789    );
2790    Ok(json!({
2791        "brain": brain,
2792        "document": Value::Object(document),
2793    }))
2794}
2795
2796// ─────────────────────────────────────────────────────────────────────────────
2797// sync — pull the granted slice as files; push the local store as a snapshot
2798// ─────────────────────────────────────────────────────────────────────────────
2799
2800/// What a pull materialized.
2801#[derive(Debug, Clone, serde::Serialize)]
2802pub struct PullReport {
2803    /// The brain id the hub reported.
2804    pub brain: String,
2805    /// The brain's slug.
2806    pub slug: String,
2807    /// The hub's feed head at export time.
2808    #[serde(rename = "headSeq")]
2809    pub head_seq: u64,
2810    /// How many files were written.
2811    pub files: usize,
2812    /// Where they were written (as given or derived from the slug).
2813    pub dest: String,
2814    /// Local content files that the export did not carry — present so a
2815    /// caller sees divergence; nothing is ever deleted locally.
2816    #[serde(rename = "extraLocal")]
2817    pub extra_local: Vec<String>,
2818    /// Exact convergence result from the post-install barrier.
2819    #[serde(rename = "syncStatus")]
2820    pub sync_status: String,
2821}
2822
2823struct V2PulledSnapshot {
2824    report: PullReport,
2825    head: V2VerifiedHead,
2826    files: std::collections::BTreeMap<String, V2BaselineFile>,
2827    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2828    local: V2LocalView,
2829    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2830}
2831
2832fn download_verified_snapshot_pack(
2833    cfg: &HubConfig,
2834    brain: &str,
2835    remote: &VerifiedRemote,
2836) -> LinkResult<Vec<u8>> {
2837    let feed_hash = remote
2838        .head
2839        .feed_hash
2840        .as_deref()
2841        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2842    let signed_head = remote
2843        .head_entry
2844        .as_ref()
2845        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2846    let expected = &signed_head.entry.pack_sha256;
2847    if !is_sha256(expected) {
2848        return Err(invalid_feed(
2849            "signed head carries an invalid snapshot pack digest",
2850        ));
2851    }
2852    let path = format!(
2853        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2854        remote.head.seq
2855    );
2856    let body = ensure_ok(
2857        request(cfg, "GET", &path, None, Auth::Required)?,
2858        "sync pull",
2859    )?;
2860    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2861        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2862        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2863        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2864    {
2865        return Err(invalid_feed(
2866            "export response is not bound to the exact verified snapshot",
2867        ));
2868    }
2869    let url = body
2870        .get("url")
2871        .and_then(Value::as_str)
2872        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2873    let bytes = get_presigned(cfg, url)?;
2874    if content_sha256(&bytes) != *expected {
2875        return Err(LinkError::InvalidPack {
2876            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2877        });
2878    }
2879    let entries = parse_store_pack(bytes.clone())?;
2880    if signed_head.entry.kind == "push" {
2881        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2882    }
2883    Ok(bytes)
2884}
2885
2886#[derive(Debug, Clone, Deserialize, Serialize)]
2887struct V2PointerBody {
2888    v: u8,
2889    brain: String,
2890    seq: u64,
2891    commit_hash: String,
2892    feed_hash: String,
2893    content_root: Option<String>,
2894    asset_root: Option<String>,
2895    materializer: String,
2896    signer_epoch: u64,
2897    control_revision: String,
2898    backup_preparation: String,
2899    prior_pointer_hash: Option<String>,
2900    signed_at: String,
2901}
2902
2903#[derive(Debug, Clone, Deserialize)]
2904struct V2SignedPointer {
2905    pointer: V2PointerBody,
2906    hub_public_key: String,
2907    hub_fingerprint: String,
2908    sig: String,
2909}
2910
2911#[derive(Debug, Clone, Deserialize)]
2912struct V2HeadIdentity {
2913    #[serde(default)]
2914    custody: String,
2915    fingerprint: String,
2916    public_key_spki: String,
2917    #[serde(default)]
2918    previous: Vec<V2PreviousIdentity>,
2919    #[serde(default)]
2920    rotations: Vec<String>,
2921}
2922
2923#[derive(Debug, Clone, Deserialize)]
2924struct V2PreviousIdentity {
2925    fingerprint: String,
2926    public_key_spki: String,
2927}
2928
2929#[derive(Debug, Deserialize)]
2930struct V2HeadResponse {
2931    v: u8,
2932    brain_id: String,
2933    profile: String,
2934    view: Option<V2HeadView>,
2935    pointer: Option<V2SignedPointer>,
2936    identity: Option<V2HeadIdentity>,
2937}
2938
2939#[derive(Debug, Clone, Deserialize)]
2940struct V2HeadView {
2941    kind: String,
2942    #[serde(default)]
2943    id: Option<String>,
2944    control_revision: String,
2945}
2946
2947#[derive(Debug, Clone)]
2948struct V2VerifiedHead {
2949    requested: String,
2950    brain_id: String,
2951    view_kind: String,
2952    /// Stable identity of the exact current-content path projection.
2953    view_revision: String,
2954    /// Use-time authority revision independently bound into writes/signatures.
2955    control_revision: String,
2956    identity: V2HeadIdentity,
2957    pointer: Option<V2PointerBody>,
2958    trust: TrustState,
2959    alias: Option<AliasBinding>,
2960}
2961
2962fn verify_v2_spki_signature(
2963    public_key: &str,
2964    message: &[u8],
2965    signature: &str,
2966) -> LinkResult<Vec<u8>> {
2967    let der = URL_SAFE_NO_PAD
2968        .decode(public_key)
2969        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2970    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2971        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2972    }
2973    let sig = URL_SAFE_NO_PAD
2974        .decode(signature)
2975        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2976    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2977        .verify(message, &sig)
2978        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2979    Ok(der)
2980}
2981
2982fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2983    if pointer.pointer.v != 2
2984        || pointer.pointer.brain != expected_brain
2985        || pointer.pointer.seq == 0
2986        || !is_sha256(&pointer.pointer.commit_hash)
2987        || !is_sha256(&pointer.pointer.feed_hash)
2988        || pointer
2989            .pointer
2990            .content_root
2991            .as_deref()
2992            .is_some_and(|hash| !is_sha256(hash))
2993        || !is_sha256(&pointer.pointer.backup_preparation)
2994    {
2995        return Err(invalid_feed("v2 pointer fields are invalid"));
2996    }
2997    let value = serde_json::to_value(&pointer.pointer)
2998        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2999    let message = crate::linkmd_v2::canonical_bytes(&value)
3000        .map_err(|error| invalid_feed(error.to_string()))?;
3001    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
3002    let fingerprint = format!("{:x}", Sha256::digest(&der));
3003    if fingerprint != pointer.hub_fingerprint {
3004        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
3005    }
3006    Ok(format!(
3007        "{}:{}",
3008        pointer.hub_fingerprint, pointer.hub_public_key
3009    ))
3010}
3011
3012fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
3013    FeedIdentity {
3014        fingerprint: identity.fingerprint.clone(),
3015        public_key_spki: identity.public_key_spki.clone(),
3016        previous: identity
3017            .previous
3018            .iter()
3019            .map(|previous| PreviousIdentity {
3020                fingerprint: previous.fingerprint.clone(),
3021                public_key_spki: previous.public_key_spki.clone(),
3022            })
3023            .collect(),
3024        rotations: identity.rotations.clone(),
3025    }
3026}
3027
3028fn verified_v2_commit_object(
3029    raw: &[u8],
3030    identity: &V2HeadIdentity,
3031) -> LinkResult<serde_json::Map<String, Value>> {
3032    let mut value: Value =
3033        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
3034    let canonical = crate::linkmd_v2::canonical_bytes(&value)
3035        .map_err(|error| invalid_feed(error.to_string()))?;
3036    if canonical != raw {
3037        return Err(invalid_feed("v2 commit is not canonical JSON"));
3038    }
3039    let object = value
3040        .as_object_mut()
3041        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
3042    let sig = object
3043        .remove("sig")
3044        .and_then(|value| value.as_str().map(str::to_string))
3045        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
3046    const FIELDS: [&str; 18] = [
3047        "actor_ref",
3048        "asset_root",
3049        "brain",
3050        "changes_sha256",
3051        "control_revision",
3052        "materializer",
3053        "op",
3054        "parent_asset_root",
3055        "parent_commit",
3056        "parent_root",
3057        "prev_entry_hash",
3058        "public_key",
3059        "seq",
3060        "signer_epoch",
3061        "state_root",
3062        "ts",
3063        "v",
3064        "v1_bridge",
3065    ];
3066    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
3067        return Err(invalid_feed("v2 commit has a non-normative field set"));
3068    }
3069    let seq = object
3070        .get("seq")
3071        .and_then(Value::as_u64)
3072        .filter(|seq| *seq > 0)
3073        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
3074    let signer_epoch = object
3075        .get("signer_epoch")
3076        .and_then(Value::as_u64)
3077        .filter(|epoch| *epoch > 0)
3078        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
3079    let hash_or_null = |field: &str| {
3080        object
3081            .get(field)
3082            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
3083    };
3084    if object.get("v").and_then(Value::as_u64) != Some(2)
3085        || object.get("op").and_then(Value::as_str) != Some("changeset")
3086        || !object
3087            .get("changes_sha256")
3088            .and_then(Value::as_str)
3089            .is_some_and(is_sha256)
3090        || !object
3091            .get("actor_ref")
3092            .and_then(Value::as_str)
3093            .is_some_and(is_sha256)
3094        || !object
3095            .get("control_revision")
3096            .and_then(Value::as_str)
3097            .is_some_and(is_sha256)
3098        || !object
3099            .get("state_root")
3100            .and_then(Value::as_str)
3101            .is_some_and(is_sha256)
3102        || !hash_or_null("parent_commit")
3103        || !hash_or_null("parent_root")
3104        || !hash_or_null("parent_asset_root")
3105        || !hash_or_null("asset_root")
3106        || !hash_or_null("prev_entry_hash")
3107        || !object
3108            .get("materializer")
3109            .and_then(Value::as_str)
3110            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
3111        || !object
3112            .get("ts")
3113            .and_then(Value::as_str)
3114            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3115    {
3116        return Err(invalid_feed("v2 commit fields are invalid"));
3117    }
3118    if (seq == 1
3119        && [
3120            "parent_commit",
3121            "parent_root",
3122            "parent_asset_root",
3123            "prev_entry_hash",
3124        ]
3125        .iter()
3126        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3127        || (seq > 1
3128            && ["parent_commit", "parent_root", "prev_entry_hash"]
3129                .iter()
3130                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3131    {
3132        return Err(invalid_feed("v2 commit parent shape is invalid"));
3133    }
3134    match object.get("v1_bridge") {
3135        Some(Value::Null) => {}
3136        Some(Value::Object(bridge))
3137            if seq == 1
3138                && bridge.len() == 3
3139                && bridge
3140                    .get("head_seq")
3141                    .and_then(Value::as_u64)
3142                    .is_some_and(|v| v > 0)
3143                && bridge
3144                    .get("feed_hash")
3145                    .and_then(Value::as_str)
3146                    .is_some_and(is_sha256)
3147                && bridge
3148                    .get("pack_sha256")
3149                    .and_then(Value::as_str)
3150                    .is_some_and(is_sha256) => {}
3151        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3152    }
3153    let public_key = object
3154        .get("public_key")
3155        .and_then(Value::as_str)
3156        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3157    let der = URL_SAFE_NO_PAD
3158        .decode(public_key)
3159        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3160    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3161    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3162        return Err(invalid_feed("v2 commit brain identity mismatch"));
3163    }
3164    // Verify the old-key-signed chain before trusting any prior public key.
3165    verify_identity_chain(&v2_identity(identity), None)?;
3166    // Old commits remain verifiable after brain-key rotation. `previous` is
3167    // newest-first, while signer epochs and rotation statements are oldest-first.
3168    let mut chain: Vec<(&str, &str)> = identity
3169        .previous
3170        .iter()
3171        .rev()
3172        .map(|previous| {
3173            (
3174                previous.fingerprint.as_str(),
3175                previous.public_key_spki.as_str(),
3176            )
3177        })
3178        .collect();
3179    chain.push((&identity.fingerprint, &identity.public_key_spki));
3180    let signer_index = chain.iter().position(|(fingerprint, spki)| {
3181        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3182    });
3183    let Some(signer_index) = signer_index else {
3184        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3185    };
3186    if signer_epoch != signer_index as u64 + 1 {
3187        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3188    }
3189    let lower_boundary = if signer_index == 0 {
3190        None
3191    } else {
3192        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3193            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3194        Some(prior.prior_head_seq)
3195    };
3196    let upper_boundary = if signer_index == identity.rotations.len() {
3197        None
3198    } else {
3199        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3200            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3201        Some(next.prior_head_seq)
3202    };
3203    if lower_boundary.is_some_and(|boundary| seq <= boundary)
3204        || upper_boundary.is_some_and(|boundary| seq > boundary)
3205    {
3206        return Err(invalid_feed(
3207            "v2 commit signer is outside its authenticated rotation epoch",
3208        ));
3209    }
3210    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3211        .map_err(|error| invalid_feed(error.to_string()))?;
3212    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3213    Ok(object.clone())
3214}
3215
3216#[derive(Debug, Deserialize)]
3217struct V2FeedWireEntry {
3218    seq: u64,
3219    commit_hash: String,
3220    feed_hash: String,
3221    bytes_base64: String,
3222}
3223
3224#[derive(Debug, Deserialize)]
3225struct V2FeedPage {
3226    v: u8,
3227    head_seq: u64,
3228    head_commit_hash: String,
3229    head_feed_hash: String,
3230    entries: Vec<V2FeedWireEntry>,
3231    next_after: u64,
3232    complete: bool,
3233}
3234
3235fn replay_v2_feed(
3236    cfg: &HubConfig,
3237    brain: &str,
3238    pointer: &V2PointerBody,
3239    identity: &V2HeadIdentity,
3240    start_after: u64,
3241    start_feed: Option<String>,
3242) -> LinkResult<()> {
3243    let mut after = start_after;
3244    let mut prior_feed = start_feed;
3245    let mut final_object = None;
3246    let mut replayed_entries = 0_u64;
3247    let mut replayed_bytes = 0_u64;
3248    while after < pointer.seq {
3249        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3250        let value = ensure_ok(
3251            request_capped(
3252                cfg,
3253                "GET",
3254                &path,
3255                None,
3256                Auth::Required,
3257                MAX_FEED_REPLAY_BYTES,
3258            )?,
3259            "v2 feed replay",
3260        )?;
3261        let page: V2FeedPage = serde_json::from_value(value)
3262            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3263        if page.v != 2
3264            || page.head_seq != pointer.seq
3265            || page.head_commit_hash != pointer.commit_hash
3266            || page.head_feed_hash != pointer.feed_hash
3267            || page.entries.is_empty()
3268            || page.entries.len() > FEED_PAGE_LIMIT
3269        {
3270            return Err(invalid_feed("v2 feed page differs from the signed head"));
3271        }
3272        for entry in page.entries {
3273            if entry.seq != after + 1
3274                || !is_sha256(&entry.commit_hash)
3275                || !is_sha256(&entry.feed_hash)
3276            {
3277                return Err(invalid_feed("v2 feed sequence is not contiguous"));
3278            }
3279            let raw = base64::engine::general_purpose::STANDARD
3280                .decode(&entry.bytes_base64)
3281                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3282            replayed_entries = replayed_entries
3283                .checked_add(1)
3284                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3285            replayed_bytes = replayed_bytes
3286                .checked_add(raw.len() as u64)
3287                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3288            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3289            {
3290                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3291            }
3292            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3293                .map_err(|error| invalid_feed(error.to_string()))?
3294                != entry.commit_hash
3295                || content_sha256(&raw) != entry.feed_hash
3296            {
3297                return Err(invalid_feed("v2 feed entry address mismatch"));
3298            }
3299            let object = verified_v2_commit_object(&raw, identity)?;
3300            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3301                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3302            {
3303                return Err(invalid_feed(
3304                    "v2 feed entry does not extend its predecessor",
3305                ));
3306            }
3307            after = entry.seq;
3308            prior_feed = Some(entry.feed_hash);
3309            final_object = Some((entry.commit_hash, object));
3310        }
3311        if page.next_after != after || (page.complete != (after == pointer.seq)) {
3312            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3313        }
3314    }
3315    let (final_hash, object) =
3316        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3317    if final_hash != pointer.commit_hash
3318        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3319        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3320        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3321        || object.get("control_revision").and_then(Value::as_str)
3322            != Some(pointer.control_revision.as_str())
3323        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3324    {
3325        return Err(invalid_feed(
3326            "v2 replay did not converge on the signed pointer",
3327        ));
3328    }
3329    Ok(())
3330}
3331
3332fn verify_v1_to_v2_bridge(
3333    cfg: &HubConfig,
3334    brain: &str,
3335    pointer: &V2PointerBody,
3336    identity: &V2HeadIdentity,
3337    checkpoint: &TrustState,
3338) -> LinkResult<()> {
3339    let value = ensure_ok(
3340        request_capped(
3341            cfg,
3342            "GET",
3343            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3344            None,
3345            Auth::Required,
3346            MAX_FEED_RESPONSE_BYTES,
3347        )?,
3348        "v2 genesis bridge",
3349    )?;
3350    let page: V2FeedPage = serde_json::from_value(value)
3351        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3352    if page.v != 2
3353        || page.head_seq != pointer.seq
3354        || page.head_commit_hash != pointer.commit_hash
3355        || page.head_feed_hash != pointer.feed_hash
3356        || page.entries.len() != 1
3357        || page.entries[0].seq != 1
3358        || !is_sha256(&page.entries[0].commit_hash)
3359        || !is_sha256(&page.entries[0].feed_hash)
3360    {
3361        return Err(invalid_feed(
3362            "v2 genesis bridge page differs from the signed head",
3363        ));
3364    }
3365    let first = &page.entries[0];
3366    let raw = STANDARD
3367        .decode(&first.bytes_base64)
3368        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3369    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3370        .map_err(|error| invalid_feed(error.to_string()))?
3371        != first.commit_hash
3372        || content_sha256(&raw) != first.feed_hash
3373    {
3374        return Err(invalid_feed("v2 genesis bridge address mismatch"));
3375    }
3376    let object = verified_v2_commit_object(&raw, identity)?;
3377    if checkpoint.head_seq == 0 {
3378        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3379            return Err(invalid_feed(
3380                "empty v1 checkpoint did not transition through an empty v2 genesis",
3381            ));
3382        }
3383        return Ok(());
3384    }
3385    let bridge = object
3386        .get("v1_bridge")
3387        .and_then(Value::as_object)
3388        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3389    let checkpoint_feed = checkpoint
3390        .feed_hash
3391        .as_deref()
3392        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3393    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3394        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3395    {
3396        return Err(invalid_feed(
3397            "v2 genesis bridge differs from the pinned v1 checkpoint",
3398        ));
3399    }
3400    let legacy_raw = ensure_raw_ok(
3401        request_raw(
3402            cfg,
3403            "GET",
3404            &format!(
3405                "/api/hub/brains/{brain}/feed?after={}&limit=1",
3406                checkpoint.head_seq - 1
3407            ),
3408            None,
3409            Auth::Required,
3410            MAX_FEED_RESPONSE_BYTES,
3411        )?,
3412        "v1 bridge boundary",
3413    )?;
3414    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3415        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3416    let legacy_identity = legacy
3417        .identity
3418        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3419    let item = legacy
3420        .entries
3421        .first()
3422        .filter(|_| legacy.entries.len() == 1)
3423        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3424    if legacy.scope_limited
3425        || legacy.head_seq != checkpoint.head_seq
3426        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3427        || item.entry.seq != checkpoint.head_seq
3428        || item.hash != checkpoint_feed
3429        || legacy_identity != v2_identity(identity)
3430        || bridge.get("pack_sha256").and_then(Value::as_str)
3431            != Some(item.entry.pack_sha256.as_str())
3432    {
3433        return Err(invalid_feed(
3434            "v1 bridge boundary differs from its signed legacy head",
3435        ));
3436    }
3437    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3438    if anchor != checkpoint.anchor {
3439        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3440    }
3441    verify_feed_item(item, &legacy_identity)?;
3442    verify_rotation_feed_boundaries(
3443        &legacy_identity,
3444        Some(checkpoint),
3445        std::slice::from_ref(item),
3446        checkpoint.head_seq,
3447    )?;
3448    Ok(())
3449}
3450
3451fn verify_v2_commit(
3452    cfg: &HubConfig,
3453    brain: &str,
3454    pointer: &V2PointerBody,
3455    identity: &V2HeadIdentity,
3456    pinned: Option<&TrustState>,
3457) -> LinkResult<()> {
3458    let path = format!(
3459        "/api/hub/brains/{brain}/v2/commit?commit={}",
3460        pointer.commit_hash
3461    );
3462    let raw = ensure_raw_ok(
3463        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3464        "v2 commit",
3465    )?;
3466    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3467        .map_err(|error| invalid_feed(error.to_string()))?
3468        != pointer.commit_hash
3469        || content_sha256(&raw) != pointer.feed_hash
3470    {
3471        return Err(invalid_feed("v2 commit address differs from the pointer"));
3472    }
3473    let object = verified_v2_commit_object(&raw, identity)?;
3474    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3475        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3476        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3477        || object.get("control_revision").and_then(Value::as_str)
3478            != Some(pointer.control_revision.as_str())
3479        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3480    {
3481        return Err(invalid_feed("v2 commit fields differ from the pointer"));
3482    }
3483    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3484        if pointer.seq == checkpoint.head_seq + 1
3485            && object.get("prev_entry_hash").and_then(Value::as_str)
3486                != checkpoint.feed_hash.as_deref()
3487        {
3488            return Err(invalid_feed(
3489                "v2 commit does not extend the pinned feed hash",
3490            ));
3491        }
3492        if pointer.seq > checkpoint.head_seq + 1 {
3493            return replay_v2_feed(
3494                cfg,
3495                brain,
3496                pointer,
3497                identity,
3498                checkpoint.head_seq,
3499                checkpoint.feed_hash.clone(),
3500            );
3501        }
3502    } else {
3503        if let Some(checkpoint) = pinned {
3504            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3505        }
3506        if pointer.seq > 1 {
3507            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3508        }
3509    }
3510    Ok(())
3511}
3512
3513fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3514    require_hardened_filesystem("verified link.md v2 state")?;
3515    require_safe_ref(brain)?;
3516    // Open and pin the private trust directory before a credential can leave
3517    // the process. A hostile checkout must not turn an unsafe state root into
3518    // a network oracle or receive a bearer before checkpoint safety is known.
3519    let trust_directory = open_trust_dir(cfg)?;
3520    let path = format!("/api/hub/brains/{brain}/v2/head");
3521    // A commit is durable before the hub's validation/index recovery view is
3522    // necessarily at that new source head. That is one typed transient state,
3523    // not a rejected commit. Waiting here keeps the accepted commit and the
3524    // local verification baseline in one operation; otherwise a large brain
3525    // can be forced through an expensive historical reconciliation merely
3526    // because its projection lagged for a few seconds.
3527    let started = std::time::Instant::now();
3528    let mut wait = std::time::Duration::from_millis(250);
3529    let response = loop {
3530        let response = request(cfg, "GET", &path, None, Auth::Required)?;
3531        if !v2_validation_catching_up(&response) {
3532            break response;
3533        }
3534        if started.elapsed() >= std::time::Duration::from_secs(15 * 60) {
3535            return Err(LinkError::Http {
3536                what: "v2 head",
3537                status: response.status,
3538                message: "validation/index recovery did not reach the durable source head within 15 minutes"
3539                    .to_string(),
3540                code: Some("validation_index_catching_up".to_string()),
3541                details: response.body,
3542            });
3543        }
3544        std::thread::sleep(wait);
3545        wait = wait
3546            .saturating_mul(2)
3547            .min(std::time::Duration::from_secs(5));
3548    };
3549    if response.status == 404 {
3550        if has_accepted_v2_ref(cfg, brain)? {
3551            return Err(LinkError::BrainUnavailable);
3552        }
3553        return Ok(None);
3554    }
3555    let body = ensure_ok(response, "v2 head")?;
3556    let head: V2HeadResponse = serde_json::from_value(body)
3557        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3558    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3559        return Err(invalid_feed("v2 head has no canonical brain id"));
3560    }
3561    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3562        return Err(invalid_feed("v2 head resolved a different brain id"));
3563    }
3564    if head.profile == "v1" {
3565        return Ok(None);
3566    }
3567    if head.profile != "v2" && head.profile != "v2-empty" {
3568        return Err(invalid_feed("v2 head advertised an unknown profile"));
3569    }
3570    let view = head
3571        .view
3572        .as_ref()
3573        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3574    if !matches!(view.kind.as_str(), "full" | "scoped")
3575        || !is_sha256(&view.control_revision)
3576        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3577    {
3578        return Err(invalid_feed("v2 head has an invalid permission view"));
3579    }
3580    let view_kind = view.kind.clone();
3581    // `id` is the stable materialized namespace. Older v2 hubs did not expose
3582    // it, so retain the fail-closed control-revision fallback for compatibility.
3583    let view_revision = view
3584        .id
3585        .clone()
3586        .unwrap_or_else(|| view.control_revision.clone());
3587    let control_revision = view.control_revision.clone();
3588    let identity = head
3589        .identity
3590        .as_ref()
3591        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3592    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3593    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3594    let feed_identity = v2_identity(identity);
3595    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3596    let (seq, feed_hash, hub_signer) = match &head.pointer {
3597        None => {
3598            if head.profile != "v2-empty" {
3599                return Err(invalid_feed("initialized v2 head has no pointer"));
3600            }
3601            (
3602                0,
3603                None,
3604                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3605            )
3606        }
3607        Some(signed) => {
3608            let signer = verify_v2_pointer(signed, &head.brain_id)?;
3609            if pinned
3610                .as_ref()
3611                .and_then(|state| state.hub_signer.as_ref())
3612                .is_some_and(|known| known != &signer)
3613            {
3614                return Err(invalid_feed(
3615                    "v2 hub pointer signer changed without a trust transition",
3616                ));
3617            }
3618            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3619                if signed.pointer.seq < checkpoint.head_seq
3620                    || (signed.pointer.seq == checkpoint.head_seq
3621                        && checkpoint.feed_hash.as_deref()
3622                            != Some(signed.pointer.feed_hash.as_str()))
3623                {
3624                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3625                }
3626            }
3627            verify_v2_commit(
3628                cfg,
3629                &head.brain_id,
3630                &signed.pointer,
3631                identity,
3632                pinned.as_ref(),
3633            )?;
3634            (
3635                signed.pointer.seq,
3636                Some(signed.pointer.feed_hash.clone()),
3637                Some(signer),
3638            )
3639        }
3640    };
3641    let trust = TrustState {
3642        v: 2,
3643        origin: normalized_origin(&cfg.hub)?,
3644        requested: head.brain_id.clone(),
3645        brain: head.brain_id.clone(),
3646        home: None,
3647        anchor,
3648        current: format!("ed25519:{}", identity.fingerprint),
3649        head_seq: seq,
3650        feed_hash,
3651        rotations: identity.rotations.clone(),
3652        hub_signer,
3653        protocol_profile: Some("link-v2".to_string()),
3654    };
3655    Ok(Some(V2VerifiedHead {
3656        requested: brain.to_string(),
3657        brain_id: head.brain_id,
3658        view_kind,
3659        view_revision,
3660        control_revision,
3661        identity: identity.clone(),
3662        pointer: head.pointer.map(|signed| signed.pointer),
3663        trust,
3664        alias: alias_binding,
3665    }))
3666}
3667
3668fn v2_validation_catching_up(response: &HubResponse) -> bool {
3669    response.status == 422
3670        && response.body.as_ref().is_some_and(|body| {
3671            body.get("code").and_then(Value::as_str) == Some("validation_index_catching_up")
3672                || body
3673                    .get("details")
3674                    .and_then(|details| details.get("code"))
3675                    .and_then(Value::as_str)
3676                    == Some("validation_index_catching_up")
3677        })
3678}
3679
3680fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3681    let directory = open_trust_dir(cfg)?;
3682    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3683    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3684    if let Some(current) = current {
3685        let common_invalid = head.trust.anchor != current.anchor
3686            || !head.trust.rotations.starts_with(&current.rotations);
3687        let profile_invalid = if accepted_as_v2(&current) {
3688            head.trust.head_seq < current.head_seq
3689                || (head.trust.head_seq == current.head_seq
3690                    && head.trust.feed_hash != current.feed_hash)
3691                || current
3692                    .hub_signer
3693                    .as_ref()
3694                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3695        } else {
3696            head.trust.protocol_profile.as_deref() != Some("link-v2")
3697                || head.trust.hub_signer.is_none()
3698        };
3699        if common_invalid || profile_invalid {
3700            return Err(invalid_feed(
3701                "v2 head cannot advance the currently accepted trust checkpoint",
3702            ));
3703        }
3704    }
3705    save_canonical_pin_and_alias(
3706        cfg,
3707        &directory,
3708        &head.requested,
3709        &head.brain_id,
3710        head.trust.clone(),
3711        alias.as_ref().or(head.alias.as_ref()),
3712    )
3713}
3714
3715#[derive(Debug, Clone, Deserialize, Serialize)]
3716struct V2BaselineFile {
3717    sha256: String,
3718    bytes: u64,
3719    #[serde(skip)]
3720    proof: Option<Vec<V2ProofStep>>,
3721}
3722
3723/// Private checkout acceleration, analogous to Git's stat cache. A cached
3724/// hash is reusable only when the same held regular file still has the exact
3725/// Unix identity/timestamps/length and those timestamps were already outside
3726/// the racy-clean window when observed. `withheld_targets` is policy-specific;
3727/// the complete cache is ignored when `.sevralocal`'s digest changes.
3728#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
3729struct V2ScanCacheFile {
3730    fingerprint: String,
3731    sha256: String,
3732    bytes: u64,
3733    #[serde(default)]
3734    withheld_targets: Vec<String>,
3735}
3736
3737#[derive(Debug, Clone, Deserialize, Serialize)]
3738struct V2SyncBaseline {
3739    v: u8,
3740    origin: String,
3741    brain: String,
3742    #[serde(default)]
3743    checkout_id: Option<String>,
3744    #[serde(default)]
3745    head_seq: Option<u64>,
3746    commit_hash: Option<String>,
3747    content_root: Option<String>,
3748    #[serde(default)]
3749    asset_root: Option<String>,
3750    #[serde(default)]
3751    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3752    #[serde(default)]
3753    view_kind: Option<String>,
3754    #[serde(default)]
3755    view_revision: Option<String>,
3756    /// The authority revision observed with this materialized view. Older
3757    /// baselines omit it and deliberately take one full verified refresh
3758    /// before they become eligible for unchanged-head reuse.
3759    #[serde(default)]
3760    control_revision: Option<String>,
3761    #[serde(default)]
3762    projection_sha256: Option<String>,
3763    files: std::collections::BTreeMap<String, V2BaselineFile>,
3764    #[serde(default)]
3765    scan_cache: std::collections::BTreeMap<String, V2ScanCacheFile>,
3766    #[serde(default)]
3767    local_policy_digest: Option<String>,
3768    #[serde(default)]
3769    local_eligibility: std::collections::BTreeMap<String, bool>,
3770    #[serde(default)]
3771    remote_copy_remains: std::collections::BTreeMap<String, String>,
3772}
3773
3774#[derive(Clone)]
3775struct V2LocalView {
3776    riding: std::collections::BTreeMap<String, (String, u64)>,
3777    scan_cache: std::collections::BTreeMap<String, V2ScanCacheFile>,
3778    eligibility: std::collections::BTreeMap<String, bool>,
3779    policy: crate::linkmd_sync_policy::SyncPolicy,
3780    withheld_links: Vec<V2WithheldLink>,
3781}
3782
3783#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3784struct V2WithheldLink {
3785    source: String,
3786    target: String,
3787}
3788
3789#[derive(Debug, Clone, Deserialize, Serialize)]
3790struct V2ProofStep {
3791    directory_root: String,
3792    component: String,
3793    proof: crate::linkmd_v2::HamtProof,
3794}
3795
3796#[derive(Debug, Deserialize)]
3797struct V2ManifestFile {
3798    path: String,
3799    sha256: String,
3800    bytes: u64,
3801    proof: Vec<V2ProofStep>,
3802}
3803
3804#[derive(Debug, Deserialize)]
3805struct V2ManifestPage {
3806    v: u8,
3807    commit: String,
3808    content_root: Option<String>,
3809    files: Vec<V2ManifestFile>,
3810    next_cursor: Option<String>,
3811}
3812
3813#[derive(Debug, Clone, Deserialize, Serialize)]
3814struct V2BaselineAsset {
3815    blob_sha256: String,
3816    bytes: u64,
3817    media_type: String,
3818    wrappers: Vec<String>,
3819    required: bool,
3820    disposition: String,
3821    leaf_hash: String,
3822}
3823
3824#[derive(Debug, Deserialize)]
3825struct V2AssetManifestItem {
3826    path: String,
3827    blob_sha256: String,
3828    bytes: u64,
3829    media_type: String,
3830    wrappers: Vec<String>,
3831    required: bool,
3832    disposition: String,
3833    leaf_hash: String,
3834    proof: crate::linkmd_v2::HamtProof,
3835}
3836
3837#[derive(Debug, Deserialize)]
3838struct V2AssetManifestPage {
3839    v: u8,
3840    commit: String,
3841    asset_root: Option<String>,
3842    assets: Vec<V2AssetManifestItem>,
3843    next_cursor: Option<String>,
3844}
3845
3846#[derive(Debug, Deserialize)]
3847struct V2SigningCandidate {
3848    seq: u64,
3849    content_root: Option<String>,
3850    asset_root: Option<String>,
3851    signing_bytes_base64: String,
3852    changes_base64: String,
3853    actor_claim_base64: String,
3854}
3855
3856#[derive(Debug, Deserialize)]
3857struct V2SigningCandidatePage {
3858    v: u8,
3859    challenge_id: String,
3860    mutation_id: String,
3861    request_hash: String,
3862    parent: V2SigningParent,
3863    candidate: V2SigningCandidate,
3864    files: Vec<V2ManifestFile>,
3865    #[serde(default)]
3866    assets: Vec<V2AssetManifestItem>,
3867    next_cursor: Option<String>,
3868    expires_at: String,
3869}
3870
3871#[derive(Debug, Deserialize)]
3872struct V2SigningParent {
3873    seq: u64,
3874    commit_hash: Option<String>,
3875}
3876
3877fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3878    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3879        .map_err(|error| invalid_feed(error.to_string()))?;
3880    let components = normalized.split('/').collect::<Vec<_>>();
3881    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3882        return Err(invalid_feed("v2 file proof has the wrong shape"));
3883    }
3884    let mut directory_root = root.to_string();
3885    for (index, step) in file.proof.iter().enumerate() {
3886        if step.directory_root != directory_root || step.component != components[index] {
3887            return Err(invalid_feed(
3888                "v2 file proof path chain differs from its manifest",
3889            ));
3890        }
3891        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3892            .map_err(|error| invalid_feed(error.to_string()))?
3893        {
3894            return Err(invalid_feed("v2 file proof failed verification"));
3895        }
3896        let entry = match &step.proof {
3897            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3898            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3899                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3900            }
3901        };
3902        if index + 1 == components.len() {
3903            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3904                || entry.child_hash != file.sha256
3905                || entry.bytes != Some(file.bytes)
3906            {
3907                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3908            }
3909        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3910            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3911        } else {
3912            directory_root = entry.child_hash.clone();
3913        }
3914    }
3915    Ok(())
3916}
3917
3918fn v2_manifest(
3919    cfg: &HubConfig,
3920    brain: &str,
3921    pointer: Option<&V2PointerBody>,
3922) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3923    let Some(pointer) = pointer else {
3924        return Ok(std::collections::BTreeMap::new());
3925    };
3926    let Some(root) = pointer.content_root.as_deref() else {
3927        return Ok(std::collections::BTreeMap::new());
3928    };
3929    let mut files = std::collections::BTreeMap::new();
3930    let mut after = String::new();
3931    loop {
3932        let encoded_after: String =
3933            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3934        let path = format!(
3935            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3936            pointer.commit_hash
3937        );
3938        let value = ensure_ok(
3939            request_capped(
3940                cfg,
3941                "GET",
3942                &path,
3943                None,
3944                Auth::Required,
3945                MAX_FEED_RESPONSE_BYTES,
3946            )?,
3947            "v2 file manifest",
3948        )?;
3949        let page: V2ManifestPage = serde_json::from_value(value)
3950            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3951        if page.v != 2
3952            || page.commit != pointer.commit_hash
3953            || page.content_root.as_deref() != Some(root)
3954            || page.files.len() > 500
3955        {
3956            return Err(invalid_feed(
3957                "v2 file manifest is not bound to the verified head",
3958            ));
3959        }
3960        for file in page.files {
3961            verify_v2_file_proof(root, &file)?;
3962            if files
3963                .insert(
3964                    file.path.clone(),
3965                    V2BaselineFile {
3966                        sha256: file.sha256,
3967                        bytes: file.bytes,
3968                        proof: Some(file.proof),
3969                    },
3970                )
3971                .is_some()
3972            {
3973                return Err(invalid_feed("v2 file manifest repeats a path"));
3974            }
3975            if files.len() > MAX_PUSH_FILES {
3976                return Err(invalid_feed(
3977                    "v2 file manifest exceeds the file-count bound",
3978                ));
3979            }
3980        }
3981        match page.next_cursor {
3982            None => break,
3983            Some(next) if next > after => after = next,
3984            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3985        }
3986    }
3987    Ok(files)
3988}
3989
3990/// Fetch one exact path proof instead of enumerating the whole permission
3991/// view. This keeps `resolve @brain/path` proportional to path depth even for
3992/// a very large brain. The returned leaf is accepted only after its complete
3993/// proof chain reaches the content root signed by `pointer`.
3994fn v2_manifest_file(
3995    cfg: &HubConfig,
3996    brain: &str,
3997    pointer: &V2PointerBody,
3998    path: &str,
3999) -> LinkResult<Option<V2BaselineFile>> {
4000    let Some(root) = pointer.content_root.as_deref() else {
4001        return Ok(None);
4002    };
4003    crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
4004        path: error.to_string(),
4005    })?;
4006    let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
4007    let response = request_capped(
4008        cfg,
4009        "GET",
4010        &format!(
4011            "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
4012            pointer.commit_hash
4013        ),
4014        None,
4015        Auth::Required,
4016        MAX_FEED_RESPONSE_BYTES,
4017    )?;
4018    // Exact v2 file reads deliberately collapse absent and unreadable paths
4019    // into the same 404. Preserve that non-oracle as a normal `None`; callers
4020    // such as `resolve` then produce the stable, local `NOT_FOUND` contract.
4021    if response.status == 404 {
4022        return Ok(None);
4023    }
4024    let value = ensure_ok(response, "v2 exact file proof")?;
4025    let mut page: V2ManifestPage = serde_json::from_value(value)
4026        .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
4027    if page.v != 2
4028        || page.commit != pointer.commit_hash
4029        || page.content_root.as_deref() != Some(root)
4030        || page.next_cursor.is_some()
4031        || page.files.len() != 1
4032        || page.files[0].path != path
4033    {
4034        return Err(invalid_feed(
4035            "v2 exact file proof is not bound to the requested signed path",
4036        ));
4037    }
4038    let file = page.files.pop().expect("exactly one file was checked");
4039    verify_v2_file_proof(root, &file)?;
4040    Ok(Some(V2BaselineFile {
4041        sha256: file.sha256,
4042        bytes: file.bytes,
4043        proof: Some(file.proof),
4044    }))
4045}
4046
4047/// Resolve an id through the hub's synchronously maintained, content-light
4048/// validation certificate, then require the same exact signed path proof as a
4049/// path lookup. The certificate is only an efficient locator: the downloaded
4050/// bytes are parsed again and their id is checked by the caller.
4051fn v2_manifest_file_by_id(
4052    cfg: &HubConfig,
4053    brain: &str,
4054    pointer: &V2PointerBody,
4055    id: &str,
4056) -> LinkResult<(String, V2BaselineFile)> {
4057    let root = pointer
4058        .content_root
4059        .as_deref()
4060        .ok_or_else(|| LinkError::Http {
4061            what: "resolve",
4062            status: 404,
4063            message: "record not found".to_string(),
4064            code: Some("NOT_FOUND".to_string()),
4065            details: None,
4066        })?;
4067    if !crate::ulid::is_ulid(id) {
4068        return Err(LinkError::BadAddress {
4069            given: id.to_string(),
4070            reason: BAD_TARGET_REASON.to_string(),
4071        });
4072    }
4073    let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
4074    let value = ensure_ok(
4075        request_capped(
4076            cfg,
4077            "GET",
4078            &format!(
4079                "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
4080                pointer.commit_hash
4081            ),
4082            None,
4083            Auth::Required,
4084            MAX_FEED_RESPONSE_BYTES,
4085        )?,
4086        "v2 exact id proof",
4087    )?;
4088    let mut page: V2ManifestPage = serde_json::from_value(value)
4089        .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
4090    if page.v != 2
4091        || page.commit != pointer.commit_hash
4092        || page.content_root.as_deref() != Some(root)
4093        || page.next_cursor.is_some()
4094        || page.files.len() != 1
4095    {
4096        return Err(invalid_feed(
4097            "v2 exact id proof is not bound to one signed path",
4098        ));
4099    }
4100    let file = page.files.pop().expect("exactly one file was checked");
4101    if !safe_store_rel_path(&file.path)
4102        || !file.path.ends_with(".md")
4103        || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4104    {
4105        return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4106    }
4107    verify_v2_file_proof(root, &file)?;
4108    Ok((
4109        file.path,
4110        V2BaselineFile {
4111            sha256: file.sha256,
4112            bytes: file.bytes,
4113            proof: Some(file.proof),
4114        },
4115    ))
4116}
4117
4118fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4119    crate::linkmd_v2::normalize_path(&item.path)
4120        .map_err(|error| invalid_feed(error.to_string()))?;
4121    if !is_sha256(&item.blob_sha256)
4122        || !is_sha256(&item.leaf_hash)
4123        || item.bytes > MAX_ASSET_BYTES
4124        || item.wrappers.is_empty()
4125        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4126        || item
4127            .wrappers
4128            .iter()
4129            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4130    {
4131        return Err(invalid_feed("v2 asset manifest item is invalid"));
4132    }
4133    let leaf = json!({
4134        "blob_sha256": item.blob_sha256,
4135        "bytes": item.bytes,
4136        "disposition": item.disposition,
4137        "media_type": item.media_type,
4138        "path": item.path,
4139        "required": item.required,
4140        "v": 2,
4141        "wrappers": item.wrappers,
4142    });
4143    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4144        .map_err(|error| invalid_feed(error.to_string()))?
4145        != item.leaf_hash
4146        || !crate::linkmd_v2::verify_proof_with_domain(
4147            root,
4148            &item.path,
4149            &item.proof,
4150            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4151        )
4152        .map_err(|error| invalid_feed(error.to_string()))?
4153    {
4154        return Err(invalid_feed("v2 asset inclusion proof failed"));
4155    }
4156    match &item.proof {
4157        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4158            if entry.name == item.path
4159                && entry.kind == crate::linkmd_v2::EntryKind::Blob
4160                && entry.child_hash == item.leaf_hash
4161                && entry.bytes == Some(item.bytes) =>
4162        {
4163            Ok(())
4164        }
4165        _ => Err(invalid_feed(
4166            "v2 asset proof leaf differs from its manifest",
4167        )),
4168    }
4169}
4170
4171fn v2_asset_manifest(
4172    cfg: &HubConfig,
4173    brain: &str,
4174    pointer: Option<&V2PointerBody>,
4175) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4176    let Some(pointer) = pointer else {
4177        return Ok(std::collections::BTreeMap::new());
4178    };
4179    let Some(root) = pointer.asset_root.as_deref() else {
4180        return Ok(std::collections::BTreeMap::new());
4181    };
4182    let mut assets = std::collections::BTreeMap::new();
4183    let mut after = String::new();
4184    loop {
4185        let encoded_after: String =
4186            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4187        let path = format!(
4188            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4189            pointer.commit_hash
4190        );
4191        let value = ensure_ok(
4192            request_capped(
4193                cfg,
4194                "GET",
4195                &path,
4196                None,
4197                Auth::Required,
4198                MAX_FEED_RESPONSE_BYTES,
4199            )?,
4200            "v2 asset manifest",
4201        )?;
4202        let page: V2AssetManifestPage = serde_json::from_value(value)
4203            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4204        if page.v != 2
4205            || page.commit != pointer.commit_hash
4206            || page.asset_root.as_deref() != Some(root)
4207            || page.assets.len() > 500
4208        {
4209            return Err(invalid_feed(
4210                "v2 asset manifest is not bound to the verified head",
4211            ));
4212        }
4213        for item in page.assets {
4214            verify_v2_asset_proof(root, &item)?;
4215            let path = item.path.clone();
4216            if assets
4217                .insert(
4218                    path,
4219                    V2BaselineAsset {
4220                        blob_sha256: item.blob_sha256,
4221                        bytes: item.bytes,
4222                        media_type: item.media_type,
4223                        wrappers: item.wrappers,
4224                        required: item.required,
4225                        disposition: item.disposition,
4226                        leaf_hash: item.leaf_hash,
4227                    },
4228                )
4229                .is_some()
4230            {
4231                return Err(invalid_feed("v2 asset manifest repeats a path"));
4232            }
4233            if assets.len() > MAX_PUSH_FILES {
4234                return Err(invalid_feed(
4235                    "v2 asset manifest exceeds the item-count bound",
4236                ));
4237            }
4238        }
4239        match page.next_cursor {
4240            None => break,
4241            Some(next) if next > after => after = next,
4242            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4243        }
4244    }
4245    Ok(assets)
4246}
4247
4248fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4249    crate::AssetRecord {
4250        path: path.to_string(),
4251        sha256: asset.blob_sha256.clone(),
4252        bytes: asset.bytes,
4253        media_type: asset.media_type.clone(),
4254        wrappers: asset.wrappers.clone(),
4255        required: asset.required,
4256    }
4257}
4258
4259fn v2_asset_resumes_hosting(
4260    remote: Option<&V2BaselineAsset>,
4261    path: &str,
4262    record: &crate::AssetRecord,
4263    disposition: &str,
4264) -> bool {
4265    remote.is_some_and(|asset| {
4266        asset.disposition == "withheld"
4267            && disposition == "hosted"
4268            && v2_asset_record(asset, path) == *record
4269    })
4270}
4271
4272fn v2_asset_inherits_withheld_absence(
4273    base: Option<&V2BaselineAsset>,
4274    base_record: Option<&crate::AssetRecord>,
4275    local_record: Option<&crate::AssetRecord>,
4276    raw_present: bool,
4277) -> bool {
4278    !raw_present
4279        && base.is_some_and(|asset| asset.disposition == "withheld")
4280        && local_record == base_record
4281}
4282
4283fn v2_asset_record_manifest_bytes(
4284    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4285) -> LinkResult<Vec<u8>> {
4286    let mut bytes = Vec::new();
4287    for (path, asset) in assets {
4288        if asset.path != *path {
4289            return Err(invalid_feed(
4290                "local asset manifest key differs from its record path",
4291            ));
4292        }
4293        serde_json::to_writer(&mut bytes, asset)
4294            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4295        bytes.push(b'\n');
4296    }
4297    Ok(bytes)
4298}
4299
4300fn v2_local_asset_records(
4301    store: &Store,
4302) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4303    let assets = crate::assets::read_manifest(store)
4304        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4305    if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4306        return Err(LinkError::InvalidPack {
4307            message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4308        });
4309    }
4310    Ok(assets
4311        .into_iter()
4312        .map(|asset| (asset.path.clone(), asset))
4313        .collect())
4314}
4315
4316fn v2_asset_records_match_remote(
4317    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4318    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4319) -> bool {
4320    local.len() == remote.len()
4321        && remote
4322            .iter()
4323            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4324}
4325
4326#[derive(Debug, Clone, PartialEq, Eq)]
4327struct V2PulledMerge<T> {
4328    records: std::collections::BTreeMap<String, T>,
4329    accept_remote: std::collections::BTreeSet<String>,
4330    conflicts: Vec<String>,
4331}
4332
4333/// Compute the local checkout that a pull may install without discarding a
4334/// local-only edit. Remote-only changes are accepted, identical concurrent
4335/// changes converge, and divergent concurrent changes are reported. The
4336/// caller can exempt kept-home coordinates from both remote installation and
4337/// conflict disclosure.
4338fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4339    base: &std::collections::BTreeMap<String, Base>,
4340    remote: &std::collections::BTreeMap<String, Remote>,
4341    local: &std::collections::BTreeMap<String, Record>,
4342    base_record: BaseRecord,
4343    remote_record: RemoteRecord,
4344    keep_local: KeepLocal,
4345) -> V2PulledMerge<Record>
4346where
4347    Record: Clone + Eq,
4348    BaseRecord: Fn(&Base, &str) -> Record,
4349    RemoteRecord: Fn(&Remote, &str) -> Record,
4350    KeepLocal: Fn(&str) -> bool,
4351{
4352    let paths = base
4353        .keys()
4354        .chain(remote.keys())
4355        .chain(local.keys())
4356        .cloned()
4357        .collect::<std::collections::BTreeSet<_>>();
4358    let mut records = local.clone();
4359    let mut accept_remote = std::collections::BTreeSet::new();
4360    let mut conflicts = Vec::new();
4361    for path in paths {
4362        if keep_local(&path) {
4363            continue;
4364        }
4365        let base_value = base.get(&path).map(|value| base_record(value, &path));
4366        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4367        let local_value = local.get(&path).cloned();
4368        if local_value != base_value && remote_value != base_value && local_value != remote_value {
4369            conflicts.push(path);
4370            continue;
4371        }
4372        if local_value == base_value || local_value == remote_value {
4373            accept_remote.insert(path.clone());
4374            match remote_value {
4375                Some(value) => {
4376                    records.insert(path, value);
4377                }
4378                None => {
4379                    records.remove(&path);
4380                }
4381            }
4382        }
4383    }
4384    V2PulledMerge {
4385        records,
4386        accept_remote,
4387        conflicts,
4388    }
4389}
4390
4391fn sign_verified_v2_candidate(
4392    cfg: &HubConfig,
4393    head: &V2VerifiedHead,
4394    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4395    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4396    mutation_id: &str,
4397    request_body: &Value,
4398    challenge_value: &Value,
4399) -> LinkResult<(String, String, String)> {
4400    if head.view_kind != "full" {
4401        return Err(invalid_feed(
4402            "a scoped self-custody writer must use the proposal workflow",
4403        ));
4404    }
4405    if head.identity.custody != "self" {
4406        return Err(invalid_feed(
4407            "a hub-custodied brain unexpectedly requested an external signature",
4408        ));
4409    }
4410    let key = cfg
4411        .brain_key
4412        .as_ref()
4413        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4414    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4415        || key.public_key_spki != head.identity.public_key_spki
4416    {
4417        return Err(bad_agent_key(
4418            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4419        ));
4420    }
4421    let challenge_id = challenge_value
4422        .get("id")
4423        .and_then(Value::as_str)
4424        .filter(|id| crate::ulid::is_ulid(id))
4425        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4426    let expected_endpoint = format!(
4427        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4428        head.brain_id
4429    );
4430    if challenge_value
4431        .get("candidate_endpoint")
4432        .and_then(Value::as_str)
4433        != Some(expected_endpoint.as_str())
4434    {
4435        return Err(invalid_feed(
4436            "self-custody challenge candidate endpoint is not origin-bound",
4437        ));
4438    }
4439
4440    let mut files = std::collections::BTreeMap::new();
4441    let mut after = String::new();
4442    type CandidateCoordinate = (
4443        String,
4444        String,
4445        String,
4446        String,
4447        Option<String>,
4448        Option<String>,
4449        u64,
4450        Option<String>,
4451    );
4452    let mut pinned: Option<CandidateCoordinate> = None;
4453    loop {
4454        let encoded_after: String =
4455            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4456        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4457        let value = ensure_ok(
4458            request_capped(
4459                cfg,
4460                "GET",
4461                &path,
4462                None,
4463                Auth::Required,
4464                MAX_FEED_RESPONSE_BYTES,
4465            )?,
4466            "v2 self-custody candidate",
4467        )?;
4468        let page: V2SigningCandidatePage = serde_json::from_value(value)
4469            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4470        if page.v != 2
4471            || page.challenge_id != challenge_id
4472            || page.mutation_id != mutation_id
4473            || page.candidate.seq != page.parent.seq + 1
4474            || page.files.len() > 500
4475            || page.expires_at.is_empty()
4476        {
4477            return Err(invalid_feed(
4478                "self-custody candidate is not bound to this mutation",
4479            ));
4480        }
4481        let coordinate = (
4482            page.request_hash.clone(),
4483            page.candidate.signing_bytes_base64.clone(),
4484            page.candidate.changes_base64.clone(),
4485            page.candidate.actor_claim_base64.clone(),
4486            page.candidate.content_root.clone(),
4487            page.candidate.asset_root.clone(),
4488            page.parent.seq,
4489            page.parent.commit_hash.clone(),
4490        );
4491        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4492            return Err(invalid_feed(
4493                "self-custody candidate changed between manifest pages",
4494            ));
4495        }
4496        pinned = Some(coordinate);
4497        let root = page
4498            .candidate
4499            .content_root
4500            .as_deref()
4501            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4502        for file in page.files {
4503            verify_v2_file_proof(root, &file)?;
4504            if files
4505                .insert(
4506                    file.path.clone(),
4507                    V2BaselineFile {
4508                        sha256: file.sha256,
4509                        bytes: file.bytes,
4510                        proof: Some(file.proof),
4511                    },
4512                )
4513                .is_some()
4514            {
4515                return Err(invalid_feed(
4516                    "self-custody candidate repeats a manifest path",
4517                ));
4518            }
4519            if files.len() > MAX_PUSH_FILES {
4520                return Err(invalid_feed(
4521                    "self-custody candidate exceeds the file-count bound",
4522                ));
4523            }
4524        }
4525        match page.next_cursor {
4526            None => break,
4527            Some(next) if next > after => after = next,
4528            Some(_) => {
4529                return Err(invalid_feed(
4530                    "self-custody candidate cursor did not advance",
4531                ))
4532            }
4533        }
4534    }
4535    if files.len() != expected.len()
4536        || files.iter().any(|(path, file)| {
4537            expected.get(path).is_none_or(|expected| {
4538                expected.sha256 != file.sha256 || expected.bytes != file.bytes
4539            })
4540        })
4541    {
4542        return Err(invalid_feed(
4543            "self-custody candidate contains an unexpected file mutation",
4544        ));
4545    }
4546    let mut assets = std::collections::BTreeMap::new();
4547    after.clear();
4548    loop {
4549        let encoded_after: String =
4550            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4551        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4552        let value = ensure_ok(
4553            request_capped(
4554                cfg,
4555                "GET",
4556                &path,
4557                None,
4558                Auth::Required,
4559                MAX_FEED_RESPONSE_BYTES,
4560            )?,
4561            "v2 self-custody asset candidate",
4562        )?;
4563        let page: V2SigningCandidatePage = serde_json::from_value(value)
4564            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4565        let coordinate = (
4566            page.request_hash.clone(),
4567            page.candidate.signing_bytes_base64.clone(),
4568            page.candidate.changes_base64.clone(),
4569            page.candidate.actor_claim_base64.clone(),
4570            page.candidate.content_root.clone(),
4571            page.candidate.asset_root.clone(),
4572            page.parent.seq,
4573            page.parent.commit_hash.clone(),
4574        );
4575        if page.v != 2
4576            || page.challenge_id != challenge_id
4577            || page.mutation_id != mutation_id
4578            || page.assets.len() > 500
4579            || pinned.as_ref() != Some(&coordinate)
4580        {
4581            return Err(invalid_feed(
4582                "self-custody asset candidate changed or is not bound",
4583            ));
4584        }
4585        let root = page.candidate.asset_root.as_deref();
4586        if !page.assets.is_empty() && root.is_none() {
4587            return Err(invalid_feed("asset candidate has no asset root"));
4588        }
4589        for item in page.assets {
4590            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4591            if assets
4592                .insert(
4593                    item.path.clone(),
4594                    V2BaselineAsset {
4595                        blob_sha256: item.blob_sha256,
4596                        bytes: item.bytes,
4597                        media_type: item.media_type,
4598                        wrappers: item.wrappers,
4599                        required: item.required,
4600                        disposition: item.disposition,
4601                        leaf_hash: item.leaf_hash,
4602                    },
4603                )
4604                .is_some()
4605            {
4606                return Err(invalid_feed("self-custody candidate repeats an asset"));
4607            }
4608        }
4609        match page.next_cursor {
4610            None => break,
4611            Some(next) if next > after => after = next,
4612            Some(_) => {
4613                return Err(invalid_feed(
4614                    "self-custody asset candidate cursor did not advance",
4615                ))
4616            }
4617        }
4618    }
4619    if assets.len() != expected_assets.len()
4620        || assets.iter().any(|(path, asset)| {
4621            expected_assets.get(path).is_none_or(|expected| {
4622                asset.blob_sha256 != expected.blob_sha256
4623                    || asset.bytes != expected.bytes
4624                    || asset.media_type != expected.media_type
4625                    || asset.wrappers != expected.wrappers
4626                    || asset.required != expected.required
4627                    || asset.disposition != expected.disposition
4628            })
4629        })
4630    {
4631        return Err(invalid_feed(
4632            "self-custody candidate contains an unexpected asset mutation",
4633        ));
4634    }
4635    let Some((
4636        request_hash,
4637        signing_b64,
4638        changes_b64,
4639        actor_b64,
4640        root,
4641        asset_root,
4642        parent_seq,
4643        parent,
4644    )) = pinned
4645    else {
4646        return Err(invalid_feed("self-custody candidate has no manifest"));
4647    };
4648    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4649    let current_commit = head
4650        .pointer
4651        .as_ref()
4652        .map(|pointer| pointer.commit_hash.clone());
4653    if parent_seq != current_seq || parent != current_commit {
4654        return Err(LinkError::RemoteAdvancedDuringSync);
4655    }
4656    let changes = STANDARD
4657        .decode(changes_b64)
4658        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4659    let mut expected_changes = json!({
4660        "mutation_id": mutation_id,
4661        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4662        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4663        "v": 2,
4664    });
4665    if let Some(withheld_links) = request_body.get("withheld_links") {
4666        expected_changes["withheld_links"] = withheld_links.clone();
4667    }
4668    if let Some(checkout_id) = request_body.get("checkout_id") {
4669        expected_changes["checkout_id"] = checkout_id.clone();
4670    }
4671    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4672        .map_err(|error| invalid_feed(error.to_string()))?;
4673    if changes != expected_changes_bytes {
4674        return Err(invalid_feed(
4675            "self-custody changeset differs from the requested mutation",
4676        ));
4677    }
4678    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4679        .map_err(|error| invalid_feed(error.to_string()))?;
4680    let request_value = json!({
4681        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4682        "brain": head.brain_id,
4683        "changes_sha256": changes_hash,
4684        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4685        "v": 2,
4686        "v1_bridge": Value::Null,
4687    });
4688    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4689        .map_err(|error| invalid_feed(error.to_string()))?;
4690    if request_hash != expected_request_hash {
4691        return Err(invalid_feed(
4692            "self-custody request hash differs from the requested mutation",
4693        ));
4694    }
4695    let actor = STANDARD
4696        .decode(actor_b64)
4697        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4698    let actor_value: Value = serde_json::from_slice(&actor)
4699        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4700    if crate::linkmd_v2::canonical_bytes(&actor_value)
4701        .map_err(|error| invalid_feed(error.to_string()))?
4702        != actor
4703    {
4704        return Err(invalid_feed("self-custody actor claim is not canonical"));
4705    }
4706    let actor_object = actor_value
4707        .as_object()
4708        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4709    let actor_claim = actor_object
4710        .get("claim")
4711        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4712    let actor_public_key = actor_object
4713        .get("public_key")
4714        .and_then(Value::as_str)
4715        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4716    let actor_fingerprint = actor_object
4717        .get("fingerprint")
4718        .and_then(Value::as_str)
4719        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4720    let actor_signature = actor_object
4721        .get("sig")
4722        .and_then(Value::as_str)
4723        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4724    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4725        .map_err(|error| invalid_feed(error.to_string()))?;
4726    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4727    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4728    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4729    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4730    let impact = actor_claim
4731        .get("result")
4732        .and_then(|result| result.get("impact"))
4733        .and_then(Value::as_object);
4734    let impact_fields = [
4735        "creates",
4736        "updates",
4737        "deletes",
4738        "withdrawals",
4739        "renames",
4740        "restores",
4741        "asset_changes",
4742        "public_expansions",
4743        "executable_activations",
4744    ];
4745    let impact_is_valid = impact.is_some_and(|impact| {
4746        impact.len() == impact_fields.len() + 1
4747            && impact.get("v").and_then(Value::as_u64) == Some(1)
4748            && impact_fields
4749                .iter()
4750                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4751    });
4752    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4753        || head
4754            .trust
4755            .hub_signer
4756            .as_ref()
4757            .is_some_and(|known| known != &expected_actor_signer)
4758        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4759        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4760        || actor_claim
4761            .get("candidate")
4762            .and_then(|candidate| candidate.get("changes_sha256"))
4763            .and_then(Value::as_str)
4764            != Some(changes_hash.as_str())
4765        || actor_claim
4766            .get("candidate")
4767            .and_then(|candidate| candidate.get("state_root"))
4768            != Some(&expected_actor_root)
4769        || actor_claim
4770            .get("candidate")
4771            .and_then(|candidate| candidate.get("asset_root"))
4772            != Some(&expected_actor_asset_root)
4773        || actor_claim
4774            .get("candidate")
4775            .and_then(|candidate| candidate.get("control_revision"))
4776            .and_then(Value::as_str)
4777            != Some(head.control_revision.as_str())
4778        || !impact_is_valid
4779    {
4780        return Err(invalid_feed(
4781            "self-custody actor claim does not bind the verified authority",
4782        ));
4783    }
4784    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4785        .map_err(|error| invalid_feed(error.to_string()))?;
4786    let signing = STANDARD
4787        .decode(signing_b64)
4788        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4789    let signing_value: Value = serde_json::from_slice(&signing)
4790        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4791    if crate::linkmd_v2::canonical_bytes(&signing_value)
4792        .map_err(|error| invalid_feed(error.to_string()))?
4793        != signing
4794    {
4795        return Err(invalid_feed("self-custody signing bytes are not canonical"));
4796    }
4797    let pointer = head.pointer.as_ref();
4798    let expected_materializer = pointer
4799        .map(|value| value.materializer.as_str())
4800        .unwrap_or("dbmd-projection-v1");
4801    let expected_parent_commit = request_body
4802        .get("base")
4803        .and_then(|base| base.get("commit_hash"))
4804        .cloned()
4805        .unwrap_or(Value::Null);
4806    let expected_parent_root = request_body
4807        .get("base")
4808        .and_then(|base| base.get("content_root"))
4809        .cloned()
4810        .unwrap_or(Value::Null);
4811    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4812    let expected_parent_asset_root = request_body
4813        .get("base")
4814        .and_then(|base| base.get("asset_root"))
4815        .cloned()
4816        .unwrap_or(Value::Null);
4817    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4818    let expected_prev_entry = pointer
4819        .map(|value| Value::String(value.feed_hash.clone()))
4820        .unwrap_or(Value::Null);
4821    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4822        .map_err(|_| invalid_feed("brain identity history is too large"))?
4823        + 1;
4824    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4825        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4826        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4827        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4828        || signing_value.get("public_key").and_then(Value::as_str)
4829            != Some(key.public_key_spki.as_str())
4830        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4831        || signing_value.get("parent_root") != Some(&expected_parent_root)
4832        || signing_value.get("state_root") != Some(&expected_state_root)
4833        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4834        || signing_value.get("asset_root") != Some(&expected_asset_root)
4835        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4836        || signing_value.get("changes_sha256").and_then(Value::as_str)
4837            != Some(changes_hash.as_str())
4838        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4839        || signing_value
4840            .get("control_revision")
4841            .and_then(Value::as_str)
4842            != Some(head.control_revision.as_str())
4843        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4844        || signing_value.get("v1_bridge") != Some(&Value::Null)
4845        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4846    {
4847        return Err(invalid_feed(
4848            "self-custody signing bytes do not bind the verified candidate",
4849        ));
4850    }
4851    let pair = agent_keypair(&key.pkcs8)?;
4852    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4853    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4854}
4855
4856fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4857    let origin = normalized_origin(&cfg.hub)?;
4858    let absolute = if checkout.is_absolute() {
4859        checkout.to_path_buf()
4860    } else {
4861        std::env::current_dir()?.join(checkout)
4862    };
4863    Ok(format!(
4864        "sync-{}.json",
4865        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4866    ))
4867}
4868
4869fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4870    if let Some(value) = existing {
4871        if !is_sha256(value) {
4872            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4873        }
4874        return Ok(value.to_string());
4875    }
4876    use ring::rand::SecureRandom as _;
4877    let mut random = [0_u8; 32];
4878    ring::rand::SystemRandom::new()
4879        .fill(&mut random)
4880        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4881    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4882}
4883
4884#[cfg(any(unix, windows))]
4885fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4886    let directory = open_trust_dir(cfg)?;
4887    let origin = normalized_origin(&cfg.hub)?;
4888    let name = format!(
4889        "operation-{}.lock",
4890        content_sha256(format!("{origin}\0{brain}").as_bytes())
4891    );
4892    lock_trust_name(&directory, &name)
4893}
4894
4895#[cfg(not(any(unix, windows)))]
4896fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4897    Err(LinkError::UnsupportedPlatform {
4898        operation: "serialized link.md v2 sync",
4899    })
4900}
4901
4902fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4903    left.brain_id == right.brain_id
4904        && left.view_kind == right.view_kind
4905        && left.view_revision == right.view_revision
4906        && left.control_revision == right.control_revision
4907        && match (&left.pointer, &right.pointer) {
4908            (None, None) => true,
4909            (Some(left), Some(right)) => {
4910                left.seq == right.seq
4911                    && left.commit_hash == right.commit_hash
4912                    && left.content_root == right.content_root
4913                    && left.asset_root == right.asset_root
4914                    && left.feed_hash == right.feed_hash
4915            }
4916            _ => false,
4917        }
4918}
4919
4920/// A private sync baseline is an already verified materialization of the
4921/// remote manifest. It may replace another paginated manifest download only
4922/// when a freshly verified signed head names the exact same content, assets,
4923/// view, and authority revision. Any remote or permission movement falls back
4924/// to proof-bearing hub reads.
4925fn v2_baseline_matches_head(head: &V2VerifiedHead, baseline: &V2SyncBaseline) -> bool {
4926    let pointer = head.pointer.as_ref();
4927    baseline.head_seq == Some(pointer.map_or(0, |value| value.seq))
4928        && baseline.commit_hash.as_deref() == pointer.map(|value| value.commit_hash.as_str())
4929        && baseline.content_root.as_deref()
4930            == pointer.and_then(|value| value.content_root.as_deref())
4931        && baseline.asset_root.as_deref() == pointer.and_then(|value| value.asset_root.as_deref())
4932        && baseline.view_kind.as_deref() == Some(head.view_kind.as_str())
4933        && baseline.view_revision.as_deref() == Some(head.view_revision.as_str())
4934        && baseline.control_revision.as_deref() == Some(head.control_revision.as_str())
4935}
4936
4937fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4938    format!(
4939        "---\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"
4940    )
4941    .into_bytes()
4942}
4943
4944fn scoped_projection_sha256(brain: &str) -> String {
4945    content_sha256(&scoped_projection_bytes(brain))
4946}
4947
4948#[derive(Deserialize)]
4949struct LocalScopedViewMarker {
4950    v: u8,
4951    kind: String,
4952    authoritative: bool,
4953    brain: String,
4954    projection_sha256: String,
4955}
4956
4957/// True only when this store carries the exact generated marker for a local
4958/// link.md scoped view. This is presentation context, never authorization;
4959/// the hub remains the only authority for reads and writes.
4960pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4961    let marker = store
4962        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4963        .ok()
4964        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4965    let Some(marker) = marker else {
4966        return false;
4967    };
4968    if marker.v != 1
4969        || marker.kind != "link.md-scoped-view"
4970        || marker.authoritative
4971        || !crate::ulid::is_ulid(&marker.brain)
4972        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4973    {
4974        return false;
4975    }
4976    store
4977        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4978        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4979}
4980
4981fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4982    let mut bytes = serde_json::to_vec_pretty(&json!({
4983        "v": 1,
4984        "kind": "link.md-scoped-view",
4985        "authoritative": false,
4986        "brain": head.brain_id,
4987        "view_revision": head.view_revision,
4988        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4989        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4990        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4991        "visible_files": files,
4992        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4993    }))
4994    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4995    bytes.push(b'\n');
4996    Ok(bytes)
4997}
4998
4999fn refresh_scoped_view_marker(
5000    store: &Store,
5001    head: &V2VerifiedHead,
5002    files: usize,
5003) -> LinkResult<()> {
5004    if head.view_kind == "scoped" {
5005        store.write_atomic(
5006            Path::new(".dbmd/view.json"),
5007            &scoped_view_metadata(head, files)?,
5008        )?;
5009    }
5010    Ok(())
5011}
5012
5013fn ensure_v2_view_compatible(
5014    head: &V2VerifiedHead,
5015    baseline: Option<&V2SyncBaseline>,
5016) -> LinkResult<()> {
5017    let Some(baseline) = baseline else {
5018        return Ok(());
5019    };
5020    match (
5021        baseline.view_kind.as_deref(),
5022        baseline.view_revision.as_deref(),
5023    ) {
5024        (None, None) if head.view_kind == "full" => Ok(()),
5025        (Some(kind), Some(revision))
5026            if kind == head.view_kind && revision == head.view_revision =>
5027        {
5028            Ok(())
5029        }
5030        _ => Err(LinkError::ScopedViewChanged),
5031    }
5032}
5033
5034fn ensure_established_v2_checkout_opened(
5035    head: &V2VerifiedHead,
5036    baseline: Option<&V2SyncBaseline>,
5037    opened: bool,
5038) -> LinkResult<()> {
5039    if baseline.is_none() || opened {
5040        return Ok(());
5041    }
5042    if head.view_kind == "scoped" {
5043        return Err(LinkError::ScopedProjectionModified);
5044    }
5045    Err(LinkError::InvalidPack {
5046        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
5047    })
5048}
5049
5050fn remove_scoped_projection(
5051    head: &V2VerifiedHead,
5052    baseline: Option<&V2SyncBaseline>,
5053    view: &mut V2LocalView,
5054) -> LinkResult<()> {
5055    if head.view_kind != "scoped" {
5056        return Ok(());
5057    }
5058    let expected = scoped_projection_sha256(&head.brain_id);
5059    if baseline
5060        .and_then(|state| state.projection_sha256.as_deref())
5061        .is_some_and(|pinned| pinned != expected)
5062    {
5063        return Err(LinkError::ScopedViewChanged);
5064    }
5065    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
5066        return Err(LinkError::ScopedProjectionModified);
5067    }
5068    view.riding.remove("DB.md");
5069    view.scan_cache.remove("DB.md");
5070    view.eligibility.remove("DB.md");
5071    Ok(())
5072}
5073
5074fn local_view_for_v2_push(
5075    store: &Store,
5076    head: &V2VerifiedHead,
5077    baseline: Option<&V2SyncBaseline>,
5078    carried: Option<V2LocalView>,
5079) -> LinkResult<V2LocalView> {
5080    match carried {
5081        // The pull half already verified and removed the generated scoped
5082        // projection before constructing its in-memory handoff. Requiring it
5083        // a second time would turn every scoped bidirectional write into a
5084        // false tamper refusal.
5085        Some(view) => Ok(view),
5086        None => {
5087            let hint = baseline.and_then(|state| {
5088                state
5089                    .local_policy_digest
5090                    .as_deref()
5091                    .map(|digest| (digest, &state.scan_cache))
5092            });
5093            let mut view = v2_local_files_cached(store, hint)?;
5094            remove_scoped_projection(head, baseline, &mut view)?;
5095            Ok(view)
5096        }
5097    }
5098}
5099
5100fn files_for_v2_view(
5101    head: &V2VerifiedHead,
5102    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
5103) -> std::collections::BTreeMap<String, V2BaselineFile> {
5104    if head.view_kind == "scoped" {
5105        // Even when a grant explicitly includes canonical DB.md, a partial
5106        // checkout uses a generated marker. Canonical validation remains a
5107        // server-side operation over the complete brain.
5108        files.remove("DB.md");
5109    }
5110    files
5111}
5112
5113fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
5114    let baseline: V2SyncBaseline =
5115        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
5116    if baseline.v != 2
5117        || baseline.origin != normalized_origin(&cfg.hub)?
5118        || baseline.brain != brain
5119        || baseline
5120            .commit_hash
5121            .as_deref()
5122            .is_some_and(|hash| !is_sha256(hash))
5123        || baseline
5124            .content_root
5125            .as_deref()
5126            .is_some_and(|hash| !is_sha256(hash))
5127        || baseline
5128            .asset_root
5129            .as_deref()
5130            .is_some_and(|hash| !is_sha256(hash))
5131        || baseline
5132            .local_policy_digest
5133            .as_deref()
5134            .is_some_and(|hash| !is_sha256(hash))
5135        || baseline
5136            .view_kind
5137            .as_deref()
5138            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5139        || baseline
5140            .view_revision
5141            .as_deref()
5142            .is_some_and(|hash| !is_sha256(hash))
5143        || baseline
5144            .control_revision
5145            .as_deref()
5146            .is_some_and(|hash| !is_sha256(hash))
5147        || baseline
5148            .projection_sha256
5149            .as_deref()
5150            .is_some_and(|hash| !is_sha256(hash))
5151        || (baseline.view_kind.as_deref() == Some("scoped")
5152            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5153        || baseline.files.len() > MAX_PUSH_FILES
5154        || baseline.scan_cache.len() > MAX_PUSH_FILES
5155        || baseline.assets.len() > MAX_PUSH_FILES
5156        || baseline.local_eligibility.len() > MAX_PUSH_FILES
5157        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5158        || baseline.files.iter().any(|(path, file)| {
5159            crate::linkmd_v2::normalize_path(path).is_err()
5160                || !is_sha256(&file.sha256)
5161                || file.bytes > MAX_STORE_BYTES
5162        })
5163        || baseline.scan_cache.iter().any(|(path, file)| {
5164            crate::linkmd_v2::normalize_path(path).is_err()
5165                || !file.fingerprint.starts_with("unix-v1:")
5166                || file.fingerprint.len() > 256
5167                || !file.fingerprint.is_ascii()
5168                || !is_sha256(&file.sha256)
5169                || file.bytes > MAX_STORE_BYTES
5170                || file.withheld_targets.len() > MAX_PUSH_FILES
5171                || file.withheld_targets.iter().any(|target| {
5172                    target.len() > MAX_STORE_PATH_BYTES
5173                        || crate::linkmd_v2::normalize_path(target).is_err()
5174                })
5175                || baseline.files.get(path).is_none_or(|baseline_file| {
5176                    baseline_file.sha256 != file.sha256 || baseline_file.bytes != file.bytes
5177                })
5178        })
5179        || baseline.assets.iter().any(|(path, asset)| {
5180            crate::linkmd_v2::normalize_path(path).is_err()
5181                || !is_sha256(&asset.blob_sha256)
5182                || !is_sha256(&asset.leaf_hash)
5183                || asset.bytes > MAX_ASSET_BYTES
5184                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5185                || asset.wrappers.is_empty()
5186                || asset
5187                    .wrappers
5188                    .iter()
5189                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5190        })
5191        || baseline
5192            .local_eligibility
5193            .keys()
5194            .chain(baseline.remote_copy_remains.keys())
5195            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5196        || baseline
5197            .remote_copy_remains
5198            .values()
5199            .any(|hash| !is_sha256(hash))
5200        || baseline
5201            .checkout_id
5202            .as_deref()
5203            .is_some_and(|checkout_id| !is_sha256(checkout_id))
5204    {
5205        return Err(invalid_feed("v2 sync baseline failed validation"));
5206    }
5207    Ok(baseline)
5208}
5209
5210#[cfg(unix)]
5211fn load_v2_baseline_in(
5212    cfg: &HubConfig,
5213    brain: &str,
5214    directory: &TrustDirectory,
5215    name_string: &str,
5216) -> LinkResult<Option<V2SyncBaseline>> {
5217    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5218    let name = c_name(name_string.as_bytes(), name_string)?;
5219    let fd = unsafe {
5220        libc::openat(
5221            directory.as_raw_fd(),
5222            name.as_ptr(),
5223            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5224        )
5225    };
5226    if fd < 0 {
5227        let error = std::io::Error::last_os_error();
5228        return if error.kind() == std::io::ErrorKind::NotFound {
5229            Ok(None)
5230        } else {
5231            Err(LinkError::UnsafePath {
5232                path: name_string.to_string(),
5233            })
5234        };
5235    }
5236    let file = unsafe { std::fs::File::from_raw_fd(fd) };
5237    let mut bytes = Vec::new();
5238    file.take(MAX_V2_BASELINE_BYTES + 1)
5239        .read_to_end(&mut bytes)?;
5240    if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5241        return Err(invalid_feed("v2 sync baseline is oversized"));
5242    }
5243    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5244}
5245
5246#[cfg(unix)]
5247fn load_v2_baseline(
5248    cfg: &HubConfig,
5249    brain: &str,
5250    checkout: &Path,
5251) -> LinkResult<Option<V2SyncBaseline>> {
5252    let directory = open_trust_dir(cfg)?;
5253    let name = v2_baseline_name(cfg, brain, checkout)?;
5254    let _lock = lock_trust_name(&directory, &name)?;
5255    load_v2_baseline_in(cfg, brain, &directory, &name)
5256}
5257
5258#[cfg(windows)]
5259fn load_v2_baseline_in(
5260    cfg: &HubConfig,
5261    brain: &str,
5262    directory: &TrustDirectory,
5263    name: &str,
5264) -> LinkResult<Option<V2SyncBaseline>> {
5265    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
5266    match reader.read(Path::new(&name), MAX_V2_BASELINE_BYTES) {
5267        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5268        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5269        Err(_) => Err(LinkError::UnsafePath {
5270            path: name.to_string(),
5271        }),
5272    }
5273}
5274
5275#[cfg(windows)]
5276fn load_v2_baseline(
5277    cfg: &HubConfig,
5278    brain: &str,
5279    checkout: &Path,
5280) -> LinkResult<Option<V2SyncBaseline>> {
5281    let directory = open_trust_dir(cfg)?;
5282    let name = v2_baseline_name(cfg, brain, checkout)?;
5283    let _lock = lock_trust_name(&directory, &name)?;
5284    load_v2_baseline_in(cfg, brain, &directory, &name)
5285}
5286
5287#[cfg(not(any(unix, windows)))]
5288fn load_v2_baseline(
5289    _cfg: &HubConfig,
5290    _brain: &str,
5291    _checkout: &Path,
5292) -> LinkResult<Option<V2SyncBaseline>> {
5293    Err(LinkError::UnsupportedPlatform {
5294        operation: "verified link.md v2 baseline",
5295    })
5296}
5297
5298#[cfg(unix)]
5299fn save_v2_baseline(
5300    cfg: &HubConfig,
5301    brain: &str,
5302    checkout: &Path,
5303    baseline: &V2SyncBaseline,
5304) -> LinkResult<()> {
5305    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5306    let directory = open_trust_dir(cfg)?;
5307    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5308    let _lock = lock_trust_name(&directory, &name_string)?;
5309    let name = c_name(name_string.as_bytes(), &name_string)?;
5310    let mut bytes = serde_json::to_vec(baseline)
5311        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5312    bytes.push(b'\n');
5313    if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5314        return Err(invalid_feed("v2 sync baseline is oversized"));
5315    }
5316    let temp_string = format!(
5317        ".{name_string}.tmp.{}-{}",
5318        std::process::id(),
5319        std::time::SystemTime::now()
5320            .duration_since(std::time::UNIX_EPOCH)
5321            .unwrap_or_default()
5322            .as_nanos()
5323    );
5324    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5325    let fd = unsafe {
5326        libc::openat(
5327            directory.as_raw_fd(),
5328            temp.as_ptr(),
5329            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5330            0o600,
5331        )
5332    };
5333    if fd < 0 {
5334        return Err(std::io::Error::last_os_error().into());
5335    }
5336    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5337    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5338        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5339        return Err(error.into());
5340    }
5341    drop(file);
5342    if unsafe {
5343        libc::renameat(
5344            directory.as_raw_fd(),
5345            temp.as_ptr(),
5346            directory.as_raw_fd(),
5347            name.as_ptr(),
5348        )
5349    } != 0
5350    {
5351        let error = std::io::Error::last_os_error();
5352        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5353        return Err(error.into());
5354    }
5355    directory.sync_all()?;
5356    Ok(())
5357}
5358
5359#[cfg(windows)]
5360fn save_v2_baseline(
5361    cfg: &HubConfig,
5362    brain: &str,
5363    checkout: &Path,
5364    baseline: &V2SyncBaseline,
5365) -> LinkResult<()> {
5366    let directory = open_trust_dir(cfg)?;
5367    let name = v2_baseline_name(cfg, brain, checkout)?;
5368    let _lock = lock_trust_name(&directory, &name)?;
5369    let mut bytes = serde_json::to_vec(baseline)
5370        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5371    bytes.push(b'\n');
5372    if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5373        return Err(invalid_feed("v2 sync baseline is oversized"));
5374    }
5375    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5376    Ok(())
5377}
5378
5379#[cfg(not(any(unix, windows)))]
5380fn save_v2_baseline(
5381    _cfg: &HubConfig,
5382    _brain: &str,
5383    _checkout: &Path,
5384    _baseline: &V2SyncBaseline,
5385) -> LinkResult<()> {
5386    Err(LinkError::UnsupportedPlatform {
5387        operation: "verified link.md v2 baseline",
5388    })
5389}
5390
5391fn v2_baseline_from_head(
5392    cfg: &HubConfig,
5393    head: &V2VerifiedHead,
5394    files: std::collections::BTreeMap<String, V2BaselineFile>,
5395    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5396    local: Option<&V2LocalView>,
5397    checkout_id: Option<&str>,
5398) -> LinkResult<V2SyncBaseline> {
5399    let mut local_eligibility = local
5400        .map(|view| view.eligibility.clone())
5401        .unwrap_or_default();
5402    if let Some(view) = local {
5403        for path in files.keys() {
5404            local_eligibility
5405                .entry(path.clone())
5406                .or_insert_with(|| !view.policy.keeps_home(path));
5407        }
5408    }
5409    let remote_copy_remains = local_eligibility
5410        .iter()
5411        .filter(|(_, riding)| !**riding)
5412        .filter_map(|(path, _)| {
5413            files
5414                .get(path)
5415                .map(|file| (path.clone(), file.sha256.clone()))
5416        })
5417        .collect();
5418    // The accepted remote tree is the new comparison base even if a local
5419    // writer changed a few files while the commit was in flight. Preserve
5420    // acceleration only for exact local/remote matches; divergent paths are
5421    // intentionally absent and take the full hash path next run.
5422    let scan_cache = local
5423        .map(|view| {
5424            view.scan_cache
5425                .iter()
5426                .filter(|(path, cached)| {
5427                    files.get(*path).is_some_and(|remote| {
5428                        remote.sha256 == cached.sha256 && remote.bytes == cached.bytes
5429                    })
5430                })
5431                .map(|(path, cached)| (path.clone(), cached.clone()))
5432                .collect()
5433        })
5434        .unwrap_or_default();
5435    Ok(V2SyncBaseline {
5436        v: 2,
5437        origin: normalized_origin(&cfg.hub)?,
5438        brain: head.brain_id.clone(),
5439        checkout_id: Some(v2_checkout_id(checkout_id)?),
5440        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5441        commit_hash: head
5442            .pointer
5443            .as_ref()
5444            .map(|pointer| pointer.commit_hash.clone()),
5445        content_root: head
5446            .pointer
5447            .as_ref()
5448            .and_then(|pointer| pointer.content_root.clone()),
5449        asset_root: head
5450            .pointer
5451            .as_ref()
5452            .and_then(|pointer| pointer.asset_root.clone()),
5453        assets,
5454        view_kind: Some(head.view_kind.clone()),
5455        view_revision: Some(head.view_revision.clone()),
5456        control_revision: Some(head.control_revision.clone()),
5457        projection_sha256: (head.view_kind == "scoped")
5458            .then(|| scoped_projection_sha256(&head.brain_id)),
5459        files,
5460        scan_cache,
5461        local_policy_digest: local.map(|view| view.policy.digest.clone()),
5462        local_eligibility,
5463        remote_copy_remains,
5464    })
5465}
5466
5467#[cfg(unix)]
5468fn v2_scan_fingerprint_at(metadata: &std::fs::Metadata, now_ns: i128) -> Option<String> {
5469    use std::os::unix::fs::MetadataExt as _;
5470
5471    // Git's "racy clean" lesson applies here: a same-size edit inside a
5472    // filesystem timestamp tick can otherwise preserve the whole stat tuple.
5473    // Do not cache a file until its mtime and ctime are at least two seconds
5474    // behind the observer. Future/negative timestamps also stay on the full
5475    // hash path. ctime cannot be restored by an unprivileged writer, so after
5476    // that window an in-place edit, chmod, or atomic replacement invalidates
5477    // the tuple even when a tool deliberately restores mtime.
5478    let mtime_ns = i128::from(metadata.mtime()) * 1_000_000_000 + i128::from(metadata.mtime_nsec());
5479    let ctime_ns = i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
5480    let newest = mtime_ns.max(ctime_ns);
5481    if newest < 0 || now_ns.checked_sub(newest)? < 2_000_000_000 {
5482        return None;
5483    }
5484    Some(format!(
5485        "unix-v1:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}",
5486        metadata.dev(),
5487        metadata.ino(),
5488        metadata.len(),
5489        metadata.mtime(),
5490        metadata.mtime_nsec(),
5491        metadata.ctime(),
5492        metadata.ctime_nsec(),
5493    ))
5494}
5495
5496#[cfg(unix)]
5497fn v2_scan_fingerprint(metadata: &std::fs::Metadata) -> Option<String> {
5498    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?;
5499    let now_ns = i128::from(now.as_secs()) * 1_000_000_000 + i128::from(now.subsec_nanos());
5500    v2_scan_fingerprint_at(metadata, now_ns)
5501}
5502
5503#[cfg(not(unix))]
5504fn v2_scan_fingerprint(_metadata: &std::fs::Metadata) -> Option<String> {
5505    // Full hashing remains the honest portable fallback until another
5506    // platform has an equally strong file-identity + change-time tuple.
5507    None
5508}
5509
5510fn v2_local_files_cached(
5511    store: &Store,
5512    prior_cache: Option<(&str, &std::collections::BTreeMap<String, V2ScanCacheFile>)>,
5513) -> LinkResult<V2LocalView> {
5514    let policy = crate::linkmd_sync_policy::load(store)
5515        .map_err(|message| LinkError::InvalidPack { message })?;
5516    let prior_cache = prior_cache
5517        .filter(|(digest, _)| *digest == policy.digest.as_str())
5518        .map(|(_, cache)| cache);
5519    let asset_paths = crate::assets::read_manifest(store)
5520        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5521        .into_iter()
5522        .map(|asset| asset.path)
5523        .collect::<std::collections::BTreeSet<_>>();
5524    let mut result = std::collections::BTreeMap::new();
5525    let mut scan_cache = std::collections::BTreeMap::new();
5526    let mut eligibility = std::collections::BTreeMap::new();
5527    let mut withheld_links = Vec::<V2WithheldLink>::new();
5528    let mut total = 0_u64;
5529    let mut paths = vec![PathBuf::from("DB.md")];
5530    paths.extend(store.walk()?);
5531    for relative in paths {
5532        let path = relative.to_string_lossy().replace('\\', "/");
5533        // v2 catalogs and asset inventory are materialized/signed separately.
5534        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5535            continue;
5536        }
5537        if asset_paths.contains(&path) {
5538            continue;
5539        }
5540        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5541            path: error.to_string(),
5542        })?;
5543        let riding = !policy.keeps_home(&path);
5544        eligibility.insert(path.clone(), riding);
5545        if !riding {
5546            continue;
5547        }
5548        let remaining = MAX_STORE_BYTES.saturating_sub(total);
5549        let mut file = store.open_regular(&relative)?;
5550        let before = file.metadata()?;
5551        if before.len() > remaining {
5552            return Err(LinkError::PushTooLarge {
5553                detail: format!("more than {MAX_STORE_BYTES} uncompressed bytes"),
5554            });
5555        }
5556        let fingerprint = v2_scan_fingerprint(&before);
5557        if let (Some(fingerprint), Some(cached)) = (
5558            fingerprint.as_deref(),
5559            prior_cache.and_then(|cache| cache.get(&path)),
5560        ) {
5561            if cached.fingerprint == fingerprint && cached.bytes == before.len() {
5562                total = total
5563                    .checked_add(cached.bytes)
5564                    .ok_or_else(|| LinkError::PushTooLarge {
5565                        detail: "v2 local byte count overflow".to_string(),
5566                    })?;
5567                result.insert(path.clone(), (cached.sha256.clone(), cached.bytes));
5568                for target in &cached.withheld_targets {
5569                    withheld_links.push(V2WithheldLink {
5570                        source: path.clone(),
5571                        target: target.clone(),
5572                    });
5573                }
5574                scan_cache.insert(path, cached.clone());
5575                continue;
5576            }
5577        }
5578        let mut bytes = Vec::with_capacity(before.len().min(8 * 1024 * 1024) as usize);
5579        Read::by_ref(&mut file)
5580            .take(remaining.saturating_add(1))
5581            .read_to_end(&mut bytes)?;
5582        if bytes.len() as u64 > remaining {
5583            return Err(LinkError::PushTooLarge {
5584                detail: format!("more than {MAX_STORE_BYTES} uncompressed bytes"),
5585            });
5586        }
5587        total = total
5588            .checked_add(bytes.len() as u64)
5589            .ok_or_else(|| LinkError::PushTooLarge {
5590                detail: "v2 local byte count overflow".to_string(),
5591            })?;
5592        if total > MAX_STORE_BYTES {
5593            return Err(LinkError::PushTooLarge {
5594                detail: format!("{total} uncompressed bytes"),
5595            });
5596        }
5597        if std::str::from_utf8(&bytes).is_err() {
5598            return Err(LinkError::NotUtf8 { path });
5599        }
5600        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5601        // A kept-home target need not be present on THIS machine. The policy
5602        // is the owner's durable statement about where a path lives. Cache
5603        // only the resulting withheld targets (rather than every wiki-link),
5604        // and bind reuse to the exact policy digest above.
5605        let targets = crate::store::extract_edge_targets(text)
5606            .into_iter()
5607            .map(|target| format!("{target}.md"))
5608            .filter(|target| policy.keeps_home(target))
5609            .collect::<Vec<_>>();
5610        for target in &targets {
5611            withheld_links.push(V2WithheldLink {
5612                source: path.clone(),
5613                target: target.clone(),
5614            });
5615        }
5616        let sha256 = content_sha256(&bytes);
5617        result.insert(path.clone(), (sha256.clone(), bytes.len() as u64));
5618        let after = file.metadata()?;
5619        if before.len() == bytes.len() as u64
5620            && v2_scan_fingerprint(&before) == v2_scan_fingerprint(&after)
5621        {
5622            if let Some(fingerprint) = v2_scan_fingerprint(&after) {
5623                scan_cache.insert(
5624                    path,
5625                    V2ScanCacheFile {
5626                        fingerprint,
5627                        sha256,
5628                        bytes: bytes.len() as u64,
5629                        withheld_targets: targets,
5630                    },
5631                );
5632            }
5633        }
5634    }
5635    withheld_links.sort();
5636    withheld_links.dedup();
5637    Ok(V2LocalView {
5638        riding: result,
5639        scan_cache,
5640        eligibility,
5641        policy,
5642        withheld_links,
5643    })
5644}
5645
5646fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5647    v2_local_files_cached(store, None)
5648}
5649
5650#[derive(Debug, Clone, Deserialize)]
5651struct V2DownloadItem {
5652    path: String,
5653    sha256: String,
5654    bytes: u64,
5655    url: String,
5656    method: String,
5657}
5658
5659#[derive(Debug, Deserialize)]
5660struct V2DownloadWindow {
5661    v: u8,
5662    commit: String,
5663    downloads: Vec<V2DownloadItem>,
5664}
5665
5666#[derive(Debug, Deserialize)]
5667struct V2BulkStreamHeader {
5668    v: u8,
5669    path: String,
5670    sha256: String,
5671    bytes: u64,
5672}
5673
5674fn parse_v2_bulk_stream(
5675    bytes: &[u8],
5676    expected: &[(&String, &V2BaselineFile)],
5677) -> LinkResult<Vec<(String, Vec<u8>)>> {
5678    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5679        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5680    }
5681    let mut cursor = V2_BULK_STREAM_MAGIC.len();
5682    let mut result = Vec::with_capacity(expected.len());
5683    for (expected_path, expected_file) in expected {
5684        let length_bytes = bytes
5685            .get(cursor..cursor + 4)
5686            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5687        cursor += 4;
5688        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5689        if header_len == 0 || header_len > 4 * 1024 {
5690            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5691        }
5692        let header_bytes = bytes
5693            .get(cursor..cursor + header_len)
5694            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5695        cursor += header_len;
5696        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5697            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5698        if header.v != 2
5699            || &header.path != *expected_path
5700            || header.sha256 != expected_file.sha256
5701            || header.bytes != expected_file.bytes
5702            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5703        {
5704            return Err(invalid_feed(
5705                "v2 bulk stream frame differs from its proven manifest entry",
5706            ));
5707        }
5708        let body_len = usize::try_from(header.bytes)
5709            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5710        let body = bytes
5711            .get(cursor..cursor + body_len)
5712            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5713        cursor += body_len;
5714        if content_sha256(body) != header.sha256 {
5715            return Err(invalid_feed(
5716                "v2 bulk stream file differs from its proven manifest entry",
5717            ));
5718        }
5719        result.push((header.path, body.to_vec()));
5720    }
5721    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5722        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5723    }
5724    cursor += 4;
5725    if cursor != bytes.len() {
5726        return Err(invalid_feed("v2 bulk stream carries trailing data"));
5727    }
5728    Ok(result)
5729}
5730
5731fn download_v2_bulk_stream(
5732    cfg: &HubConfig,
5733    brain: &str,
5734    pointer: &V2PointerBody,
5735    pending: &[(&String, &V2BaselineFile)],
5736) -> LinkResult<Vec<(String, Vec<u8>)>> {
5737    let claims = pending
5738        .iter()
5739        .map(|(path, file)| {
5740            Ok(json!({
5741                "path": path,
5742                "sha256": file.sha256,
5743                "bytes": file.bytes,
5744                "proof": file.proof.as_ref().ok_or_else(|| {
5745                    invalid_feed("v2 manifest omitted a bulk-stream proof")
5746                })?,
5747            }))
5748        })
5749        .collect::<LinkResult<Vec<_>>>()?;
5750    let raw = request_raw_retryable_read(
5751        cfg,
5752        "POST",
5753        &format!("/api/hub/brains/{brain}/v2/stream"),
5754        Some(&json!({
5755            "commit": pointer.commit_hash,
5756            "files": claims,
5757        })),
5758        Auth::Required,
5759        V2_BULK_STREAM_RESPONSE_BYTES,
5760    )?;
5761    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5762    parse_v2_bulk_stream(&body, pending)
5763}
5764
5765fn request_capped_retryable_read(
5766    cfg: &HubConfig,
5767    method: &str,
5768    path: &str,
5769    body: Option<&Value>,
5770    auth: Auth,
5771    max_response_bytes: u64,
5772) -> LinkResult<HubResponse> {
5773    let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5774    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5775    Ok(HubResponse {
5776        status: raw.status,
5777        body: parsed,
5778    })
5779}
5780
5781fn prepare_v2_downloads(
5782    cfg: &HubConfig,
5783    brain: &str,
5784    pointer: &V2PointerBody,
5785    pending: &[(&String, &V2BaselineFile)],
5786) -> LinkResult<Vec<V2DownloadItem>> {
5787    let mut result = Vec::with_capacity(pending.len());
5788    for chunk in pending.chunks(128) {
5789        let claims = chunk
5790            .iter()
5791            .map(|(path, file)| {
5792                Ok(json!({
5793                    "path": path,
5794                    "sha256": file.sha256,
5795                    "bytes": file.bytes,
5796                    "proof": file.proof.as_ref().ok_or_else(|| {
5797                        invalid_feed("v2 manifest omitted a download proof")
5798                    })?,
5799                }))
5800            })
5801            .collect::<LinkResult<Vec<_>>>()?;
5802        let value = ensure_ok(
5803            request_capped_retryable_read(
5804                cfg,
5805                "POST",
5806                &format!("/api/hub/brains/{brain}/v2/downloads"),
5807                Some(&json!({
5808                    "commit": pointer.commit_hash,
5809                    "files": claims,
5810                })),
5811                Auth::Required,
5812                MAX_FEED_RESPONSE_BYTES,
5813            )?,
5814            "prepare v2 blob downloads",
5815        )?;
5816        let window: V2DownloadWindow = serde_json::from_value(value)
5817            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5818        if window.v != 2
5819            || window.commit != pointer.commit_hash
5820            || window.downloads.len() != chunk.len()
5821        {
5822            return Err(invalid_feed(
5823                "v2 download window is not bound to the requested files",
5824            ));
5825        }
5826        let mut by_path = window
5827            .downloads
5828            .into_iter()
5829            .map(|item| (item.path.clone(), item))
5830            .collect::<std::collections::BTreeMap<_, _>>();
5831        if by_path.len() != chunk.len() {
5832            return Err(invalid_feed("v2 download window repeats a path"));
5833        }
5834        for (path, file) in chunk {
5835            let item = by_path
5836                .remove(*path)
5837                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5838            if item.method != "GET"
5839                || item.sha256 != file.sha256
5840                || item.bytes != file.bytes
5841                || item.url.is_empty()
5842            {
5843                return Err(invalid_feed(
5844                    "v2 download capability differs from its proven file",
5845                ));
5846            }
5847            result.push(item);
5848        }
5849    }
5850    Ok(result)
5851}
5852
5853fn prepare_v2_asset_downloads(
5854    cfg: &HubConfig,
5855    brain: &str,
5856    pointer: &V2PointerBody,
5857    pending: &[(&String, &V2BaselineAsset)],
5858) -> LinkResult<Vec<V2DownloadItem>> {
5859    let mut result = Vec::with_capacity(pending.len());
5860    for chunk in pending.chunks(128) {
5861        let claims = chunk
5862            .iter()
5863            .map(|(path, asset)| {
5864                json!({
5865                    "path": path,
5866                    "sha256": asset.blob_sha256,
5867                    "bytes": asset.bytes,
5868                    "leaf_hash": asset.leaf_hash,
5869                })
5870            })
5871            .collect::<Vec<_>>();
5872        let value = ensure_ok(
5873            request_capped_retryable_read(
5874                cfg,
5875                "POST",
5876                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5877                Some(&json!({
5878                    "commit": pointer.commit_hash,
5879                    "assets": claims,
5880                })),
5881                Auth::Required,
5882                MAX_FEED_RESPONSE_BYTES,
5883            )?,
5884            "prepare v2 asset downloads",
5885        )?;
5886        let window: V2DownloadWindow = serde_json::from_value(value)
5887            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5888        if window.v != 2
5889            || window.commit != pointer.commit_hash
5890            || window.downloads.len() != chunk.len()
5891        {
5892            return Err(invalid_feed(
5893                "v2 asset download window is not bound to the requested assets",
5894            ));
5895        }
5896        let mut by_path = window
5897            .downloads
5898            .into_iter()
5899            .map(|item| (item.path.clone(), item))
5900            .collect::<std::collections::BTreeMap<_, _>>();
5901        if by_path.len() != chunk.len() {
5902            return Err(invalid_feed("v2 asset download window repeats a path"));
5903        }
5904        for (path, asset) in chunk {
5905            let item = by_path
5906                .remove(*path)
5907                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5908            if item.method != "GET"
5909                || item.sha256 != asset.blob_sha256
5910                || item.bytes != asset.bytes
5911                || item.url.is_empty()
5912            {
5913                return Err(invalid_feed(
5914                    "v2 asset download capability differs from its signed leaf",
5915                ));
5916            }
5917            result.push(item);
5918        }
5919    }
5920    Ok(result)
5921}
5922
5923#[cfg(any(unix, windows))]
5924fn stage_v2_asset_download_window(
5925    cfg: &HubConfig,
5926    brain: &str,
5927    pointer: &V2PointerBody,
5928    cache_dir: &Path,
5929    pending: &[(&String, &V2BaselineAsset)],
5930) -> LinkResult<Vec<V2StagedFile>> {
5931    if pending.is_empty() {
5932        return Ok(Vec::new());
5933    }
5934    if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5935        return Err(invalid_feed("v2 asset capability window is oversized"));
5936    }
5937
5938    let mut last_error = None;
5939    for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5940        .iter()
5941        .copied()
5942        .map(Some)
5943        .chain(std::iter::once(None))
5944    {
5945        // This POST rechecks the exact head and current path authority before
5946        // issuing fresh, immutable GET capabilities. Successful prior workers
5947        // are already durable in the content-addressed cache, so retrying the
5948        // complete small window neither redownloads them nor widens access.
5949        let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5950        let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5951        for item in downloads {
5952            match unique.get(&item.sha256) {
5953                Some(prior) if prior.bytes != item.bytes => {
5954                    return Err(invalid_feed(
5955                        "one v2 asset hash has conflicting byte lengths",
5956                    ));
5957                }
5958                Some(_) => {}
5959                None => {
5960                    unique.insert(item.sha256.clone(), item);
5961                }
5962            }
5963        }
5964        let downloads = unique.into_values().collect::<Vec<_>>();
5965        let next = std::sync::atomic::AtomicUsize::new(0);
5966        let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5967        let mut results = std::iter::repeat_with(|| None)
5968            .take(downloads.len())
5969            .collect::<Vec<Option<LinkResult<PathBuf>>>>();
5970        std::thread::scope(|scope| {
5971            let (sender, receiver) = std::sync::mpsc::channel();
5972            for _ in 0..worker_count {
5973                let sender = sender.clone();
5974                let downloads = &downloads;
5975                let next = &next;
5976                scope.spawn(move || loop {
5977                    let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5978                    let Some(item) = downloads.get(index) else {
5979                        break;
5980                    };
5981                    let result = download_presigned_to_cache(
5982                        cfg,
5983                        &item.url,
5984                        cache_dir,
5985                        &item.sha256,
5986                        item.bytes,
5987                    );
5988                    if sender.send((index, result)).is_err() {
5989                        break;
5990                    }
5991                });
5992            }
5993            drop(sender);
5994            for (index, result) in receiver {
5995                results[index] = Some(result);
5996            }
5997        });
5998
5999        let mut failed = None;
6000        for result in results {
6001            match result {
6002                Some(Ok(_)) => {}
6003                Some(Err(error)) if failed.is_none() => failed = Some(error),
6004                Some(Err(_)) => {}
6005                None if failed.is_none() => {
6006                    failed = Some(LinkError::Transport {
6007                        hub: cfg.hub.clone(),
6008                        message: "a bounded v2 asset worker stopped before reporting its result"
6009                            .to_string(),
6010                    });
6011                }
6012                None => {}
6013            }
6014        }
6015        if let Some(error) = failed {
6016            last_error = Some(error);
6017            if let Some(milliseconds) = retry_delay {
6018                std::thread::sleep(std::time::Duration::from_millis(milliseconds));
6019                continue;
6020            }
6021            break;
6022        }
6023
6024        return pending
6025            .iter()
6026            .map(|(path, asset)| {
6027                let source = cache_dir.join(&asset.blob_sha256);
6028                if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
6029                    return Err(invalid_feed(
6030                        "v2 asset download cache omitted a proven blob",
6031                    ));
6032                }
6033                Ok(V2StagedFile {
6034                    path: (*path).clone(),
6035                    source,
6036                    sha256: asset.blob_sha256.clone(),
6037                    bytes: asset.bytes,
6038                })
6039            })
6040            .collect();
6041    }
6042    Err(last_error
6043        .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
6044}
6045
6046fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
6047    let bytes = get_presigned(cfg, &item.url)?;
6048    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
6049        return Err(invalid_feed("v2 blob differs from its proven path entry"));
6050    }
6051    Ok(bytes)
6052}
6053
6054#[derive(Debug, Clone)]
6055struct V2StagedFile {
6056    path: String,
6057    source: PathBuf,
6058    sha256: String,
6059    bytes: u64,
6060}
6061
6062#[cfg(unix)]
6063fn v2_download_cache_dir(
6064    cfg: &HubConfig,
6065    brain: &str,
6066    pointer: &V2PointerBody,
6067) -> LinkResult<PathBuf> {
6068    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
6069}
6070
6071#[cfg(unix)]
6072fn v2_download_cache_dir_for(
6073    cfg: &HubConfig,
6074    brain: &str,
6075    transaction: &str,
6076) -> LinkResult<PathBuf> {
6077    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6078        return Err(invalid_feed("v2 download cache address is invalid"));
6079    }
6080    let path = cfg
6081        .state_dir
6082        .join("downloads")
6083        .join(brain)
6084        .join(transaction);
6085    let directory = open_or_create_dir_nofollow(&path)?;
6086    use std::os::fd::AsRawFd as _;
6087    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
6088        return Err(std::io::Error::last_os_error().into());
6089    }
6090    directory.sync_all()?;
6091    Ok(path)
6092}
6093
6094#[cfg(windows)]
6095fn v2_download_cache_dir(
6096    cfg: &HubConfig,
6097    brain: &str,
6098    pointer: &V2PointerBody,
6099) -> LinkResult<PathBuf> {
6100    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
6101}
6102
6103#[cfg(windows)]
6104fn v2_download_cache_dir_for(
6105    cfg: &HubConfig,
6106    brain: &str,
6107    transaction: &str,
6108) -> LinkResult<PathBuf> {
6109    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6110        return Err(invalid_feed("v2 download cache address is invalid"));
6111    }
6112    let path = cfg
6113        .state_dir
6114        .join("downloads")
6115        .join(brain)
6116        .join(transaction);
6117    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
6118    crate::fsx::open_directory_nofollow(&path)?;
6119    Ok(path)
6120}
6121
6122#[cfg(unix)]
6123fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
6124    use std::os::fd::AsRawFd as _;
6125    let parent = cfg.state_dir.join("downloads").join(brain);
6126    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
6127        return;
6128    };
6129    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
6130        return;
6131    };
6132    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
6133    let _ = directory.sync_all();
6134}
6135
6136#[cfg(windows)]
6137fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
6138    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6139        return;
6140    }
6141    let parent = cfg.state_dir.join("downloads").join(brain);
6142    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
6143        return;
6144    };
6145    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
6146}
6147
6148#[cfg(not(any(unix, windows)))]
6149fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
6150
6151#[cfg(not(any(unix, windows)))]
6152fn v2_download_cache_dir_for(
6153    _cfg: &HubConfig,
6154    _brain: &str,
6155    _transaction: &str,
6156) -> LinkResult<PathBuf> {
6157    Err(LinkError::UnsupportedPlatform {
6158        operation: "resumable v2 download staging",
6159    })
6160}
6161
6162#[cfg(any(unix, windows))]
6163fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
6164    let file = match crate::fsx::open_regular_nofollow(path) {
6165        Ok(file) => file,
6166        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6167        Err(error) => return Err(error.into()),
6168    };
6169    if file.metadata()?.len() != bytes {
6170        return Ok(false);
6171    }
6172    Ok(content_sha256_reader(file)? == sha256)
6173}
6174
6175#[cfg(any(unix, windows))]
6176fn cache_v2_blob_bytes(
6177    cache_dir: &Path,
6178    sha256: &str,
6179    expected_bytes: u64,
6180    bytes: &[u8],
6181) -> LinkResult<PathBuf> {
6182    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
6183        return Err(invalid_feed("v2 cached blob differs from its declaration"));
6184    }
6185    let path = cache_dir.join(sha256);
6186    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
6187        crate::fsx::write_atomic(&path, bytes)?;
6188    }
6189    Ok(path)
6190}
6191
6192#[cfg(not(any(unix, windows)))]
6193fn cache_v2_blob_bytes(
6194    _cache_dir: &Path,
6195    _sha256: &str,
6196    _expected_bytes: u64,
6197    _bytes: &[u8],
6198) -> LinkResult<PathBuf> {
6199    Err(LinkError::UnsupportedPlatform {
6200        operation: "resumable v2 download staging",
6201    })
6202}
6203
6204#[cfg(unix)]
6205fn download_presigned_to_cache(
6206    cfg: &HubConfig,
6207    url: &str,
6208    cache_dir: &Path,
6209    sha256: &str,
6210    expected_bytes: u64,
6211) -> LinkResult<PathBuf> {
6212    use std::os::fd::{AsRawFd as _, FromRawFd as _};
6213
6214    let target = cache_dir.join(sha256);
6215    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6216        return Ok(target);
6217    }
6218    let directory = open_existing_dir_nofollow(cache_dir)?;
6219    let mut nonce = [0_u8; 16];
6220    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
6221        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
6222    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
6223    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
6224    let fd = unsafe {
6225        libc::openat(
6226            directory.as_raw_fd(),
6227            temp.as_ptr(),
6228            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6229            0o600,
6230        )
6231    };
6232    if fd < 0 {
6233        return Err(std::io::Error::last_os_error().into());
6234    }
6235    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
6236    let response = match presigned_agent(cfg, url)?.get(url).call() {
6237        Ok(response) => response,
6238        Err(ureq::Error::Status(_, response)) => {
6239            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6240            return Err(LinkError::Http {
6241                what: "v2 direct download",
6242                status: response.status(),
6243                message: "object store rejected the download".to_string(),
6244                code: None,
6245                details: None,
6246            });
6247        }
6248        Err(ureq::Error::Transport(error)) => {
6249            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6250            return Err(LinkError::Transport {
6251                hub: cfg.hub.clone(),
6252                message: error.to_string(),
6253            });
6254        }
6255    };
6256    let mut reader = response
6257        .into_reader()
6258        .take(expected_bytes.saturating_add(1));
6259    let mut digest = Sha256::new();
6260    let mut total = 0_u64;
6261    let mut buffer = [0_u8; 64 * 1024];
6262    // Reading the socket and writing the file are different failures wearing
6263    // the same io::Error: a stalled download is the peer's problem and a full
6264    // disk is this machine's, and an operator who is told only "timed out"
6265    // cannot tell which one to act on.
6266    let write_result = (|| -> LinkResult<()> {
6267        loop {
6268            let read = reader
6269                .read(&mut buffer)
6270                .map_err(|error| LinkError::Transport {
6271                    hub: cfg.hub.clone(),
6272                    message: error.to_string(),
6273                })?;
6274            if read == 0 {
6275                break;
6276            }
6277            total = total.saturating_add(read as u64);
6278            digest.update(&buffer[..read]);
6279            output.write_all(&buffer[..read])?;
6280        }
6281        output.sync_all().map_err(LinkError::from)
6282    })();
6283    if let Err(error) = write_result {
6284        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6285        return Err(error);
6286    }
6287    drop(output);
6288    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6289        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6290        return Err(invalid_feed(
6291            "v2 direct download failed integrity verification",
6292        ));
6293    }
6294    let target_name = c_name(sha256.as_bytes(), sha256)?;
6295    // Replace only a previously inspected cache leaf. Cache bytes are private
6296    // and content-addressed; final install re-verifies them again.
6297    if unsafe {
6298        libc::renameat(
6299            directory.as_raw_fd(),
6300            temp.as_ptr(),
6301            directory.as_raw_fd(),
6302            target_name.as_ptr(),
6303        )
6304    } != 0
6305    {
6306        let error = std::io::Error::last_os_error();
6307        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6308        return Err(error.into());
6309    }
6310    directory.sync_all()?;
6311    Ok(target)
6312}
6313
6314#[cfg(windows)]
6315fn download_presigned_to_cache(
6316    cfg: &HubConfig,
6317    url: &str,
6318    cache_dir: &Path,
6319    sha256: &str,
6320    expected_bytes: u64,
6321) -> LinkResult<PathBuf> {
6322    use std::fs::OpenOptions;
6323
6324    let target = cache_dir.join(sha256);
6325    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6326        return Ok(target);
6327    }
6328    // Keep the checked directory handle alive while the private cache path is
6329    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
6330    // an ancestor can be swapped after validation.
6331    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6332    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6333    let mut output = OpenOptions::new()
6334        .write(true)
6335        .create_new(true)
6336        .open(&temp)?;
6337    let response = match presigned_agent(cfg, url)?.get(url).call() {
6338        Ok(response) => response,
6339        Err(ureq::Error::Status(_, response)) => {
6340            let _ = std::fs::remove_file(&temp);
6341            return Err(LinkError::Http {
6342                what: "v2 direct download",
6343                status: response.status(),
6344                message: "object store rejected the download".to_string(),
6345                code: None,
6346                details: None,
6347            });
6348        }
6349        Err(ureq::Error::Transport(error)) => {
6350            let _ = std::fs::remove_file(&temp);
6351            return Err(LinkError::Transport {
6352                hub: cfg.hub.clone(),
6353                message: error.to_string(),
6354            });
6355        }
6356    };
6357    let mut reader = response
6358        .into_reader()
6359        .take(expected_bytes.saturating_add(1));
6360    let mut digest = Sha256::new();
6361    let mut total = 0_u64;
6362    let mut buffer = [0_u8; 64 * 1024];
6363    // Same split as the cache-fill path: a stalled socket is not a disk error.
6364    let copied = (|| -> LinkResult<()> {
6365        loop {
6366            let read = reader
6367                .read(&mut buffer)
6368                .map_err(|error| LinkError::Transport {
6369                    hub: cfg.hub.clone(),
6370                    message: error.to_string(),
6371                })?;
6372            if read == 0 {
6373                break;
6374            }
6375            total = total.saturating_add(read as u64);
6376            digest.update(&buffer[..read]);
6377            output.write_all(&buffer[..read])?;
6378        }
6379        output.sync_all()?;
6380        Ok(())
6381    })();
6382    if let Err(error) = copied {
6383        let _ = std::fs::remove_file(&temp);
6384        return Err(error);
6385    }
6386    drop(output);
6387    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6388        let _ = std::fs::remove_file(&temp);
6389        return Err(invalid_feed(
6390            "v2 direct download failed integrity verification",
6391        ));
6392    }
6393    if target.exists() {
6394        std::fs::remove_file(&target)?;
6395    }
6396    if let Err(error) = std::fs::rename(&temp, &target) {
6397        let _ = std::fs::remove_file(&temp);
6398        return Err(error.into());
6399    }
6400    Ok(target)
6401}
6402
6403#[cfg(not(any(unix, windows)))]
6404fn download_presigned_to_cache(
6405    _cfg: &HubConfig,
6406    _url: &str,
6407    _cache_dir: &Path,
6408    _sha256: &str,
6409    _expected_bytes: u64,
6410) -> LinkResult<PathBuf> {
6411    Err(LinkError::UnsupportedPlatform {
6412        operation: "resumable v2 download staging",
6413    })
6414}
6415
6416fn download_v2_blobs(
6417    cfg: &HubConfig,
6418    brain: &str,
6419    pointer: &V2PointerBody,
6420    pending: Vec<(&String, &V2BaselineFile)>,
6421) -> LinkResult<Vec<(String, Vec<u8>)>> {
6422    if pending.is_empty() {
6423        return Ok(Vec::new());
6424    }
6425    let expected_order = pending
6426        .iter()
6427        .map(|(path, _)| (*path).clone())
6428        .collect::<Vec<_>>();
6429    let mut streamed = std::collections::BTreeMap::new();
6430    let mut direct = Vec::new();
6431    let mut window = Vec::new();
6432    let mut window_bytes = 0_u64;
6433    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6434                 window_bytes: &mut u64,
6435                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6436     -> LinkResult<()> {
6437        if window.is_empty() {
6438            return Ok(());
6439        }
6440        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6441            if streamed.insert(path, bytes).is_some() {
6442                return Err(invalid_feed("v2 bulk streams repeated a path"));
6443            }
6444        }
6445        window.clear();
6446        *window_bytes = 0;
6447        Ok(())
6448    };
6449    for &(path, file) in &pending {
6450        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6451            flush(&mut window, &mut window_bytes, &mut streamed)?;
6452            direct.push((path, file));
6453            continue;
6454        }
6455        if window.len() == V2_BULK_STREAM_FILES
6456            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6457        {
6458            flush(&mut window, &mut window_bytes, &mut streamed)?;
6459        }
6460        window.push((path, file));
6461        window_bytes += file.bytes;
6462    }
6463    flush(&mut window, &mut window_bytes, &mut streamed)?;
6464
6465    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6466    let next = std::sync::atomic::AtomicUsize::new(0);
6467    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6468    let mut results = std::iter::repeat_with(|| None)
6469        .take(downloads.len())
6470        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6471    std::thread::scope(|scope| {
6472        let (sender, receiver) = std::sync::mpsc::channel();
6473        for _ in 0..worker_count {
6474            let sender = sender.clone();
6475            let downloads = &downloads;
6476            let next = &next;
6477            scope.spawn(move || loop {
6478                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6479                let Some(item) = downloads.get(index) else {
6480                    break;
6481                };
6482                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6483                if sender.send((index, result)).is_err() {
6484                    break;
6485                }
6486            });
6487        }
6488        drop(sender);
6489        for (index, result) in receiver {
6490            results[index] = Some(result);
6491        }
6492    });
6493    for result in results.into_iter().map(|result| {
6494        result.ok_or_else(|| LinkError::Transport {
6495            hub: cfg.hub.clone(),
6496            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6497        })?
6498    }) {
6499        let (path, bytes) = result?;
6500        if streamed.insert(path, bytes).is_some() {
6501            return Err(invalid_feed("v2 download lanes repeated a path"));
6502        }
6503    }
6504    expected_order
6505        .into_iter()
6506        .map(|path| {
6507            streamed
6508                .remove(&path)
6509                .map(|bytes| (path, bytes))
6510                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6511        })
6512        .collect()
6513}
6514
6515/// Resume an exact-head materialization into the private global cache. Each
6516/// verified frame is durable before the next network window begins, so a
6517/// retry skips it and memory never grows with total changed bytes.
6518#[cfg(any(unix, windows))]
6519fn queue_v2_bulk_window<'a>(
6520    cache_dir: &Path,
6521    window: &mut Vec<(&'a String, &'a V2BaselineFile)>,
6522    window_bytes: &mut u64,
6523    staged: &mut std::collections::BTreeMap<String, V2StagedFile>,
6524    missing_windows: &mut Vec<Vec<(&'a String, &'a V2BaselineFile)>>,
6525) -> LinkResult<()> {
6526    if window.is_empty() {
6527        return Ok(());
6528    }
6529    let missing = window
6530        .iter()
6531        .filter_map(|(path, file)| {
6532            let target = cache_dir.join(&file.sha256);
6533            match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6534                Ok(true) => {
6535                    staged.insert(
6536                        (*path).clone(),
6537                        V2StagedFile {
6538                            path: (*path).clone(),
6539                            source: target,
6540                            sha256: file.sha256.clone(),
6541                            bytes: file.bytes,
6542                        },
6543                    );
6544                    None
6545                }
6546                Ok(false) => Some(Ok((*path, *file))),
6547                Err(error) => Some(Err(error)),
6548            }
6549        })
6550        .collect::<LinkResult<Vec<_>>>()?;
6551    if !missing.is_empty() {
6552        missing_windows.push(missing);
6553    }
6554    window.clear();
6555    *window_bytes = 0;
6556    Ok(())
6557}
6558
6559#[cfg(any(unix, windows))]
6560fn stage_v2_bulk_windows<'a>(
6561    cfg: &HubConfig,
6562    brain: &str,
6563    pointer: &V2PointerBody,
6564    cache_dir: &Path,
6565    windows: Vec<Vec<(&'a String, &'a V2BaselineFile)>>,
6566    staged: &mut std::collections::BTreeMap<String, V2StagedFile>,
6567) -> LinkResult<()> {
6568    let next = std::sync::atomic::AtomicUsize::new(0);
6569    let worker_count = windows.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6570    let mut first_error = None;
6571    std::thread::scope(|scope| {
6572        let (sender, receiver) = std::sync::mpsc::channel();
6573        for _ in 0..worker_count {
6574            let sender = sender.clone();
6575            let windows = &windows;
6576            let next = &next;
6577            scope.spawn(move || loop {
6578                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6579                let Some(window) = windows.get(index) else {
6580                    break;
6581                };
6582                let result = download_v2_bulk_stream(cfg, brain, pointer, window);
6583                if sender.send((index, result)).is_err() {
6584                    break;
6585                }
6586            });
6587        }
6588        drop(sender);
6589        for (index, result) in receiver {
6590            match result {
6591                Ok(files) => {
6592                    for (path, bytes) in files {
6593                        let Some(file) = windows[index].iter().find_map(|(expected_path, file)| {
6594                            (*expected_path == &path).then_some(*file)
6595                        }) else {
6596                            first_error.get_or_insert_with(|| {
6597                                invalid_feed("v2 stream returned an unrequested cache path")
6598                            });
6599                            continue;
6600                        };
6601                        match cache_v2_blob_bytes(cache_dir, &file.sha256, file.bytes, &bytes) {
6602                            Ok(source) => {
6603                                if staged
6604                                    .insert(
6605                                        path.clone(),
6606                                        V2StagedFile {
6607                                            path,
6608                                            source,
6609                                            sha256: file.sha256.clone(),
6610                                            bytes: file.bytes,
6611                                        },
6612                                    )
6613                                    .is_some()
6614                                {
6615                                    first_error.get_or_insert_with(|| {
6616                                        invalid_feed("v2 bulk streams repeated a path")
6617                                    });
6618                                }
6619                            }
6620                            Err(error) => {
6621                                first_error.get_or_insert(error);
6622                            }
6623                        }
6624                    }
6625                }
6626                Err(error) => {
6627                    first_error.get_or_insert(error);
6628                }
6629            }
6630        }
6631    });
6632    match first_error {
6633        Some(error) => Err(error),
6634        None => Ok(()),
6635    }
6636}
6637
6638#[cfg(any(unix, windows))]
6639fn stage_v2_blobs(
6640    cfg: &HubConfig,
6641    brain: &str,
6642    pointer: &V2PointerBody,
6643    pending: Vec<(&String, &V2BaselineFile)>,
6644) -> LinkResult<Vec<V2StagedFile>> {
6645    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6646    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6647    let mut direct = Vec::new();
6648    let mut window = Vec::new();
6649    let mut missing_windows = Vec::new();
6650    let mut window_bytes = 0_u64;
6651    for &(path, file) in &pending {
6652        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6653            queue_v2_bulk_window(
6654                &cache_dir,
6655                &mut window,
6656                &mut window_bytes,
6657                &mut staged,
6658                &mut missing_windows,
6659            )?;
6660            direct.push((path, file));
6661            continue;
6662        }
6663        if window.len() == V2_BULK_STREAM_FILES
6664            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6665        {
6666            queue_v2_bulk_window(
6667                &cache_dir,
6668                &mut window,
6669                &mut window_bytes,
6670                &mut staged,
6671                &mut missing_windows,
6672            )?;
6673        }
6674        window.push((path, file));
6675        window_bytes += file.bytes;
6676    }
6677    queue_v2_bulk_window(
6678        &cache_dir,
6679        &mut window,
6680        &mut window_bytes,
6681        &mut staged,
6682        &mut missing_windows,
6683    )?;
6684    stage_v2_bulk_windows(
6685        cfg,
6686        brain,
6687        pointer,
6688        &cache_dir,
6689        missing_windows,
6690        &mut staged,
6691    )?;
6692    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6693        let source =
6694            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6695        staged.insert(
6696            item.path.clone(),
6697            V2StagedFile {
6698                path: item.path,
6699                source,
6700                sha256: item.sha256,
6701                bytes: item.bytes,
6702            },
6703        );
6704    }
6705    pending
6706        .into_iter()
6707        .map(|(path, _)| {
6708            staged
6709                .remove(path)
6710                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6711        })
6712        .collect()
6713}
6714
6715#[cfg(not(any(unix, windows)))]
6716fn stage_v2_blobs(
6717    _cfg: &HubConfig,
6718    _brain: &str,
6719    _pointer: &V2PointerBody,
6720    _pending: Vec<(&String, &V2BaselineFile)>,
6721) -> LinkResult<Vec<V2StagedFile>> {
6722    Err(LinkError::UnsupportedPlatform {
6723        operation: "resumable v2 download staging",
6724    })
6725}
6726
6727const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6728const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6729const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6730
6731#[derive(Debug, Clone, Deserialize, Serialize)]
6732struct V2ConflictCoordinate {
6733    sha256: Option<String>,
6734    bytes: Option<u64>,
6735    file: Option<String>,
6736}
6737
6738#[derive(Debug, Clone, Deserialize, Serialize)]
6739struct V2ConflictFile {
6740    path: String,
6741    base: V2ConflictCoordinate,
6742    local: V2ConflictCoordinate,
6743    remote: V2ConflictCoordinate,
6744}
6745
6746#[derive(Debug, Clone, Deserialize, Serialize)]
6747struct V2ConflictPlan {
6748    v: u8,
6749    class: String,
6750    bundle: String,
6751    brain: String,
6752    origin: String,
6753    created_unix: u64,
6754    expires_unix: u64,
6755    base_seq: Option<u64>,
6756    base_commit: Option<String>,
6757    remote_seq: u64,
6758    remote_commit: Option<String>,
6759    remote_content_root: Option<String>,
6760    view_kind: String,
6761    view_revision: String,
6762    files: Vec<V2ConflictFile>,
6763}
6764
6765fn v2_take_remote_selection(
6766    files: &[V2ConflictFile],
6767    current: &std::collections::BTreeMap<String, V2BaselineFile>,
6768) -> LinkResult<(
6769    std::collections::BTreeMap<String, V2BaselineFile>,
6770    Vec<String>,
6771)> {
6772    let mut selected = std::collections::BTreeMap::new();
6773    let mut deleted = Vec::new();
6774    for file in files {
6775        match (&file.remote.sha256, file.remote.bytes) {
6776            (Some(sha256), Some(bytes)) => {
6777                let proven = current.get(&file.path).ok_or_else(|| {
6778                    invalid_feed("conflict remote coordinate disappeared from the exact head")
6779                })?;
6780                if proven.sha256 != *sha256 || proven.bytes != bytes {
6781                    return Err(invalid_feed(
6782                        "conflict remote coordinate differs from the exact head",
6783                    ));
6784                }
6785                if selected.insert(file.path.clone(), proven.clone()).is_some() {
6786                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6787                }
6788            }
6789            (None, None) => {
6790                if current.contains_key(&file.path) {
6791                    return Err(invalid_feed(
6792                        "conflict remote deletion differs from the exact head",
6793                    ));
6794                }
6795                deleted.push(file.path.clone());
6796            }
6797            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6798        }
6799    }
6800    Ok((selected, deleted))
6801}
6802
6803fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6804    PathBuf::from(".dbmd")
6805        .join("conflicts")
6806        .join(bundle)
6807        .join(suffix)
6808}
6809
6810fn read_historical_conflict_blob(
6811    cfg: &HubConfig,
6812    brain: &str,
6813    baseline: &V2SyncBaseline,
6814    path: &str,
6815    file: &V2BaselineFile,
6816) -> LinkResult<Option<Vec<u8>>> {
6817    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6818        return Ok(None);
6819    };
6820    if seq == 0 {
6821        return Ok(None);
6822    }
6823    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6824    let endpoint = format!(
6825        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6826        file.sha256
6827    );
6828    let history_http = hub_agent_with_timeout(cfg, std::time::Duration::from_secs(15))?;
6829    let raw = match request_raw_with_agent(
6830        cfg,
6831        &history_http,
6832        "GET",
6833        &endpoint,
6834        None,
6835        RawRequestOptions {
6836            auth: Auth::Required,
6837            max_response_bytes: file.bytes,
6838            request_id: None,
6839            retry_transport: false,
6840        },
6841    ) {
6842        Ok(raw) => raw,
6843        // The base body is diagnostic convenience only. The conflict plan
6844        // already carries its verified hash/length, plus exact local and
6845        // current-remote bodies. A slow retained-object read must not prevent
6846        // the operator from choosing keep-local or take-remote safely.
6847        Err(LinkError::Transport { .. }) => return Ok(None),
6848        Err(error) => return Err(error),
6849    };
6850    if raw.status == 404 || raw.status == 403 {
6851        return Ok(None);
6852    }
6853    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6854    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6855        return Err(invalid_feed(
6856            "v2 conflict base failed integrity verification",
6857        ));
6858    }
6859    Ok(Some(bytes))
6860}
6861
6862/// Persist a readable content conflict without modifying either side. The
6863/// plan is written last, so an interrupted bundle build is never actionable.
6864fn create_v2_conflict_bundle(
6865    cfg: &HubConfig,
6866    store: &Store,
6867    head: &V2VerifiedHead,
6868    baseline: Option<&V2SyncBaseline>,
6869    local: &std::collections::BTreeMap<String, (String, u64)>,
6870    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6871    paths: &[String],
6872) -> LinkResult<(String, Vec<String>)> {
6873    let conflicts_root = Path::new(".dbmd/conflicts");
6874    store.create_dir_all(conflicts_root)?;
6875    let completed = store
6876        .directory_names(conflicts_root)?
6877        .into_iter()
6878        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6879        .count();
6880    if completed >= V2_CONFLICT_BUNDLE_MAX {
6881        return Err(LinkError::InvalidPack {
6882            message: format!(
6883                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6884            ),
6885        });
6886    }
6887
6888    // A conflict bundle is diagnostic state, not a second whole-brain cache.
6889    // Materialize a deterministic prefix whose remote artifacts fit one
6890    // bounded window. Resolving it exposes the next prefix on the next sync.
6891    let mut selected_paths = Vec::new();
6892    let mut selected_remote_bytes = 0_u64;
6893    for path in paths {
6894        let bytes = remote.get(path).map_or(0, |file| file.bytes);
6895        if !selected_paths.is_empty()
6896            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6897        {
6898            break;
6899        }
6900        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6901        selected_paths.push(path.clone());
6902        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6903            break;
6904        }
6905    }
6906    if selected_paths.is_empty() {
6907        return Err(invalid_feed("content conflict set is empty"));
6908    }
6909    let bundle = crate::ulid::mint();
6910    let bundle_root = v2_conflict_relative(&bundle, "");
6911    store.create_dir_all(&bundle_root.join("files"))?;
6912    let pointer = head.pointer.as_ref();
6913    let remote_bytes = match pointer {
6914        Some(pointer) => download_v2_blobs(
6915            cfg,
6916            &head.brain_id,
6917            pointer,
6918            selected_paths
6919                .iter()
6920                .filter_map(|path| {
6921                    remote
6922                        .get(path)
6923                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6924                        .map(|file| (path, file))
6925                })
6926                .collect(),
6927        )?
6928        .into_iter()
6929        .collect::<std::collections::BTreeMap<_, _>>(),
6930        None => std::collections::BTreeMap::new(),
6931    };
6932
6933    let mut files = Vec::with_capacity(selected_paths.len());
6934    let mut historical_body_available = true;
6935    for (index, path) in selected_paths.iter().enumerate() {
6936        let base_file = baseline.and_then(|state| state.files.get(path));
6937        let base_bytes = match (historical_body_available, baseline, base_file) {
6938            (true, Some(state), Some(file)) => {
6939                let bytes = read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?;
6940                if bytes.is_none() {
6941                    // Historical content is optional diagnostic context. Once
6942                    // the retained-object lane proves unavailable, preserve
6943                    // the exact base coordinates but spend no more network
6944                    // time on this bundle.
6945                    historical_body_available = false;
6946                }
6947                bytes
6948            }
6949            _ => None,
6950        };
6951        let local_file = local.get(path);
6952        let remote_file = remote.get(path);
6953        let remote_content = remote_bytes.get(path);
6954        let prefix = format!("files/{index:04}");
6955        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6956        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6957        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6958        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6959            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6960        }
6961        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6962            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6963            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6964                return Err(LinkError::InvalidPack {
6965                    message: format!("local conflict path `{path}` changed while bundling"),
6966                });
6967            }
6968            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6969        }
6970        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6971            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6972        }
6973        files.push(V2ConflictFile {
6974            path: path.clone(),
6975            base: V2ConflictCoordinate {
6976                sha256: base_file.map(|file| file.sha256.clone()),
6977                bytes: base_file.map(|file| file.bytes),
6978                file: base_name,
6979            },
6980            local: V2ConflictCoordinate {
6981                sha256: local_file.map(|(sha256, _)| sha256.clone()),
6982                bytes: local_file.map(|(_, bytes)| *bytes),
6983                file: local_name,
6984            },
6985            remote: V2ConflictCoordinate {
6986                sha256: remote_file.map(|file| file.sha256.clone()),
6987                bytes: remote_file.map(|file| file.bytes),
6988                file: remote_name,
6989            },
6990        });
6991    }
6992    let now = SystemTime::now()
6993        .duration_since(UNIX_EPOCH)
6994        .unwrap_or_default()
6995        .as_secs();
6996    let plan = V2ConflictPlan {
6997        v: 2,
6998        class: "content_resolution_required".to_string(),
6999        bundle: bundle.clone(),
7000        brain: head.brain_id.clone(),
7001        origin: normalized_origin(&cfg.hub)?,
7002        created_unix: now,
7003        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
7004        base_seq: baseline.and_then(|state| state.head_seq),
7005        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
7006        remote_seq: pointer.map_or(0, |value| value.seq),
7007        remote_commit: pointer.map(|value| value.commit_hash.clone()),
7008        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
7009        view_kind: head.view_kind.clone(),
7010        view_revision: head.view_revision.clone(),
7011        files,
7012    };
7013    let mut bytes = serde_json::to_vec_pretty(&plan)
7014        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
7015    bytes.push(b'\n');
7016    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
7017    Ok((bundle, selected_paths))
7018}
7019
7020fn v2_sync_pull_with_resolution(
7021    cfg: &HubConfig,
7022    requested_brain: &str,
7023    expected_head: V2VerifiedHead,
7024    out: Option<&Path>,
7025    take_remote: Option<&std::collections::BTreeSet<String>>,
7026) -> LinkResult<V2PulledSnapshot> {
7027    let dest = out
7028        .map(Path::to_path_buf)
7029        .unwrap_or_else(|| PathBuf::from(requested_brain));
7030    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
7031    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
7032    let head = v2_verified_head(cfg, requested_brain)?
7033        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7034    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
7035        return Err(LinkError::RemoteAdvancedDuringSync);
7036    }
7037    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
7038    ensure_v2_view_compatible(&head, baseline.as_ref())?;
7039    let (remote, remote_assets) = match baseline
7040        .as_ref()
7041        .filter(|state| v2_baseline_matches_head(&head, state))
7042    {
7043        Some(state) => (state.files.clone(), state.assets.clone()),
7044        None => (
7045            files_for_v2_view(
7046                &head,
7047                v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7048            ),
7049            v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7050        ),
7051    };
7052    let local_store = Store::open_strict(&dest).ok();
7053    // A baseline proves this path is an established checkout. Never reinterpret
7054    // a damaged/edited store marker as an empty clone destination: doing so
7055    // would let the pull half erase the local evidence before the push half can
7056    // reject it (most importantly for the generated scoped-view DB.md).
7057    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
7058    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
7059    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
7060        return Err(LinkError::ScopedViewChanged);
7061    }
7062    if let Some(view) = local_view.as_mut() {
7063        remove_scoped_projection(&head, baseline.as_ref(), view)?;
7064    }
7065    let empty_local = std::collections::BTreeMap::new();
7066    let local = local_view
7067        .as_ref()
7068        .map_or(&empty_local, |view| &view.riding);
7069    let kept_home = |path: &str| {
7070        local_view
7071            .as_ref()
7072            .is_some_and(|view| view.policy.keeps_home(path))
7073    };
7074    let empty_base = std::collections::BTreeMap::new();
7075    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
7076    let empty_base_assets = std::collections::BTreeMap::new();
7077    let base_assets = baseline
7078        .as_ref()
7079        .map_or(&empty_base_assets, |state| &state.assets);
7080    let mut local_assets = local_store
7081        .as_ref()
7082        .map(v2_local_asset_records)
7083        .transpose()?
7084        .unwrap_or_default();
7085    let mut content_merge = merge_v2_pulled_records(
7086        base,
7087        &remote,
7088        local,
7089        |file, _| (file.sha256.clone(), file.bytes),
7090        |file, _| (file.sha256.clone(), file.bytes),
7091        kept_home,
7092    );
7093    if let Some(selected) = take_remote {
7094        for path in selected {
7095            if let Some(position) = content_merge
7096                .conflicts
7097                .iter()
7098                .position(|conflict| conflict == path)
7099            {
7100                content_merge.conflicts.remove(position);
7101                content_merge.accept_remote.insert(path.clone());
7102                match remote.get(path) {
7103                    Some(file) => {
7104                        content_merge
7105                            .records
7106                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
7107                    }
7108                    None => {
7109                        content_merge.records.remove(path);
7110                    }
7111                }
7112            } else if !content_merge.accept_remote.contains(path) {
7113                return Err(LinkError::InvalidPack {
7114                    message: format!(
7115                        "take-remote path `{path}` is no longer at its conflict coordinate"
7116                    ),
7117                });
7118            }
7119        }
7120    }
7121    if !content_merge.conflicts.is_empty() {
7122        let mut conflicts = content_merge.conflicts.clone();
7123        conflicts.truncate(100);
7124        if let Some(store) = local_store.as_ref() {
7125            let (bundle, paths) = create_v2_conflict_bundle(
7126                cfg,
7127                store,
7128                &head,
7129                baseline.as_ref(),
7130                local,
7131                &remote,
7132                &conflicts,
7133            )?;
7134            return Err(LinkError::ConflictBundle { bundle, paths });
7135        }
7136        return Err(LinkError::Conflict { paths: conflicts });
7137    }
7138    let asset_merge = merge_v2_pulled_records(
7139        base_assets,
7140        &remote_assets,
7141        &local_assets,
7142        v2_asset_record,
7143        v2_asset_record,
7144        |_| false,
7145    );
7146    if !asset_merge.conflicts.is_empty() {
7147        let mut conflicts = asset_merge.conflicts.clone();
7148        conflicts.truncate(100);
7149        return Err(LinkError::Conflict { paths: conflicts });
7150    }
7151    let pointer = head.pointer.as_ref();
7152    let cache_transaction = pointer.map_or_else(
7153        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
7154        |value| value.commit_hash.clone(),
7155    );
7156    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
7157    let mut changed = match pointer {
7158        Some(pointer) => stage_v2_blobs(
7159            cfg,
7160            &head.brain_id,
7161            pointer,
7162            remote
7163                .iter()
7164                .filter(|(path, file)| {
7165                    content_merge.accept_remote.contains(*path)
7166                        && local.get(*path).map(|value| value.0.as_str())
7167                            != Some(file.sha256.as_str())
7168                })
7169                .collect(),
7170        )?,
7171        None => Vec::new(),
7172    };
7173    let mut deleted = content_merge
7174        .accept_remote
7175        .iter()
7176        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
7177        .cloned()
7178        .collect::<Vec<_>>();
7179    if local_assets != asset_merge.records {
7180        if asset_merge.records.is_empty() {
7181            deleted.push("assets.jsonl".to_string());
7182        } else {
7183            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
7184            let sha256 = content_sha256(&bytes);
7185            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
7186            changed.push(V2StagedFile {
7187                path: "assets.jsonl".to_string(),
7188                source,
7189                sha256,
7190                bytes: bytes.len() as u64,
7191            });
7192        }
7193    }
7194    if let Some(pointer) = pointer {
7195        let mut pending_assets = Vec::new();
7196        for (path, asset) in &remote_assets {
7197            if asset.disposition != "hosted"
7198                || kept_home(path)
7199                || !asset_merge.accept_remote.contains(path)
7200            {
7201                continue;
7202            }
7203            let already_current = local_store.as_ref().is_some_and(|store| {
7204                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7205                    && store
7206                        .read_bounded(Path::new(path), asset.bytes)
7207                        .ok()
7208                        .is_some_and(|bytes| {
7209                            bytes.len() as u64 == asset.bytes
7210                                && content_sha256(&bytes) == asset.blob_sha256
7211                        })
7212            });
7213            if !already_current {
7214                pending_assets.push((path, asset));
7215            }
7216        }
7217        let mut window = Vec::new();
7218        let mut window_bytes = 0_u64;
7219        let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
7220                     window_bytes: &mut u64,
7221                     changed: &mut Vec<V2StagedFile>|
7222         -> LinkResult<()> {
7223            changed.extend(stage_v2_asset_download_window(
7224                cfg,
7225                &head.brain_id,
7226                pointer,
7227                &cache_dir,
7228                window,
7229            )?);
7230            window.clear();
7231            *window_bytes = 0;
7232            Ok(())
7233        };
7234        for item @ (_, asset) in pending_assets {
7235            if !window.is_empty()
7236                && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
7237                    || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
7238            {
7239                flush(&mut window, &mut window_bytes, &mut changed)?;
7240            }
7241            window.push(item);
7242            window_bytes = window_bytes.saturating_add(asset.bytes);
7243            if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
7244                flush(&mut window, &mut window_bytes, &mut changed)?;
7245            }
7246        }
7247        flush(&mut window, &mut window_bytes, &mut changed)?;
7248    }
7249    for (path, prior) in base_assets {
7250        if remote_assets.contains_key(path)
7251            || kept_home(path)
7252            || !asset_merge.accept_remote.contains(path)
7253        {
7254            continue;
7255        }
7256        let unchanged = local_store.as_ref().is_some_and(|store| {
7257            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7258                && store
7259                    .read_bounded(Path::new(path), prior.bytes)
7260                    .ok()
7261                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
7262        });
7263        if unchanged {
7264            deleted.push(path.clone());
7265        }
7266    }
7267    let extra_local = content_merge
7268        .records
7269        .keys()
7270        .filter(|path| !remote.contains_key(*path))
7271        .cloned()
7272        .collect::<Vec<_>>();
7273    if head.view_kind == "scoped" {
7274        for (path, bytes) in [
7275            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
7276            (
7277                ".dbmd/view.json".to_string(),
7278                scoped_view_metadata(&head, remote.len())?,
7279            ),
7280        ] {
7281            let sha256 = content_sha256(&bytes);
7282            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
7283            changed.push(V2StagedFile {
7284                path,
7285                source,
7286                sha256,
7287                bytes: bytes.len() as u64,
7288            });
7289        }
7290    }
7291    let install_changed = !changed.is_empty() || !deleted.is_empty();
7292    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
7293    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
7294        let installed_store =
7295            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
7296                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7297            })?;
7298        let installed_local = if install_changed {
7299            let hint = baseline.as_ref().and_then(|state| {
7300                state
7301                    .local_policy_digest
7302                    .as_deref()
7303                    .map(|digest| (digest, &state.scan_cache))
7304            });
7305            let mut scanned = v2_local_files_cached(&installed_store, hint)?;
7306            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
7307            scanned
7308        } else {
7309            local_view
7310                .take()
7311                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
7312        };
7313        if installed_local.riding != content_merge.records {
7314            return Err(LinkError::InvalidPack {
7315                message: "local content changed while installing the v2 pull".to_string(),
7316            });
7317        }
7318        let installed_assets = if install_changed {
7319            v2_local_asset_records(&installed_store)?
7320        } else {
7321            std::mem::take(&mut local_assets)
7322        };
7323        if installed_assets != asset_merge.records {
7324            return Err(LinkError::InvalidPack {
7325                message: "local assets changed while installing the v2 pull".to_string(),
7326            });
7327        }
7328        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
7329            installed_local.policy.keeps_home(path)
7330        })
7331            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
7332        let final_head = v2_verified_head(cfg, requested_brain)?
7333            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
7334        if !same_v2_head(&head, &final_head) {
7335            return Err(LinkError::RemoteAdvancedDuringSync);
7336        }
7337        accept_v2_head(cfg, &final_head)?;
7338        save_v2_baseline(
7339            cfg,
7340            &head.brain_id,
7341            &dest,
7342            &v2_baseline_from_head(
7343                cfg,
7344                &head,
7345                remote.clone(),
7346                remote_assets.clone(),
7347                Some(&installed_local),
7348                baseline
7349                    .as_ref()
7350                    .and_then(|current| current.checkout_id.as_deref()),
7351            )?,
7352        )?;
7353        complete_v2_pull(&dest)?;
7354        Ok((local_dirty, installed_local, installed_assets))
7355    })();
7356    let (local_dirty, installed_local, installed_assets) = match finalized {
7357        Ok(value) => value,
7358        Err(error) => {
7359            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
7360                return Err(LinkError::InvalidPack {
7361                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
7362                });
7363            }
7364            return Err(error);
7365        }
7366    };
7367    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
7368    let report = PullReport {
7369        brain: head.brain_id.clone(),
7370        slug: requested_brain.to_string(),
7371        head_seq: pointer.map_or(0, |value| value.seq),
7372        files: remote.len() + remote_assets.len(),
7373        dest: dest.to_string_lossy().into_owned(),
7374        extra_local,
7375        sync_status: if local_dirty {
7376            "local_dirty_after_install".to_string()
7377        } else {
7378            "synced".to_string()
7379        },
7380    };
7381    Ok(V2PulledSnapshot {
7382        report,
7383        head,
7384        files: remote,
7385        assets: remote_assets,
7386        local: installed_local,
7387        local_assets: installed_assets,
7388    })
7389}
7390
7391fn v2_sync_pull(
7392    cfg: &HubConfig,
7393    requested_brain: &str,
7394    head: V2VerifiedHead,
7395    out: Option<&Path>,
7396) -> LinkResult<PullReport> {
7397    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
7398}
7399
7400fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
7401    match remote {
7402        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
7403        None => json!({ "kind": "absent" }),
7404    }
7405}
7406
7407fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7408    match remote {
7409        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7410        None => json!({ "kind": "absent" }),
7411    }
7412}
7413
7414fn v2_content_withdrawal_operation(
7415    store: &Store,
7416    local_view: &V2LocalView,
7417    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7418    path: &str,
7419    reason: &str,
7420) -> LinkResult<Value> {
7421    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7422        || path == "DB.md"
7423    {
7424        return Err(LinkError::InvalidPack {
7425            message: format!("content withdrawal path `{path}` is not a record or source"),
7426        });
7427    }
7428    if !local_view.policy.keeps_home(path)
7429        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7430    {
7431        return Err(LinkError::InvalidPack {
7432            message: format!(
7433                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7434            ),
7435        });
7436    }
7437    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7438        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7439    })?;
7440    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
7441    Ok(json!({
7442        "op": "withdraw_from_hosting",
7443        "path": path,
7444        "expected": { "kind": "blob", "hash": current.sha256 },
7445        "reason": reason,
7446    }))
7447}
7448
7449fn v2_asset_withdrawal_operation(
7450    store: &Store,
7451    local_view: &V2LocalView,
7452    path: &str,
7453    local: &crate::AssetRecord,
7454    current: &V2BaselineAsset,
7455    reason: &str,
7456) -> LinkResult<Value> {
7457    if !local_view.policy.keeps_home(path)
7458        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7459    {
7460        return Err(LinkError::InvalidPack {
7461            message: format!(
7462                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7463            ),
7464        });
7465    }
7466    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7467    if current.disposition != "hosted"
7468        || current.blob_sha256 != local.sha256
7469        || current.bytes != local.bytes
7470        || current.media_type != local.media_type
7471    {
7472        return Err(LinkError::InvalidPack {
7473            message: format!(
7474                "asset withdrawal path `{path}` must preserve the currently hosted blob identity, byte count, and media type"
7475            ),
7476        });
7477    }
7478    Ok(json!({
7479        "op": "asset_withdraw",
7480        "path": path,
7481        "expected": v2_asset_expected(Some(current)),
7482        "asset": v2_asset_value(local, "withheld"),
7483        "reason": reason,
7484    }))
7485}
7486
7487// A source is immutable, but its exact bytes may move from an intake namespace
7488// to an archive namespace under the distinct `promote_source` capability. A
7489// plain filesystem move appears to sync as one absent old path plus one new
7490// path. Preserve that intent as a protocol rename only when the hash has one
7491// unambiguous source delete and one unambiguous absent-destination source put.
7492// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
7493fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7494    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7495    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7496    for (index, operation) in operations.iter().enumerate() {
7497        match operation.get("op").and_then(Value::as_str) {
7498            Some("delete") => {
7499                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7500                    continue;
7501                };
7502                let Some(hash) = operation
7503                    .get("expected")
7504                    .and_then(|value| value.get("hash"))
7505                    .and_then(Value::as_str)
7506                else {
7507                    continue;
7508                };
7509                if path.starts_with("sources/") {
7510                    deletes
7511                        .entry(hash.to_string())
7512                        .or_default()
7513                        .push((index, path.to_string()));
7514                }
7515            }
7516            Some("put") => {
7517                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7518                    continue;
7519                };
7520                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7521                    continue;
7522                };
7523                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7524                    continue;
7525                };
7526                let destination_absent = operation
7527                    .get("expected")
7528                    .and_then(|value| value.get("kind"))
7529                    .and_then(Value::as_str)
7530                    == Some("absent");
7531                if path.starts_with("sources/") && destination_absent {
7532                    puts.entry(hash.to_string()).or_default().push((
7533                        index,
7534                        path.to_string(),
7535                        bytes,
7536                    ));
7537                }
7538            }
7539            _ => {}
7540        }
7541    }
7542    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7543    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7544    for (hash, source) in deletes {
7545        let Some(destination) = puts.get(&hash) else {
7546            continue;
7547        };
7548        if source.len() != 1 || destination.len() != 1 {
7549            continue;
7550        }
7551        let (delete_index, from) = &source[0];
7552        let (put_index, to, bytes) = &destination[0];
7553        if from == to {
7554            continue;
7555        }
7556        rename_at.insert(
7557            *delete_index,
7558            json!({
7559                "op": "rename",
7560                "from": from,
7561                "to": to,
7562                "expected_from": { "kind": "blob", "hash": hash },
7563                "expected_to": { "kind": "absent" },
7564                "blob": hash,
7565                "bytes": bytes,
7566            }),
7567        );
7568        consumed_puts.insert(*put_index);
7569    }
7570    operations
7571        .into_iter()
7572        .enumerate()
7573        .filter_map(|(index, operation)| {
7574            if let Some(rename) = rename_at.remove(&index) {
7575                Some(rename)
7576            } else if consumed_puts.contains(&index) {
7577                None
7578            } else {
7579                Some(operation)
7580            }
7581        })
7582        .collect()
7583}
7584
7585fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7586    json!({
7587        "blob_sha256": record.sha256,
7588        "bytes": record.bytes,
7589        "media_type": record.media_type,
7590        "wrappers": record.wrappers,
7591        "required": record.required,
7592        "disposition": disposition,
7593    })
7594}
7595
7596/// Apply the exact operation subset generated by this client to an already
7597/// verified manifest. This avoids downloading the same 50k-path listing again
7598/// after an unrebased commit; a rebased response still re-reads the manifest.
7599fn apply_generated_v2_operations(
7600    operations: &[Value],
7601    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7602    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7603    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7604) -> LinkResult<bool> {
7605    let mut asset_changed = false;
7606    for operation in operations {
7607        match operation.get("op").and_then(Value::as_str) {
7608            Some("put") => {
7609                let path = operation
7610                    .get("path")
7611                    .and_then(Value::as_str)
7612                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7613                let sha256 = operation
7614                    .get("blob")
7615                    .and_then(Value::as_str)
7616                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7617                let bytes = operation
7618                    .get("bytes")
7619                    .and_then(Value::as_u64)
7620                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7621                candidate.insert(
7622                    path.to_string(),
7623                    V2BaselineFile {
7624                        sha256: sha256.to_string(),
7625                        bytes,
7626                        proof: None,
7627                    },
7628                );
7629            }
7630            Some("rename") => {
7631                let from = operation
7632                    .get("from")
7633                    .and_then(Value::as_str)
7634                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7635                let to = operation
7636                    .get("to")
7637                    .and_then(Value::as_str)
7638                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7639                let sha256 = operation
7640                    .get("blob")
7641                    .and_then(Value::as_str)
7642                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7643                let bytes = operation
7644                    .get("bytes")
7645                    .and_then(Value::as_u64)
7646                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7647                let expected_from = operation
7648                    .get("expected_from")
7649                    .and_then(|expected| expected.get("hash"))
7650                    .and_then(Value::as_str);
7651                let expected_to_absent = operation
7652                    .get("expected_to")
7653                    .and_then(|expected| expected.get("kind"))
7654                    .and_then(Value::as_str)
7655                    == Some("absent");
7656                if from == to
7657                    || !from.starts_with("sources/")
7658                    || !to.starts_with("sources/")
7659                    || expected_from != Some(sha256)
7660                    || !expected_to_absent
7661                    || candidate.contains_key(to)
7662                {
7663                    return Err(invalid_feed("generated v2 source rename is malformed"));
7664                }
7665                let source = candidate
7666                    .remove(from)
7667                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7668                if source.sha256 != sha256 || source.bytes != bytes {
7669                    return Err(invalid_feed(
7670                        "v2 rename source differs from its exact-byte claim",
7671                    ));
7672                }
7673                candidate.insert(
7674                    to.to_string(),
7675                    V2BaselineFile {
7676                        sha256: sha256.to_string(),
7677                        bytes,
7678                        proof: None,
7679                    },
7680                );
7681            }
7682            Some("delete" | "withdraw_from_hosting") => {
7683                let path = operation
7684                    .get("path")
7685                    .and_then(Value::as_str)
7686                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7687                candidate.remove(path);
7688            }
7689            Some("asset_delete") => {
7690                let path = operation
7691                    .get("path")
7692                    .and_then(Value::as_str)
7693                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7694                candidate_assets.remove(path);
7695                asset_changed = true;
7696            }
7697            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7698                let path = operation
7699                    .get("path")
7700                    .and_then(Value::as_str)
7701                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7702                let record = local_assets
7703                    .get(path)
7704                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7705                let disposition =
7706                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7707                        "withheld"
7708                    } else {
7709                        operation
7710                            .get("asset")
7711                            .and_then(|asset| asset.get("disposition"))
7712                            .and_then(Value::as_str)
7713                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7714                    };
7715                candidate_assets.insert(
7716                    path.to_string(),
7717                    V2BaselineAsset {
7718                        blob_sha256: record.sha256.clone(),
7719                        bytes: record.bytes,
7720                        media_type: record.media_type.clone(),
7721                        wrappers: record.wrappers.clone(),
7722                        required: record.required,
7723                        disposition: disposition.to_string(),
7724                        // The server assigns the signed leaf address. Callers
7725                        // that need an exact asset manifest re-read it below.
7726                        leaf_hash: String::new(),
7727                    },
7728                );
7729                asset_changed = true;
7730            }
7731            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7732        }
7733    }
7734    Ok(asset_changed)
7735}
7736
7737fn v2_riding_matches_remote(
7738    local: &std::collections::BTreeMap<String, (String, u64)>,
7739    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7740    keeps_home: impl Fn(&str) -> bool,
7741) -> bool {
7742    remote.iter().all(|(path, file)| {
7743        keeps_home(path)
7744            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7745    }) && local.iter().all(|(path, (hash, _))| {
7746        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7747    })
7748}
7749
7750fn v2_initial_content_conflicts(
7751    local: &std::collections::BTreeMap<String, (String, u64)>,
7752    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7753    resolving: bool,
7754) -> Vec<String> {
7755    if resolving {
7756        // `sync resolve` already pins the exact remote head/view and rechecks
7757        // every bundled local coordinate before it enters the push planner.
7758        // Treating this as an ordinary baseline-less push would merely mint an
7759        // identical conflict bundle forever. The operation loop below permits
7760        // only those explicit remote replacements plus safe local-only
7761        // additions, and verifies each selected local/remote pair again before
7762        // authoring the commit.
7763        return Vec::new();
7764    }
7765    remote
7766        .iter()
7767        .filter(|(path, file)| {
7768            local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7769        })
7770        .map(|(path, _)| path.clone())
7771        .collect()
7772}
7773
7774fn v2_resolution_allows_path(
7775    resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7776    path: &str,
7777    remote_present: bool,
7778) -> bool {
7779    resolution.is_none_or(|allowed| allowed.contains_key(path) || !remote_present)
7780}
7781
7782fn v2_resolution_allows_asset(
7783    resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7784    base: Option<&V2BaselineAsset>,
7785    remote: Option<&V2BaselineAsset>,
7786    local: Option<&crate::AssetRecord>,
7787) -> bool {
7788    let Some(allowed) = resolution else {
7789        return false;
7790    };
7791    let wrappers = base
7792        .into_iter()
7793        .flat_map(|asset| asset.wrappers.iter())
7794        .chain(remote.into_iter().flat_map(|asset| asset.wrappers.iter()))
7795        .chain(local.into_iter().flat_map(|asset| asset.wrappers.iter()))
7796        .collect::<BTreeSet<_>>();
7797    !wrappers.is_empty()
7798        && wrappers
7799            .iter()
7800            .all(|wrapper| allowed.contains_key(*wrapper))
7801}
7802
7803#[derive(Debug, Clone)]
7804struct V2ResolutionOverride {
7805    expected_remote: Option<String>,
7806    selected_local: Option<String>,
7807}
7808
7809#[derive(Debug, Clone)]
7810struct V2UploadSource {
7811    path: String,
7812    bytes: u64,
7813}
7814
7815struct V2SyncPushOptions<'a> {
7816    resume_local_policy: bool,
7817    bulk_confirmation: Option<&'a V2BulkConfirmation>,
7818    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7819    pulled: Option<V2PulledSnapshot>,
7820    withdrawal_paths: &'a [String],
7821    withdrawal_reason: Option<&'a str>,
7822    /// Permit one automatic DB.md-only commit before the remaining exact
7823    /// content/asset operations. The recursive second phase disables this so
7824    /// a concurrently edited contract fails for a clean retry instead of
7825    /// creating an unbounded commit chain.
7826    allow_contract_phase: bool,
7827}
7828
7829fn verify_v2_upload_source(
7830    store: &Store,
7831    path: &str,
7832    sha256: &str,
7833    expected_bytes: u64,
7834) -> LinkResult<()> {
7835    let file = store.open_regular(Path::new(path))?;
7836    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7837        return Err(LinkError::InvalidPack {
7838            message: format!("local path `{path}` changed during sync planning"),
7839        });
7840    }
7841    Ok(())
7842}
7843
7844/// One reserved object waiting to be staged, held until its whole batch can be
7845/// uploaded together.
7846struct V2PendingUpload<'a> {
7847    url: String,
7848    headers: Value,
7849    sha256: String,
7850    source: &'a V2UploadSource,
7851}
7852
7853/// How many objects a migration stages at once. Bounded so a first push does
7854/// not behave like a burst against the object store, while still being far
7855/// faster than one-at-a-time.
7856/// Workers staging one batch. With a shared pooled connection the cost of an
7857/// object is the PUT itself rather than a handshake, so a wider pool converts
7858/// directly into throughput on a migration.
7859const V2_UPLOAD_CONCURRENCY: usize = 16;
7860
7861/// Stage a reserved batch, uploading up to `V2_UPLOAD_CONCURRENCY` objects at a
7862/// time and verifying each against the store after it lands. The first failure
7863/// is returned and the remaining work is abandoned.
7864fn upload_v2_batch_concurrently(
7865    cfg: &HubConfig,
7866    store: &Store,
7867    pending: &[V2PendingUpload<'_>],
7868) -> LinkResult<()> {
7869    if pending.is_empty() {
7870        return Ok(());
7871    }
7872    let urls = pending
7873        .iter()
7874        .map(|task| task.url.as_str())
7875        .collect::<Vec<_>>();
7876    let shared = shared_staging_agent(cfg, &urls);
7877    if pending.len() == 1 {
7878        let task = &pending[0];
7879        put_presigned_source(
7880            cfg,
7881            &task.url,
7882            &task.headers,
7883            store,
7884            task.source,
7885            shared.as_ref(),
7886        )?;
7887        return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7888    }
7889    let next = std::sync::atomic::AtomicUsize::new(0);
7890    let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7891    let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7892    std::thread::scope(|scope| {
7893        for _ in 0..workers {
7894            scope.spawn(|| loop {
7895                if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7896                    return;
7897                }
7898                let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7899                let Some(task) = pending.get(index) else {
7900                    return;
7901                };
7902                let outcome = put_presigned_source(
7903                    cfg,
7904                    &task.url,
7905                    &task.headers,
7906                    store,
7907                    task.source,
7908                    shared.as_ref(),
7909                )
7910                .and_then(|()| {
7911                    verify_v2_upload_source(
7912                        store,
7913                        &task.source.path,
7914                        &task.sha256,
7915                        task.source.bytes,
7916                    )
7917                });
7918                if let Err(error) = outcome {
7919                    if let Ok(mut guard) = failure.lock() {
7920                        guard.get_or_insert(error);
7921                    }
7922                    return;
7923                }
7924            });
7925        }
7926    });
7927    match failure.into_inner() {
7928        Ok(Some(error)) => Err(error),
7929        Ok(None) => Ok(()),
7930        Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7931    }
7932}
7933
7934fn put_presigned_source(
7935    cfg: &HubConfig,
7936    raw: &str,
7937    headers: &Value,
7938    store: &Store,
7939    source: &V2UploadSource,
7940    shared: Option<&ureq::Agent>,
7941) -> LinkResult<()> {
7942    put_presigned_source_with_budget(
7943        cfg,
7944        raw,
7945        headers,
7946        store,
7947        source,
7948        shared,
7949        std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7950    )
7951}
7952
7953fn put_presigned_source_with_budget(
7954    cfg: &HubConfig,
7955    raw: &str,
7956    headers: &Value,
7957    store: &Store,
7958    source: &V2UploadSource,
7959    shared: Option<&ureq::Agent>,
7960    total_budget: std::time::Duration,
7961) -> LinkResult<()> {
7962    // The URL's own safety checks run either way; only the connection pool is
7963    // shared, so a batch never trades a check for a warm socket.
7964    let owned = match shared {
7965        Some(_) => {
7966            checked_presigned_url(cfg, raw)?;
7967            None
7968        }
7969        None => Some(presigned_agent(cfg, raw)?),
7970    };
7971    let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7972    let deadline = std::time::Instant::now()
7973        .checked_add(total_budget)
7974        .ok_or_else(upload_deadline_error)?;
7975    let mut attempt = 0;
7976    let result = loop {
7977        let file = store.open_regular(Path::new(&source.path))?;
7978        if file.metadata()?.len() != source.bytes {
7979            return Err(LinkError::InvalidPack {
7980                message: format!("local path `{}` changed before upload", source.path),
7981            });
7982        }
7983        // The reservation already states content-length, lowercased. The HTTP
7984        // client de-duplicates headers by exact name, not case, so setting
7985        // `Content-Length` here as well leaves both on the wire and an edge
7986        // rejects the request as malformed. Supply it only when absent.
7987        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7988        let mut has_content_length = false;
7989        if let Some(map) = headers.as_object() {
7990            for (name, value) in map {
7991                if let Some(value) = value.as_str() {
7992                    has_content_length |= name.eq_ignore_ascii_case("content-length");
7993                    req = req.set(name, value);
7994                }
7995            }
7996        }
7997        if !has_content_length {
7998            req = req.set("Content-Length", &source.bytes.to_string());
7999        }
8000        match req.send(file) {
8001            // Every transport failure is retryable here, not only the
8002            // pre-request kinds: staging an object is idempotent (fixed content
8003            // address, exact length and checksum, write-once precondition), the
8004            // file is reopened on each attempt, and a migration holds
8005            // connections long enough that a mid-request drop is ordinary.
8006            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
8007                attempt += 1;
8008            }
8009            // A migration stages tens of thousands of immutable objects, and an
8010            // edge or object store may refuse a single one under sustained
8011            // load. The upload is content-addressed and precondition-guarded,
8012            // so replaying it is safe; failing the whole push on one refusal is
8013            // not. 412 already means "someone else stored these exact bytes".
8014            Err(ureq::Error::Status(status, _))
8015                if status != 412
8016                    && is_retryable_upload_status(status)
8017                    && wait_for_upload_retry(deadline, attempt) =>
8018            {
8019                attempt += 1;
8020            }
8021            result => break result,
8022        }
8023    };
8024    match result {
8025        Ok(response) if (200..300).contains(&response.status()) => {
8026            drain_presigned_response(response);
8027            Ok(())
8028        }
8029        Ok(response) => {
8030            // The object store states its own reason (checksum mismatch,
8031            // precondition, expiry). Carrying a bounded excerpt turns an
8032            // unactionable refusal into one an operator can diagnose; the
8033            // coordinate is the store path, never file content.
8034            let status = response.status();
8035            let detail = response
8036                .into_string()
8037                .ok()
8038                .map(|body| body.chars().take(400).collect::<String>())
8039                .filter(|body| !body.trim().is_empty());
8040            Err(LinkError::Http {
8041                what: "v2 changed-byte upload",
8042                status,
8043                message: match detail {
8044                    Some(body) => format!(
8045                        "object store rejected the upload of `{}`: {}",
8046                        source.path,
8047                        body.replace('\n', " ")
8048                    ),
8049                    None => format!("object store rejected the upload of `{}`", source.path),
8050                },
8051                code: None,
8052                details: None,
8053            })
8054        }
8055        Err(error) => match error {
8056            ureq::Error::Status(412, _) => Ok(()),
8057            ureq::Error::Status(_, response) => {
8058                let status = response.status();
8059                let detail = response
8060                    .into_string()
8061                    .ok()
8062                    .map(|body| body.chars().take(400).collect::<String>())
8063                    .filter(|body| !body.trim().is_empty());
8064                Err(LinkError::Http {
8065                    what: "v2 changed-byte upload",
8066                    status,
8067                    message: match detail {
8068                        Some(body) => format!(
8069                            "object store rejected the upload of `{}`: {}",
8070                            source.path,
8071                            body.replace('\n', " ")
8072                        ),
8073                        None => {
8074                            format!("object store rejected the upload of `{}`", source.path)
8075                        }
8076                    },
8077                    code: None,
8078                    details: None,
8079                })
8080            }
8081            ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
8082        },
8083    }
8084}
8085
8086/// The request as it is hashed and signed. A staged push carries its operations
8087/// by address rather than inline, so the signed view restores them: the request
8088/// hash covers the change itself, never the transport that carried it.
8089fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
8090    if body.get("operations").is_some() {
8091        return body.clone();
8092    }
8093    let mut value = body.clone();
8094    if let Some(map) = value.as_object_mut() {
8095        map.remove("staged_change");
8096        map.insert("operations".to_string(), Value::Array(operations.to_vec()));
8097    }
8098    value
8099}
8100
8101/// Open one staging window, waiting out a busy hub rather than abandoning the
8102/// push. The call reserves capacity and mints URLs; it commits nothing, so
8103/// asking again after a refusal costs only the wait.
8104fn reserve_upload_window(
8105    cfg: &HubConfig,
8106    path: &str,
8107    body: &Value,
8108    what: &'static str,
8109) -> LinkResult<Value> {
8110    let mut attempt = 0;
8111    loop {
8112        let pause = |attempt: usize| {
8113            std::thread::sleep(std::time::Duration::from_millis(
8114                RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
8115            ));
8116        };
8117        match request(cfg, "POST", path, Some(body), Auth::Required) {
8118            // A migration opens dozens of windows over tens of minutes, so one
8119            // response that never arrives is ordinary rather than final. The
8120            // call reserves capacity and mints URLs; it commits nothing, so a
8121            // dropped or timed-out attempt is worth asking again.
8122            Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
8123                pause(attempt);
8124                attempt += 1;
8125            }
8126            Err(error) => return Err(error),
8127            Ok(response) => {
8128                if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
8129                    pause(attempt);
8130                    attempt += 1;
8131                    continue;
8132                }
8133                return ensure_ok(response, what);
8134            }
8135        }
8136    }
8137}
8138
8139/// The exact bytes of a staged change. The hub refuses a manifest carrying any
8140/// other key, so this is the whole contract: operations, and the blob
8141/// references they need, and nothing else.
8142fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
8143    let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
8144        .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
8145    if bytes.len() > MAX_STAGED_CHANGE_BYTES {
8146        return Err(LinkError::PushTooLarge {
8147            detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
8148        });
8149    }
8150    Ok(bytes)
8151}
8152
8153/// State one change's operations and blob references through the same
8154/// content-addressed lane its bytes already ride.
8155///
8156/// A first push of a real store names one operation and one blob reference per
8157/// file, which alone outgrows the bounded commit request. Staging that metadata
8158/// and addressing it by hash keeps the push a single atomic commit: the hub
8159/// verifies the address, splices the manifest in, and validates exactly what it
8160/// would have validated inline. The alternative — many partial commits — would
8161/// make a failed migration leave a half-populated brain readable.
8162fn stage_v2_change(
8163    cfg: &HubConfig,
8164    requested_brain: &str,
8165    operations: &[Value],
8166    blobs: Value,
8167) -> LinkResult<Value> {
8168    let bytes = v2_change_manifest(operations, blobs)?;
8169    let sha256 = content_sha256(&bytes);
8170    let reserved = reserve_upload_window(
8171        cfg,
8172        &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8173        &json!({
8174            "blobs": [{
8175                "sha256": sha256,
8176                "bytes": bytes.len(),
8177                "kind": "staged_change",
8178            }],
8179        }),
8180        "stage the v2 change",
8181    )?;
8182    let items = reserved
8183        .get("uploads")
8184        .and_then(Value::as_array)
8185        .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
8186    let [item] = items.as_slice() else {
8187        return Err(invalid_feed(
8188            "v2 change staging response changed the requested set",
8189        ));
8190    };
8191    let reservation_id = item
8192        .get("reservation_id")
8193        .and_then(Value::as_str)
8194        .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
8195    if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
8196        || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
8197        || !crate::ulid::is_ulid(reservation_id)
8198    {
8199        return Err(invalid_feed("v2 change staging item is inconsistent"));
8200    }
8201    match item.get("status").and_then(Value::as_str) {
8202        Some("upload") => put_presigned(
8203            cfg,
8204            item.get("url")
8205                .and_then(Value::as_str)
8206                .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
8207            item.get("headers").unwrap_or(&Value::Null),
8208            &bytes,
8209        )?,
8210        Some("already_present") => {}
8211        _ => return Err(invalid_feed("v2 change staging has an unknown status")),
8212    }
8213    Ok(json!({
8214        "sha256": sha256,
8215        "bytes": bytes.len(),
8216        "reservation_id": reservation_id,
8217    }))
8218}
8219
8220/// Move a commit request's operations and blob references off the wire body
8221/// when they no longer fit in it. Idempotent by construction: the mutation ID
8222/// is derived from the change, never from how the change was transported.
8223fn stage_oversized_v2_change(
8224    cfg: &HubConfig,
8225    requested_brain: &str,
8226    operations: &[Value],
8227    body: &mut Value,
8228) -> LinkResult<()> {
8229    if body.to_string().len() <= MAX_PUSH_BYTES {
8230        return Ok(());
8231    }
8232    let staged = stage_v2_change(
8233        cfg,
8234        requested_brain,
8235        operations,
8236        body.get("blobs")
8237            .cloned()
8238            .unwrap_or(Value::Array(Vec::new())),
8239    )?;
8240    let map = body
8241        .as_object_mut()
8242        .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
8243    map.remove("operations");
8244    map.remove("blobs");
8245    map.insert("staged_change".to_string(), staged);
8246    Ok(())
8247}
8248
8249fn v2_sync_push(
8250    cfg: &HubConfig,
8251    requested_brain: &str,
8252    store: &Store,
8253    head: V2VerifiedHead,
8254    options: V2SyncPushOptions<'_>,
8255) -> LinkResult<Value> {
8256    let V2SyncPushOptions {
8257        resume_local_policy,
8258        bulk_confirmation,
8259        resolution,
8260        pulled,
8261        withdrawal_paths,
8262        withdrawal_reason,
8263        allow_contract_phase,
8264    } = options;
8265    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
8266    let head = v2_verified_head(cfg, requested_brain)?
8267        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
8268    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
8269    ensure_v2_view_compatible(&head, baseline.as_ref())?;
8270    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
8271        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
8272            Some(snapshot) => (
8273                snapshot.files,
8274                snapshot.assets,
8275                Some(snapshot.local),
8276                Some(snapshot.local_assets),
8277            ),
8278            None => match baseline
8279                .as_ref()
8280                .filter(|state| v2_baseline_matches_head(&head, state))
8281            {
8282                Some(state) => (state.files.clone(), state.assets.clone(), None, None),
8283                None => (
8284                    files_for_v2_view(
8285                        &head,
8286                        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
8287                    ),
8288                    v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
8289                    None,
8290                    None,
8291                ),
8292            },
8293        };
8294    if head.view_kind == "scoped" && baseline.is_none() {
8295        return Err(LinkError::ScopedViewChanged);
8296    }
8297    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
8298    let local = &local_view.riding;
8299    let local_assets = match carried_local_assets {
8300        Some(assets) => assets,
8301        None => v2_local_asset_records(store)?,
8302    };
8303    if withdrawal_paths.len() > MAX_PUSH_FILES {
8304        return Err(LinkError::PushTooLarge {
8305            detail: "too many explicit withdrawal paths".to_string(),
8306        });
8307    }
8308    let withdrawal_reason = if withdrawal_paths.is_empty() {
8309        None
8310    } else {
8311        let reason = withdrawal_reason
8312            .map(str::trim)
8313            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
8314            .ok_or_else(|| LinkError::InvalidPack {
8315                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
8316            })?;
8317        Some(reason)
8318    };
8319    let mut withdrawals = withdrawal_paths
8320        .iter()
8321        .map(|path| {
8322            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
8323                path: error.to_string(),
8324            })
8325        })
8326        .collect::<LinkResult<Vec<_>>>()?;
8327    withdrawals.sort();
8328    withdrawals.dedup();
8329    if withdrawals.len() != withdrawal_paths.len() {
8330        return Err(LinkError::InvalidPack {
8331            message: "explicit withdrawal paths must be unique".to_string(),
8332        });
8333    }
8334    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
8335    let mut consumed_withdrawals = BTreeSet::new();
8336    if let Some(previous) = baseline.as_ref() {
8337        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
8338            && !resume_local_policy
8339        {
8340            let mut newly_eligible = previous
8341                .local_eligibility
8342                .iter()
8343                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
8344                .map(|(path, _)| path.clone())
8345                .collect::<Vec<_>>();
8346            if !newly_eligible.is_empty() {
8347                newly_eligible.truncate(100);
8348                return Err(LinkError::LocalPolicyTransition {
8349                    paths: newly_eligible,
8350                });
8351            }
8352        }
8353    }
8354    // An accepted commit can outlive the response path that would have saved
8355    // its local baseline. If the complete current riding tree and asset set
8356    // already match the independently verified new head, fast-forward the
8357    // private baseline by proof. This is not force and cannot hide a conflict:
8358    // any differing path falls through to the ordinary three-way algorithm.
8359    if baseline
8360        .as_ref()
8361        .is_some_and(|state| !v2_baseline_matches_head(&head, state))
8362        && resolution.is_none()
8363        && withdrawal_paths.is_empty()
8364        && v2_riding_matches_remote(local, &remote, |path| local_view.policy.keeps_home(path))
8365        && v2_asset_records_match_remote(&local_assets, &remote_assets)
8366    {
8367        let final_head = v2_verified_head(cfg, requested_brain)?
8368            .ok_or_else(|| invalid_feed("v2 head disappeared during baseline recovery"))?;
8369        if !same_v2_head(&head, &final_head) {
8370            return Err(LinkError::RemoteAdvancedDuringSync);
8371        }
8372        let mut final_local = v2_local_files_cached(
8373            store,
8374            Some((&local_view.policy.digest, &local_view.scan_cache)),
8375        )?;
8376        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8377        let final_assets = v2_local_asset_records(store)?;
8378        if final_local.riding != local_view.riding || final_assets != local_assets {
8379            return Err(LinkError::RemoteAdvancedDuringSync);
8380        }
8381        let checkout_pseudonym = v2_checkout_id(
8382            baseline
8383                .as_ref()
8384                .and_then(|current| current.checkout_id.as_deref()),
8385        )?;
8386        let next = v2_baseline_from_head(
8387            cfg,
8388            &head,
8389            remote,
8390            remote_assets,
8391            Some(&final_local),
8392            Some(&checkout_pseudonym),
8393        )?;
8394        let split_count = next.remote_copy_remains.len();
8395        accept_v2_head(cfg, &final_head)?;
8396        refresh_scoped_view_marker(store, &head, next.files.len())?;
8397        save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8398        return Ok(json!({
8399            "v": 2,
8400            "outcome": "no_change",
8401            "sync_status": "synced",
8402            "baseline_recovered": true,
8403            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8404            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8405            "local_policy": {
8406                "remote_copy_remains": split_count,
8407            },
8408        }));
8409    }
8410    let base = match baseline.as_ref() {
8411        Some(state) => &state.files,
8412        None if remote.is_empty() => &remote,
8413        None => {
8414            let mut conflicts = v2_initial_content_conflicts(local, &remote, resolution.is_some());
8415            if !conflicts.is_empty() {
8416                conflicts.truncate(100);
8417                let (bundle, paths) =
8418                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
8419                return Err(LinkError::ConflictBundle { bundle, paths });
8420            }
8421            &remote
8422        }
8423    };
8424    let all_paths = base
8425        .keys()
8426        .chain(remote.keys())
8427        .chain(local.keys())
8428        .cloned()
8429        .collect::<std::collections::BTreeSet<_>>();
8430    let mut conflicts = Vec::new();
8431    let mut operations = Vec::new();
8432    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
8433    for path in all_paths {
8434        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
8435        let remote_file = remote.get(&path);
8436        let remote_hash = remote_file.map(|file| file.sha256.as_str());
8437        let local_file = local.get(&path);
8438        let local_hash = local_file.map(|file| file.0.as_str());
8439        // A remote change that already equals the exact local intent is
8440        // converged, even when the private baseline is older. Emitting a
8441        // redundant put here can turn a completed DB.md contract phase back
8442        // into another mixed contract/content mutation.
8443        if local_hash == remote_hash {
8444            continue;
8445        }
8446        if local_hash == base_hash {
8447            continue;
8448        }
8449        if !v2_resolution_allows_path(resolution, &path, remote_file.is_some()) {
8450            continue;
8451        }
8452        if local_view.policy.keeps_home(&path) {
8453            // Kept-home is a local transfer exclusion, never an implicit
8454            // delete of the company's already-hosted coordinate.
8455            continue;
8456        }
8457        if remote_hash != base_hash && local_hash != remote_hash {
8458            let explicitly_resolved = resolution
8459                .and_then(|allowed| allowed.get(&path))
8460                .is_some_and(|selected| {
8461                    selected.expected_remote.as_deref() == remote_hash
8462                        && selected.selected_local.as_deref() == local_hash
8463                });
8464            if !explicitly_resolved {
8465                conflicts.push(path);
8466                continue;
8467            }
8468        }
8469        match local_file {
8470            Some((sha256, byte_count)) => {
8471                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
8472                operations.push(json!({
8473                    "op": "put",
8474                    "path": path,
8475                    "expected": v2_expected(remote_file),
8476                    "blob": sha256,
8477                    "bytes": byte_count,
8478                }));
8479                upload_sources
8480                    .entry(sha256.clone())
8481                    .or_insert_with(|| V2UploadSource {
8482                        path: path.clone(),
8483                        bytes: *byte_count,
8484                    });
8485            }
8486            None => {
8487                let Some(current) = remote_file else {
8488                    continue;
8489                };
8490                operations.push(json!({
8491                    "op": "delete",
8492                    "path": path,
8493                    "expected": { "kind": "blob", "hash": current.sha256 },
8494                }));
8495            }
8496        }
8497    }
8498    operations = infer_exact_source_promotions(operations);
8499    for path in &withdrawals {
8500        if local_assets.contains_key(path) {
8501            continue;
8502        }
8503        operations.push(v2_content_withdrawal_operation(
8504            store,
8505            &local_view,
8506            &remote,
8507            path,
8508            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8509        )?);
8510        consumed_withdrawals.insert(path.clone());
8511    }
8512    if !conflicts.is_empty() {
8513        conflicts.truncate(100);
8514        let (bundle, paths) = create_v2_conflict_bundle(
8515            cfg,
8516            store,
8517            &head,
8518            baseline.as_ref(),
8519            local,
8520            &remote,
8521            &conflicts,
8522        )?;
8523        return Err(LinkError::ConflictBundle { bundle, paths });
8524    }
8525    let base_assets = match baseline.as_ref() {
8526        Some(state) => &state.assets,
8527        None if remote_assets.is_empty() => &remote_assets,
8528        None => {
8529            let mismatched = remote_assets
8530                .keys()
8531                .chain(local_assets.keys())
8532                .collect::<BTreeSet<_>>()
8533                .into_iter()
8534                .filter(|path| {
8535                    remote_assets
8536                        .get(*path)
8537                        .map(|asset| v2_asset_record(asset, path))
8538                        .as_ref()
8539                        != local_assets.get(*path)
8540                })
8541                .collect::<Vec<_>>();
8542            let resolution_covers_all = !mismatched.is_empty()
8543                && mismatched.iter().all(|path| {
8544                    v2_resolution_allows_asset(
8545                        resolution,
8546                        None,
8547                        remote_assets.get(*path),
8548                        local_assets.get(*path),
8549                    )
8550                });
8551            if !mismatched.is_empty() && !resolution_covers_all {
8552                return Err(LinkError::Conflict {
8553                    paths: vec!["assets.jsonl".to_string()],
8554                });
8555            }
8556            &remote_assets
8557        }
8558    };
8559    let asset_paths = base_assets
8560        .keys()
8561        .chain(remote_assets.keys())
8562        .chain(local_assets.keys())
8563        .cloned()
8564        .collect::<std::collections::BTreeSet<_>>();
8565    let mut asset_policy_transitions = Vec::new();
8566    let mut asset_withdrawal_transitions = Vec::new();
8567    for path in asset_paths {
8568        let base_record = base_assets
8569            .get(&path)
8570            .map(|asset| v2_asset_record(asset, &path));
8571        let remote = remote_assets.get(&path);
8572        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8573        let local_record = local_assets.get(&path);
8574        if withdrawal_set.contains(&path) {
8575            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8576                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8577            })?;
8578            let current = remote.ok_or_else(|| LinkError::InvalidPack {
8579                message: format!(
8580                    "asset withdrawal path `{path}` has no readable hosted coordinate"
8581                ),
8582            })?;
8583            operations.push(v2_asset_withdrawal_operation(
8584                store,
8585                &local_view,
8586                &path,
8587                record,
8588                current,
8589                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8590            )?);
8591            consumed_withdrawals.insert(path.clone());
8592            continue;
8593        }
8594        let mut raw_present = false;
8595        let mut disposition = "withheld";
8596        let mut resumes_hosting = false;
8597        if let Some(record) = local_record {
8598            crate::linkmd_v2::normalize_path(&record.path)
8599                .map_err(|error| invalid_feed(error.to_string()))?;
8600            let kept_home = local_view.policy.keeps_home(&path);
8601            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8602            disposition = if kept_home || !raw_present {
8603                "withheld"
8604            } else {
8605                "hosted"
8606            };
8607            let inherits_withheld_absence = v2_asset_inherits_withheld_absence(
8608                base_assets.get(&path),
8609                base_record.as_ref(),
8610                local_record,
8611                raw_present,
8612            );
8613            if !raw_present && record.required && !kept_home && !inherits_withheld_absence {
8614                return Err(LinkError::InvalidPack {
8615                    message: format!("required asset {path} is missing"),
8616                });
8617            }
8618            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8619            if remote.is_some_and(|asset| asset.disposition == "hosted")
8620                && disposition == "withheld"
8621            {
8622                asset_withdrawal_transitions.push(path.clone());
8623                continue;
8624            }
8625        }
8626        if local_record == base_record.as_ref() && !resumes_hosting {
8627            continue;
8628        }
8629        if remote_record != base_record
8630            && local_record != remote_record.as_ref()
8631            && !v2_resolution_allows_asset(resolution, base_assets.get(&path), remote, local_record)
8632        {
8633            conflicts.push(path);
8634            continue;
8635        }
8636        let Some(record) = local_record else {
8637            if let Some(remote) = remote {
8638                operations.push(json!({
8639                    "op": "asset_delete",
8640                    "path": path,
8641                    "expected": v2_asset_expected(Some(remote)),
8642                }));
8643            }
8644            continue;
8645        };
8646        let raw = if raw_present {
8647            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8648            Some(())
8649        } else {
8650            None
8651        };
8652        let op = if resumes_hosting {
8653            if !resume_local_policy {
8654                asset_policy_transitions.push(path);
8655                continue;
8656            }
8657            "asset_resume"
8658        } else {
8659            "asset_put"
8660        };
8661        operations.push(json!({
8662            "op": op,
8663            "path": path,
8664            "expected": v2_asset_expected(remote),
8665            "asset": v2_asset_value(record, disposition),
8666        }));
8667        if disposition == "hosted" {
8668            raw.expect("hosted asset was checked present");
8669            upload_sources
8670                .entry(record.sha256.clone())
8671                .or_insert_with(|| V2UploadSource {
8672                    path: path.clone(),
8673                    bytes: record.bytes,
8674                });
8675        }
8676    }
8677    if consumed_withdrawals != withdrawal_set {
8678        let missing = withdrawal_set
8679            .difference(&consumed_withdrawals)
8680            .next()
8681            .expect("different withdrawal sets have one member");
8682        return Err(LinkError::InvalidPack {
8683            message: format!(
8684                "withdrawal path `{missing}` is not a readable content or asset coordinate"
8685            ),
8686        });
8687    }
8688    if !conflicts.is_empty() {
8689        conflicts.truncate(100);
8690        return Err(LinkError::Conflict { paths: conflicts });
8691    }
8692    if !asset_policy_transitions.is_empty() {
8693        asset_policy_transitions.truncate(100);
8694        return Err(LinkError::LocalPolicyTransition {
8695            paths: asset_policy_transitions,
8696        });
8697    }
8698    if !asset_withdrawal_transitions.is_empty() {
8699        asset_withdrawal_transitions.truncate(100);
8700        return Err(LinkError::AssetWithdrawalRequired {
8701            paths: asset_withdrawal_transitions,
8702        });
8703    }
8704    let contract_phase = operations.len() > 1
8705        && operations.iter().any(|operation| {
8706            operation.get("path").and_then(Value::as_str) == Some("DB.md")
8707                && !operation
8708                    .get("op")
8709                    .and_then(Value::as_str)
8710                    .is_some_and(|kind| kind.starts_with("asset_"))
8711        });
8712    if contract_phase {
8713        if !allow_contract_phase {
8714            return Err(LinkError::RemoteAdvancedDuringSync);
8715        }
8716        operations.retain(|operation| {
8717            operation.get("path").and_then(Value::as_str) == Some("DB.md")
8718                && !operation
8719                    .get("op")
8720                    .and_then(Value::as_str)
8721                    .is_some_and(|kind| kind.starts_with("asset_"))
8722        });
8723        let contract_blobs = operations
8724            .iter()
8725            .filter_map(|operation| operation.get("blob").and_then(Value::as_str))
8726            .collect::<BTreeSet<_>>();
8727        upload_sources.retain(|sha256, _| contract_blobs.contains(sha256.as_str()));
8728    }
8729    let touched_sources = operations
8730        .iter()
8731        .filter_map(
8732            |operation| match operation.get("op").and_then(Value::as_str) {
8733                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
8734                Some("rename") => operation.get("to").and_then(Value::as_str),
8735                _ => None,
8736            },
8737        )
8738        .collect::<std::collections::BTreeSet<_>>();
8739    let withheld_links = local_view
8740        .withheld_links
8741        .iter()
8742        .filter(|link| touched_sources.contains(link.source.as_str()))
8743        .collect::<Vec<_>>();
8744    let checkout_pseudonym = v2_checkout_id(
8745        baseline
8746            .as_ref()
8747            .and_then(|current| current.checkout_id.as_deref()),
8748    )?;
8749    let checkout_id = if withheld_links.is_empty() {
8750        None
8751    } else {
8752        Some(checkout_pseudonym.clone())
8753    };
8754    if operations.is_empty() {
8755        let final_head = v2_verified_head(cfg, requested_brain)?
8756            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8757        if !same_v2_head(&head, &final_head) {
8758            return Err(LinkError::RemoteAdvancedDuringSync);
8759        }
8760        let mut final_local = v2_local_files_cached(
8761            store,
8762            Some((&local_view.policy.digest, &local_view.scan_cache)),
8763        )?;
8764        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8765        let final_assets = v2_local_asset_records(store)?;
8766        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8767        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8768            final_local.policy.keeps_home(path)
8769        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8770        let next = v2_baseline_from_head(
8771            cfg,
8772            &head,
8773            remote,
8774            remote_assets,
8775            Some(&final_local),
8776            Some(&checkout_pseudonym),
8777        )?;
8778        let split_count = next.remote_copy_remains.len();
8779        accept_v2_head(cfg, &final_head)?;
8780        if !remote_ahead {
8781            refresh_scoped_view_marker(store, &head, next.files.len())?;
8782            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8783        }
8784        return Ok(json!({
8785            "v": 2,
8786            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8787            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8788            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8789            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8790            "local_policy": {
8791                "remote_copy_remains": split_count,
8792            },
8793        }));
8794    }
8795    let includes_contract = operations
8796        .iter()
8797        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8798    let rebase = if head.pointer.is_none() || includes_contract {
8799        "strict"
8800    } else {
8801        "disjoint"
8802    };
8803    let base_value = head.pointer.as_ref().map(|pointer| {
8804        json!({
8805            "seq": pointer.seq,
8806            "commit_hash": pointer.commit_hash,
8807            "content_root": pointer.content_root,
8808            "asset_root": pointer.asset_root,
8809        })
8810    });
8811    // The mutation coordinate is stable for the exact observed base and local
8812    // operation set. A lost response can therefore be retried without a
8813    // duplicate commit/proposal even across process restarts.
8814    let entropy = format!(
8815        "{}\0{}\0{}\0{}\0{}\0{}",
8816        normalized_origin(&cfg.hub)?,
8817        head.brain_id,
8818        serde_json::to_string(&base_value).unwrap_or_default(),
8819        serde_json::to_string(&operations).unwrap_or_default(),
8820        serde_json::to_string(&withheld_links).unwrap_or_default(),
8821        checkout_id.as_deref().unwrap_or("")
8822    );
8823    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8824    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8825        total
8826            .checked_add(source.bytes)
8827            .ok_or_else(|| LinkError::PushTooLarge {
8828                detail: "v2 changed-byte total overflow".to_string(),
8829            })
8830    })?;
8831    let inline = changed_bytes <= 3 * 1024 * 1024;
8832    let inline_blobs = if inline {
8833        upload_sources
8834            .iter()
8835            .map(|(sha256, source)| {
8836                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8837                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8838                    return Err(LinkError::InvalidPack {
8839                        message: format!("local path `{}` changed before upload", source.path),
8840                    });
8841                }
8842                Ok(json!({
8843                    "sha256": sha256,
8844                    "bytes": source.bytes,
8845                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8846                }))
8847            })
8848            .collect::<LinkResult<Vec<_>>>()?
8849    } else {
8850        Vec::new()
8851    };
8852    let mut body = json!({
8853        "mutation_id": mutation_id,
8854        "base": base_value,
8855        "rebase": rebase,
8856        "reason": "dbmd sync",
8857        "operations": operations,
8858        "blobs": inline_blobs,
8859    });
8860    if !withheld_links.is_empty() {
8861        body["withheld_links"] = serde_json::to_value(&withheld_links)
8862            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8863        body["checkout_id"] =
8864            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8865    }
8866    if let Some(confirmation) = bulk_confirmation {
8867        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8868            return Err(LinkError::InvalidPack {
8869                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8870                    .to_string(),
8871            });
8872        }
8873        // A preview is bound to the exact observed head. Confirmation can
8874        // therefore never use disjoint rebase semantics, even when the normal
8875        // small-mutation path could.
8876        body["rebase"] = Value::String("strict".to_string());
8877        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8878        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8879    }
8880    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8881        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8882        for operation in &operations {
8883            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8884                return Err(invalid_feed("v2 upload operation has no kind"));
8885            };
8886            let hash = match kind {
8887                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8888                "asset_put" | "asset_resume" => operation
8889                    .get("asset")
8890                    .and_then(|asset| asset.get("blob_sha256"))
8891                    .and_then(Value::as_str),
8892                _ => None,
8893            };
8894            let Some(hash) = hash else { continue };
8895            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8896            if kind == "rename" {
8897                for field in ["from", "to"] {
8898                    coordinates.insert(
8899                        operation
8900                            .get(field)
8901                            .and_then(Value::as_str)
8902                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8903                            .to_string(),
8904                    );
8905                }
8906            } else {
8907                let path = operation
8908                    .get("path")
8909                    .and_then(Value::as_str)
8910                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8911                coordinates.insert(if kind.starts_with("asset_") {
8912                    format!("assets/{path}")
8913                } else {
8914                    path.to_string()
8915                });
8916            }
8917        }
8918        let declarations = upload_sources
8919            .iter()
8920            .map(|(sha256, source)| {
8921                json!({
8922                    "sha256": sha256,
8923                    "bytes": source.bytes,
8924                    "coordinates": coordinates_by_hash
8925                        .get(sha256)
8926                        .into_iter()
8927                        .flatten()
8928                        .collect::<Vec<_>>(),
8929                })
8930            })
8931            .collect::<Vec<_>>();
8932        let mut references = Vec::with_capacity(upload_sources.len());
8933        let mut seen = std::collections::BTreeSet::new();
8934        let mut reserved_count = 0usize;
8935        let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8936        // A reservation's presigned URL is short-lived, so each batch's bytes
8937        // ride before the next batch is reserved. Reserving every blob first
8938        // and uploading afterwards expires the earliest URLs on a real store.
8939        for batch in batch_upload_declarations(declarations) {
8940            let batch_len = batch.len();
8941            let reserved = reserve_upload_window(
8942                cfg,
8943                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8944                &json!({ "blobs": batch }),
8945                "prepare v2 changed-byte uploads",
8946            )?;
8947            let items = reserved
8948                .get("uploads")
8949                .and_then(Value::as_array)
8950                .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8951            if items.len() != batch_len {
8952                return Err(invalid_feed(
8953                    "v2 upload reservation response changed the requested set",
8954                ));
8955            }
8956            reserved_count += items.len();
8957            for item in items {
8958                let sha256 = item
8959                    .get("sha256")
8960                    .and_then(Value::as_str)
8961                    .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8962                let source = upload_sources
8963                    .get(sha256)
8964                    .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8965                let declared_bytes = item
8966                    .get("bytes")
8967                    .and_then(Value::as_u64)
8968                    .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8969                let reservation_id = item
8970                    .get("reservation_id")
8971                    .and_then(Value::as_str)
8972                    .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8973                let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8974                    invalid_feed("v2 upload reservation has no coordinate binding")
8975                })?;
8976                let returned_coordinates = item
8977                    .get("coordinates")
8978                    .and_then(Value::as_array)
8979                    .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8980                if declared_bytes != source.bytes
8981                    || !crate::ulid::is_ulid(reservation_id)
8982                    || !seen.insert(sha256.to_string())
8983                    || returned_coordinates.len() != expected_coordinates.len()
8984                    || returned_coordinates
8985                        .iter()
8986                        .zip(expected_coordinates)
8987                        .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8988                {
8989                    return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8990                }
8991                match item.get("status").and_then(Value::as_str) {
8992                    Some("upload") => {
8993                        let url = item
8994                            .get("url")
8995                            .and_then(Value::as_str)
8996                            .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8997                        pending_uploads.push(V2PendingUpload {
8998                            url: url.to_string(),
8999                            headers: item.get("headers").cloned().unwrap_or(Value::Null),
9000                            sha256: sha256.to_string(),
9001                            source,
9002                        });
9003                    }
9004                    Some("already_present") => {}
9005                    _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
9006                }
9007                references.push(json!({
9008                    "sha256": sha256,
9009                    "bytes": source.bytes,
9010                    "reservation_id": reservation_id,
9011                }));
9012            }
9013            // Stage this batch's bytes concurrently. Sequential PUTs move a real
9014            // store at a few objects a second, which both takes hours and pushes
9015            // the batch past its reservation's upload window; the object store
9016            // is content-addressed and precondition-guarded, so parallel writes
9017            // stay safe.
9018            upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
9019            pending_uploads.clear();
9020        }
9021        if reserved_count != upload_sources.len() {
9022            return Err(invalid_feed(
9023                "v2 upload reservation response changed the requested set",
9024            ));
9025        }
9026        body["blobs"] = Value::Array(references);
9027    }
9028    stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
9029    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
9030    let mut candidate_hub_signer: Option<String> = None;
9031    let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
9032    let bulk_preview_required = !(200..300).contains(&response.status)
9033        && response.body.as_ref().is_some_and(|value| {
9034            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
9035                || value
9036                    .get("details")
9037                    .and_then(|details| details.get("code"))
9038                    .and_then(Value::as_str)
9039                    == Some("bulk_preview_required")
9040        });
9041    if bulk_preview_required && bulk_confirmation.is_none() {
9042        body["rebase"] = Value::String("strict".to_string());
9043        body["preview_only"] = Value::Bool(true);
9044        let preview = ensure_ok(
9045            request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
9046            "v2 bulk preview",
9047        )?;
9048        let preview_code = preview.get("code").and_then(Value::as_str);
9049        let required = preview.get("required").and_then(Value::as_bool);
9050        if preview.get("v").and_then(Value::as_u64) != Some(2)
9051            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
9052            || !matches!(
9053                preview_code,
9054                Some("bulk_preview_created" | "bulk_preview_not_required")
9055            )
9056            || required.is_none()
9057        {
9058            return Err(invalid_feed(
9059                "bulk preview response is not bound to the requested mutation",
9060            ));
9061        }
9062        if required == Some(true) {
9063            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
9064            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
9065            if preview_code != Some("bulk_preview_created")
9066                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
9067                || preview_digest.is_none_or(|value| !is_sha256(value))
9068                || preview.get("expires_at").and_then(Value::as_str).is_none()
9069                || !preview.get("impact").is_some_and(Value::is_object)
9070            {
9071                return Err(invalid_feed("bulk preview receipt is malformed"));
9072            }
9073            return Err(LinkError::BulkPreviewRequired { preview });
9074        }
9075        if preview_code != Some("bulk_preview_not_required") {
9076            return Err(invalid_feed("bulk preview requirement is inconsistent"));
9077        }
9078        // The rolling activity window can expire between the refusal and
9079        // preview. Retry once, still pinned strictly to the observed head.
9080        body.as_object_mut()
9081            .expect("v2 commit request is an object")
9082            .remove("preview_only");
9083        response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
9084    }
9085    let mut result = ensure_ok(response, "v2 sync push")?;
9086    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
9087        if let Some(object) = result.as_object_mut() {
9088            object.insert(
9089                "sync_status".to_string(),
9090                Value::String("proposal_pending".to_string()),
9091            );
9092        }
9093        return Ok(result);
9094    }
9095    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
9096        let request_id = result
9097            .get("request_id")
9098            .and_then(Value::as_str)
9099            .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
9100            .to_string();
9101        let challenge = result
9102            .get("signing_challenge")
9103            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
9104        let mut expected_candidate = remote.clone();
9105        let mut expected_candidate_assets = remote_assets.clone();
9106        apply_generated_v2_operations(
9107            &operations,
9108            &local_assets,
9109            &mut expected_candidate,
9110            &mut expected_candidate_assets,
9111        )?;
9112        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
9113            cfg,
9114            &head,
9115            &expected_candidate,
9116            &expected_candidate_assets,
9117            &mutation_id,
9118            &v2_signed_request_view(&body, &operations),
9119            challenge,
9120        )?;
9121        body["signing_challenge_id"] = Value::String(challenge_id);
9122        body["signature_base64url"] = Value::String(signature);
9123        candidate_hub_signer = Some(actor_signer);
9124        result = ensure_ok(
9125            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
9126            "v2 self-custody commit",
9127        )?;
9128    }
9129    let refreshed = v2_verified_head(cfg, requested_brain)?
9130        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
9131    if candidate_hub_signer
9132        .as_ref()
9133        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
9134    {
9135        return Err(invalid_feed(
9136            "self-custody actor signer differs from the committed hub pointer signer",
9137        ));
9138    }
9139    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
9140    if refreshed
9141        .pointer
9142        .as_ref()
9143        .map(|pointer| pointer.commit_hash.as_str())
9144        != accepted_hash
9145    {
9146        return Err(LinkError::RemoteAdvancedDuringSync);
9147    }
9148    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
9149    let rebased = result
9150        .get("rebased")
9151        .and_then(Value::as_bool)
9152        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
9153    let (refreshed_files, refreshed_assets) = if rebased {
9154        (
9155            files_for_v2_view(
9156                &refreshed,
9157                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
9158            ),
9159            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
9160        )
9161    } else {
9162        let asset_changed = apply_generated_v2_operations(
9163            &operations,
9164            &local_assets,
9165            &mut remote,
9166            &mut remote_assets,
9167        )?;
9168        let assets = if asset_changed {
9169            // Asset leaf hashes are server-generated signed coordinates; only
9170            // asset-changing commits need this usually tiny post-commit read.
9171            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
9172        } else {
9173            remote_assets
9174        };
9175        (remote, assets)
9176    };
9177    let mut final_local = v2_local_files_cached(
9178        store,
9179        Some((&local_view.policy.digest, &local_view.scan_cache)),
9180    )?;
9181    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
9182    let final_assets = v2_local_asset_records(store)?;
9183    let local_dirty = final_local.riding != local_view.riding
9184        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
9185            final_local.policy.keeps_home(path)
9186        })
9187        || final_assets != local_assets
9188        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
9189    // A required DB.md-only first phase can hand its already verified remote
9190    // manifests and local stat/hash view directly to the second phase. This is
9191    // an in-memory optimization only; v2_sync_push re-verifies the hosted head
9192    // under the operation lock, upload sources are re-opened before use, and
9193    // the ordinary final scan still detects concurrent local edits.
9194    let contract_handoff = contract_phase.then(|| V2PulledSnapshot {
9195        report: PullReport {
9196            brain: refreshed.brain_id.clone(),
9197            slug: requested_brain.to_string(),
9198            head_seq: refreshed.pointer.as_ref().map_or(0, |pointer| pointer.seq),
9199            files: refreshed_files.len(),
9200            dest: store.root.to_string_lossy().into_owned(),
9201            extra_local: Vec::new(),
9202            sync_status: "contract_phase".to_string(),
9203        },
9204        head: refreshed.clone(),
9205        files: refreshed_files.clone(),
9206        assets: refreshed_assets.clone(),
9207        local: final_local.clone(),
9208        local_assets: final_assets.clone(),
9209    });
9210    let next = v2_baseline_from_head(
9211        cfg,
9212        &refreshed,
9213        refreshed_files,
9214        refreshed_assets,
9215        Some(&final_local),
9216        Some(&checkout_pseudonym),
9217    )?;
9218    let split_count = next.remote_copy_remains.len();
9219    accept_v2_head(cfg, &refreshed)?;
9220    if !local_dirty {
9221        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
9222    }
9223    // The verified accepted head remains the correct three-way base when a
9224    // local writer races the final scan. v2_baseline_from_head kept cache
9225    // entries only for exact local/remote matches, so saving here neither
9226    // hides those edits nor trusts their stale hashes.
9227    save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
9228    if let Some(object) = result.as_object_mut() {
9229        object.insert(
9230            "local_policy".to_string(),
9231            json!({ "remote_copy_remains": split_count }),
9232        );
9233        object.insert(
9234            "sync_status".to_string(),
9235            Value::String(if local_dirty {
9236                "remote_committed_local_dirty".to_string()
9237            } else {
9238                "synced".to_string()
9239            }),
9240        );
9241    }
9242    if contract_phase && result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9243        let contract_receipt = result;
9244        // The hub deliberately requires DB.md authority changes to be a sole,
9245        // strict-head mutation. Release the checkout-wide operation lock, then
9246        // recompute the remaining delta from the independently verified new
9247        // head. No local byte or permission conclusion is carried across.
9248        drop(_operation_lock);
9249        let mut remaining = v2_sync_push(
9250            cfg,
9251            requested_brain,
9252            store,
9253            refreshed,
9254            V2SyncPushOptions {
9255                resume_local_policy,
9256                bulk_confirmation,
9257                resolution,
9258                pulled: contract_handoff,
9259                withdrawal_paths,
9260                withdrawal_reason,
9261                allow_contract_phase: false,
9262            },
9263        )?;
9264        if let Some(object) = remaining.as_object_mut() {
9265            object.insert("contract_phase".to_string(), contract_receipt);
9266        }
9267        return Ok(remaining);
9268    }
9269    if contract_phase {
9270        if let Some(object) = result.as_object_mut() {
9271            object.insert("contract_phase_pending".to_string(), Value::Bool(true));
9272        }
9273    }
9274    Ok(result)
9275}
9276
9277/// Negotiate v2 and send only local changes. A v1 hub retains the existing
9278/// whole-snapshot behavior until its brain advertises the new profile.
9279pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
9280    sync_push_incremental_with_policy(cfg, brain, store, false)
9281}
9282
9283/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
9284/// path that became eligible because local policy was weakened or removed.
9285pub fn sync_push_incremental_with_policy(
9286    cfg: &HubConfig,
9287    brain: &str,
9288    store: &Store,
9289    resume_local_policy: bool,
9290) -> LinkResult<Value> {
9291    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
9292}
9293
9294/// Incremental push with both local-policy adoption and an optional exact
9295/// permissioned bulk-preview confirmation.
9296pub fn sync_push_incremental_with_options(
9297    cfg: &HubConfig,
9298    brain: &str,
9299    store: &Store,
9300    resume_local_policy: bool,
9301    bulk_confirmation: Option<&V2BulkConfirmation>,
9302) -> LinkResult<Value> {
9303    sync_push_incremental_with_controls(
9304        cfg,
9305        brain,
9306        store,
9307        resume_local_policy,
9308        bulk_confirmation,
9309        &[],
9310        None,
9311    )
9312}
9313
9314/// Incremental push with explicit permissioned current-host withdrawal.
9315pub fn sync_push_incremental_with_controls(
9316    cfg: &HubConfig,
9317    brain: &str,
9318    store: &Store,
9319    resume_local_policy: bool,
9320    bulk_confirmation: Option<&V2BulkConfirmation>,
9321    withdrawal_paths: &[String],
9322    withdrawal_reason: Option<&str>,
9323) -> LinkResult<Value> {
9324    require_safe_ref(brain)?;
9325    if let Some(head) = v2_verified_head(cfg, brain)? {
9326        return v2_sync_push(
9327            cfg,
9328            brain,
9329            store,
9330            head,
9331            V2SyncPushOptions {
9332                resume_local_policy,
9333                bulk_confirmation,
9334                resolution: None,
9335                pulled: None,
9336                withdrawal_paths,
9337                withdrawal_reason,
9338                allow_contract_phase: true,
9339            },
9340        );
9341    }
9342    if !withdrawal_paths.is_empty() {
9343        return Err(LinkError::InvalidPack {
9344            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
9345        });
9346    }
9347    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
9348}
9349
9350/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
9351/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
9352/// a damaged `DB.md` makes strict store-open fail.
9353pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
9354    require_safe_ref(brain)?;
9355    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
9356}
9357
9358/// Rebind one verified incremental baseline after its checkout directory was
9359/// moved without changing the checkout bytes. Baselines deliberately live in
9360/// private trust state and are path-bound, so callers performing an atomic
9361/// staged publish must cross this explicit boundary instead of silently
9362/// turning the moved checkout into a baseline-less clone.
9363pub fn relocate_v2_sync_baseline(
9364    cfg: &HubConfig,
9365    brain: &str,
9366    from: &Path,
9367    to: &Path,
9368) -> LinkResult<Value> {
9369    require_hardened_filesystem("verified link.md v2 baseline relocation")?;
9370    require_safe_ref(brain)?;
9371    if !crate::ulid::is_ulid(brain) {
9372        return Err(invalid_feed(
9373            "v2 baseline relocation requires the canonical brain id",
9374        ));
9375    }
9376    let from_absolute = if from.is_absolute() {
9377        from.to_path_buf()
9378    } else {
9379        std::env::current_dir()?.join(from)
9380    };
9381    let to_absolute = if to.is_absolute() {
9382        to.to_path_buf()
9383    } else {
9384        std::env::current_dir()?.join(to)
9385    };
9386    let source_name = v2_baseline_name(cfg, brain, &from_absolute)?;
9387    let target_name = v2_baseline_name(cfg, brain, &to_absolute)?;
9388    if source_name == target_name {
9389        return Err(invalid_feed(
9390            "v2 baseline relocation source and destination are the same checkout",
9391        ));
9392    }
9393    match std::fs::symlink_metadata(&from_absolute) {
9394        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
9395        Err(error) => return Err(error.into()),
9396        Ok(_) => {
9397            return Err(LinkError::InvalidPack {
9398                message: "the old checkout still exists; move it before relocating its baseline"
9399                    .to_string(),
9400            })
9401        }
9402    }
9403    let store = Store::open_strict(&to_absolute).map_err(|error| LinkError::InvalidPack {
9404        message: format!("relocated checkout is not a valid db.md store: {error}"),
9405    })?;
9406    let _operation_lock = lock_v2_sync_operation(cfg, brain)?;
9407
9408    #[cfg(any(unix, windows))]
9409    {
9410        let directory = open_trust_dir(cfg)?;
9411        let mut lock_names = [source_name.as_str(), target_name.as_str()];
9412        lock_names.sort();
9413        let _locks = lock_names
9414            .iter()
9415            .map(|name| lock_trust_name(&directory, name))
9416            .collect::<LinkResult<Vec<_>>>()?;
9417        let source = load_v2_baseline_in(cfg, brain, &directory, &source_name)?;
9418        let target = load_v2_baseline_in(cfg, brain, &directory, &target_name)?;
9419        let (baseline, already_relocated) = match (source, target) {
9420            (Some(source), None) => (source, false),
9421            (None, Some(target)) => (target, true),
9422            (Some(_), Some(_)) => {
9423                return Err(LinkError::InvalidPack {
9424                    message: "both old and new checkout paths already have private sync baselines"
9425                        .to_string(),
9426                })
9427            }
9428            (None, None) => {
9429                return Err(LinkError::InvalidPack {
9430                    message: "the old checkout has no verified incremental baseline to relocate"
9431                        .to_string(),
9432                })
9433            }
9434        };
9435
9436        let mut local = v2_local_files(&store)?;
9437        if baseline.view_kind.as_deref() == Some("scoped") {
9438            let expected = baseline
9439                .projection_sha256
9440                .as_deref()
9441                .ok_or_else(|| invalid_feed("scoped baseline has no projection hash"))?;
9442            if local.riding.get("DB.md").map(|value| value.0.as_str()) != Some(expected) {
9443                return Err(LinkError::ScopedProjectionModified);
9444            }
9445            local.riding.remove("DB.md");
9446        }
9447        if baseline.local_policy_digest.as_deref() != Some(local.policy.digest.as_str())
9448            || !v2_riding_matches_remote(&local.riding, &baseline.files, |path| {
9449                local.policy.keeps_home(path)
9450            })
9451            || !v2_asset_records_match_remote(&v2_local_asset_records(&store)?, &baseline.assets)
9452        {
9453            return Err(LinkError::InvalidPack {
9454                message: "the moved checkout no longer matches its verified incremental baseline"
9455                    .to_string(),
9456            });
9457        }
9458        if !already_relocated {
9459            crate::fsx::rename_beneath(
9460                &directory,
9461                Path::new(&source_name),
9462                Path::new(&target_name),
9463            )?;
9464            directory.sync_all()?;
9465        }
9466        Ok(json!({
9467            "v": 2,
9468            "class": "checkout_baseline_relocated",
9469            "brain": baseline.brain,
9470            "from": from_absolute,
9471            "to": to_absolute,
9472            "headSeq": baseline.head_seq.unwrap_or(0),
9473            "commitHash": baseline.commit_hash,
9474            "moved": !already_relocated,
9475        }))
9476    }
9477
9478    #[cfg(not(any(unix, windows)))]
9479    Err(LinkError::UnsupportedPlatform {
9480        operation: "verified link.md v2 baseline relocation",
9481    })
9482}
9483
9484#[cfg(windows)]
9485fn legacy_sync_push_incremental(
9486    _cfg: &HubConfig,
9487    _brain: &str,
9488    _store: &Store,
9489    _resume_local_policy: bool,
9490    _bulk_confirmation: Option<&V2BulkConfirmation>,
9491) -> LinkResult<Value> {
9492    Err(LinkError::UnsupportedPlatform {
9493        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
9494    })
9495}
9496
9497#[cfg(not(windows))]
9498fn legacy_sync_push_incremental(
9499    cfg: &HubConfig,
9500    brain: &str,
9501    store: &Store,
9502    resume_local_policy: bool,
9503    bulk_confirmation: Option<&V2BulkConfirmation>,
9504) -> LinkResult<Value> {
9505    if resume_local_policy || bulk_confirmation.is_some() {
9506        return Err(LinkError::InvalidPack {
9507            message: "v2 sync options require a link.md v2 brain".to_string(),
9508        });
9509    }
9510    let files = collect_push_files(store)?;
9511    sync_push(cfg, brain, &files)
9512}
9513
9514/// One explicit resolution for a previously preserved private conflict bundle.
9515#[derive(Debug, Clone)]
9516pub enum V2ConflictChoice {
9517    KeepLocal,
9518    TakeRemote,
9519    From(PathBuf),
9520}
9521
9522fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
9523    if !crate::ulid::is_ulid(bundle) {
9524        return Err(LinkError::InvalidPack {
9525            message: "conflict bundle must be a lowercase ULID".to_string(),
9526        });
9527    }
9528    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
9529    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
9530        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
9531    if plan.v != 2
9532        || plan.class != "content_resolution_required"
9533        || plan.bundle != bundle
9534        || !crate::ulid::is_ulid(&plan.brain)
9535        || plan.files.is_empty()
9536        || plan.files.len() > 100
9537        || plan.files.iter().any(|file| {
9538            crate::linkmd_v2::normalize_path(&file.path).is_err()
9539                || [&file.base, &file.local, &file.remote]
9540                    .into_iter()
9541                    .any(|coordinate| {
9542                        coordinate
9543                            .sha256
9544                            .as_deref()
9545                            .is_some_and(|hash| !is_sha256(hash))
9546                            || coordinate.file.as_deref().is_some_and(|name| {
9547                                name.starts_with('/')
9548                                    || name
9549                                        .split('/')
9550                                        .any(|part| part.is_empty() || part == "." || part == "..")
9551                            })
9552                    })
9553        })
9554    {
9555        return Err(invalid_feed("private conflict plan failed validation"));
9556    }
9557    Ok(plan)
9558}
9559
9560/// Inspect or prune private conflict-control state. Ordinary pruning removes
9561/// only expired completed bundles and interrupted bundles with no `plan.json`;
9562/// corrupt completed plans fail closed. `all` is an explicit local discard of
9563/// every well-addressed bundle and never changes hosted brain data.
9564pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
9565    require_hardened_filesystem("private conflict maintenance")?;
9566    if all && !prune {
9567        return Err(LinkError::InvalidPack {
9568            message: "discarding all conflict bundles requires prune=true".to_string(),
9569        });
9570    }
9571    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9572        message: format!("conflict checkout is not a valid db.md store: {error}"),
9573    })?;
9574    let _transaction = store.transaction()?;
9575    let root = Path::new(".dbmd/conflicts");
9576    let names = match store.directory_names(root) {
9577        Ok(names) => names,
9578        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
9579        Err(error) => return Err(error.into()),
9580    };
9581    let now = SystemTime::now()
9582        .duration_since(UNIX_EPOCH)
9583        .unwrap_or_default()
9584        .as_secs();
9585    let mut bundles = Vec::new();
9586    let mut pruned = 0_u64;
9587    for name in names {
9588        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
9589            continue;
9590        };
9591        let plan_path = v2_conflict_relative(bundle, "plan.json");
9592        let plan_exists = store.regular_file_exists(&plan_path)?;
9593        let expired = if plan_exists {
9594            match load_v2_conflict_plan(&store, bundle) {
9595                Ok(plan) => plan.expires_unix < now,
9596                Err(error) if all => {
9597                    let _ = error;
9598                    true
9599                }
9600                Err(error) => return Err(error),
9601            }
9602        } else {
9603            true
9604        };
9605        if prune && (all || expired) {
9606            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9607            pruned += 1;
9608            continue;
9609        }
9610        bundles.push(json!({
9611            "bundle": bundle,
9612            "complete": plan_exists,
9613            "expired": expired,
9614        }));
9615    }
9616    Ok(json!({
9617        "v": 2,
9618        "class": "private_conflict_state",
9619        "bundles": bundles.len(),
9620        "pruned": pruned,
9621        "items": bundles,
9622    }))
9623}
9624
9625/// Resolve one exact private conflict plan. Drift never lowers a precondition:
9626/// the remote head/view and every original local coordinate are rechecked
9627/// before either an explicit local install or a fresh normal commit.
9628pub fn sync_resolve_conflict(
9629    cfg: &HubConfig,
9630    checkout: &Path,
9631    bundle: &str,
9632    choice: V2ConflictChoice,
9633    bulk_confirmation: Option<&V2BulkConfirmation>,
9634) -> LinkResult<Value> {
9635    require_hardened_filesystem("conflict resolution")?;
9636    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9637        message: format!("conflict checkout is not a valid db.md store: {error}"),
9638    })?;
9639    let plan = load_v2_conflict_plan(&store, bundle)?;
9640    if plan.origin != normalized_origin(&cfg.hub)? {
9641        return Err(invalid_feed(
9642            "conflict bundle belongs to another hub origin",
9643        ));
9644    }
9645    let now = SystemTime::now()
9646        .duration_since(UNIX_EPOCH)
9647        .unwrap_or_default()
9648        .as_secs();
9649    if now > plan.expires_unix {
9650        return Err(LinkError::InvalidPack {
9651            message: "conflict bundle expired; rerun sync to obtain current coordinates"
9652                .to_string(),
9653        });
9654    }
9655    let head = v2_verified_head(cfg, &plan.brain)?
9656        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
9657    let pointer = head.pointer.as_ref();
9658    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
9659        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
9660        || pointer.and_then(|value| value.content_root.as_deref())
9661            != plan.remote_content_root.as_deref()
9662        || head.view_kind != plan.view_kind
9663        || head.view_revision != plan.view_revision
9664    {
9665        return Err(LinkError::RemoteAdvancedDuringSync);
9666    }
9667
9668    // A resolution never overwrites a post-bundle local edit.
9669    for file in &plan.files {
9670        let actual = match store.regular_file_exists(Path::new(&file.path))? {
9671            true => Some(content_sha256(&store.read_bounded(
9672                Path::new(&file.path),
9673                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
9674            )?)),
9675            false => None,
9676        };
9677        if actual.as_deref() != file.local.sha256.as_deref() {
9678            return Err(LinkError::InvalidPack {
9679                message: format!(
9680                    "local conflict path `{}` changed after the bundle was created",
9681                    file.path
9682                ),
9683            });
9684        }
9685    }
9686
9687    let from_source = match &choice {
9688        V2ConflictChoice::From(source) => Some(source.clone()),
9689        _ => None,
9690    };
9691    let result = match choice {
9692        V2ConflictChoice::TakeRemote => {
9693            if bulk_confirmation.is_some() {
9694                return Err(LinkError::InvalidPack {
9695                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
9696                });
9697            }
9698            // Re-read the exact current manifest so every selected remote byte
9699            // retains its signed inclusion proof. The private bundle is useful
9700            // for inspection, but is never promoted into live state as trust.
9701            let current_remote =
9702                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
9703            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
9704            let selected = plan
9705                .files
9706                .iter()
9707                .map(|file| file.path.clone())
9708                .collect::<std::collections::BTreeSet<_>>();
9709            serde_json::to_value(
9710                v2_sync_pull_with_resolution(
9711                    cfg,
9712                    &plan.brain,
9713                    head,
9714                    Some(checkout),
9715                    Some(&selected),
9716                )?
9717                .report,
9718            )
9719            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
9720        }
9721        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
9722            if let Some(source) = from_source.as_ref() {
9723                if plan.files.len() != 1 {
9724                    return Err(LinkError::InvalidPack {
9725                        message: "--from requires a bundle with exactly one conflict".to_string(),
9726                    });
9727                }
9728                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
9729                if std::str::from_utf8(&candidate).is_err() {
9730                    return Err(LinkError::NotUtf8 {
9731                        path: source.display().to_string(),
9732                    });
9733                }
9734                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
9735            }
9736            let refreshed_store =
9737                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9738                    message: format!("resolved checkout is not a valid db.md store: {error}"),
9739                })?;
9740            let mut overrides = std::collections::BTreeMap::new();
9741            for file in &plan.files {
9742                let selected_local = match refreshed_store
9743                    .regular_file_exists(Path::new(&file.path))?
9744                {
9745                    true => Some(content_sha256(
9746                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
9747                    )),
9748                    false => None,
9749                };
9750                overrides.insert(
9751                    file.path.clone(),
9752                    V2ResolutionOverride {
9753                        expected_remote: file.remote.sha256.clone(),
9754                        selected_local,
9755                    },
9756                );
9757            }
9758            v2_sync_push(
9759                cfg,
9760                &plan.brain,
9761                &refreshed_store,
9762                head,
9763                V2SyncPushOptions {
9764                    resume_local_policy: true,
9765                    bulk_confirmation,
9766                    resolution: Some(&overrides),
9767                    pulled: None,
9768                    withdrawal_paths: &[],
9769                    withdrawal_reason: None,
9770                    allow_contract_phase: true,
9771                },
9772            )?
9773        }
9774    };
9775
9776    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9777        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9778            message: format!("resolved checkout is not a valid db.md store: {error}"),
9779        })?;
9780        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9781    }
9782    Ok(json!({
9783        "v": 2,
9784        "class": "auto_converged",
9785        "bundle": bundle,
9786        "receipt": result,
9787    }))
9788}
9789
9790/// Converge one established permissioned-v2 checkout in both directions.
9791///
9792/// The pull half first performs the ordinary three-way conflict check and
9793/// crash-safe install against the accepted baseline. The push half then opens
9794/// that exact installed checkout, takes its local transaction guard, computes
9795/// a fresh three-way delta, and submits it with mandatory preconditions. A
9796/// remote movement between halves is therefore a normal rebase/conflict input,
9797/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
9798/// its whole-pack replacement semantics are never presented as granular
9799/// convergence.
9800pub fn sync_converge(
9801    cfg: &HubConfig,
9802    brain: &str,
9803    checkout: &Path,
9804    resume_local_policy: bool,
9805) -> LinkResult<Value> {
9806    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9807}
9808
9809/// Bidirectional convergence with optional exact bulk-preview confirmation.
9810pub fn sync_converge_with_options(
9811    cfg: &HubConfig,
9812    brain: &str,
9813    checkout: &Path,
9814    resume_local_policy: bool,
9815    bulk_confirmation: Option<&V2BulkConfirmation>,
9816) -> LinkResult<Value> {
9817    sync_converge_with_controls(
9818        cfg,
9819        brain,
9820        checkout,
9821        resume_local_policy,
9822        bulk_confirmation,
9823        &[],
9824        None,
9825    )
9826}
9827
9828/// Bidirectional convergence with optional exact withdrawal intents.
9829pub fn sync_converge_with_controls(
9830    cfg: &HubConfig,
9831    brain: &str,
9832    checkout: &Path,
9833    resume_local_policy: bool,
9834    bulk_confirmation: Option<&V2BulkConfirmation>,
9835    withdrawal_paths: &[String],
9836    withdrawal_reason: Option<&str>,
9837) -> LinkResult<Value> {
9838    require_hardened_filesystem("bidirectional sync")?;
9839    require_safe_ref(brain)?;
9840    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9841        message:
9842            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9843                .to_string(),
9844    })?;
9845    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9846    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9847        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9848    })?;
9849    let _transaction = store.transaction()?;
9850    let pulled_report = pulled.report.clone();
9851    let pulled_head = pulled.head.clone();
9852    let mut result = v2_sync_push(
9853        cfg,
9854        brain,
9855        &store,
9856        pulled_head,
9857        V2SyncPushOptions {
9858            resume_local_policy,
9859            bulk_confirmation,
9860            resolution: None,
9861            pulled: Some(pulled),
9862            withdrawal_paths,
9863            withdrawal_reason,
9864            allow_contract_phase: true,
9865        },
9866    )?;
9867    if let Some(object) = result.as_object_mut() {
9868        object.insert("pulled_files".to_string(), json!(pulled_report.files));
9869        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9870        object.insert(
9871            "mode".to_string(),
9872            Value::String("bidirectional".to_string()),
9873        );
9874    }
9875    Ok(result)
9876}
9877
9878/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
9879/// exported path is safety-gated before it touches disk; files are written
9880/// atomically; nothing local is ever deleted (locals the export lacks are
9881/// *reported* in `extra_local` instead). Returns the report; rebuilding the
9882/// local index catalog afterwards is the caller's (cheap, optional) step.
9883pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9884    require_hardened_filesystem("sync pull")?;
9885    require_safe_ref(brain)?;
9886    if let Some(head) = v2_verified_head(cfg, brain)? {
9887        return v2_sync_pull(cfg, brain, head, out);
9888    }
9889    legacy_sync_pull(cfg, brain, out)
9890}
9891
9892#[cfg(windows)]
9893fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9894    Err(LinkError::UnsupportedPlatform {
9895        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9896    })
9897}
9898
9899#[cfg(not(windows))]
9900fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9901    let remote = verified_remote_head(cfg, brain, false)?;
9902    if !remote.head.verified {
9903        return Err(invalid_feed(
9904            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9905        ));
9906    }
9907    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9908    let path = format!(
9909        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9910        remote.head.seq
9911    );
9912    let body = ensure_ok(
9913        request(cfg, "GET", &path, None, Auth::Required)?,
9914        "sync pull",
9915    )?;
9916    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9917        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9918    {
9919        return Err(invalid_feed(
9920            "export response is not bound to the verified snapshot token",
9921        ));
9922    }
9923
9924    let remote_slug = body
9925        .get("slug")
9926        .and_then(Value::as_str)
9927        .filter(|slug| is_safe_slug(slug));
9928    let slug = remote_slug
9929        .or_else(|| is_safe_slug(brain).then_some(brain))
9930        .unwrap_or("brain")
9931        .to_string();
9932    let brain_id = body
9933        .get("brain")
9934        .and_then(Value::as_str)
9935        .unwrap_or(&remote.head.brain)
9936        .to_string();
9937    if brain_id != remote.head.brain {
9938        return Err(invalid_feed(
9939            "export response names a different brain than the verified head",
9940        ));
9941    }
9942    let head_seq = remote.head.seq;
9943    let dest: PathBuf = match out {
9944        Some(p) => p.to_path_buf(),
9945        None => PathBuf::from(&slug),
9946    };
9947    let entries = if head_seq == 0 {
9948        let files = body
9949            .get("files")
9950            .and_then(Value::as_array)
9951            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
9952        if !files.is_empty() || body.get("url").is_some() {
9953            return Err(invalid_feed(
9954                "empty signed feed cannot authorize non-empty exported content",
9955            ));
9956        }
9957        Vec::new()
9958    } else {
9959        let signed_head = remote
9960            .head_entry
9961            .as_ref()
9962            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
9963        let expected = &signed_head.entry.pack_sha256;
9964        if !is_sha256(expected) {
9965            return Err(invalid_feed(
9966                "signed head carries an invalid snapshot pack digest",
9967            ));
9968        }
9969        if let Some(url) = body.get("url").and_then(Value::as_str) {
9970            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
9971                return Err(invalid_feed(
9972                    "export pack digest does not match the signed head entry",
9973                ));
9974            }
9975            let bytes = get_presigned(cfg, url)?;
9976            let actual = format!("{:x}", Sha256::digest(&bytes));
9977            if actual != *expected {
9978                return Err(LinkError::InvalidPack {
9979                    message: "downloaded pack does not match the signed snapshot digest"
9980                        .to_string(),
9981                });
9982            }
9983            let entries = parse_store_pack(bytes)?;
9984            if signed_head.entry.kind == "push" {
9985                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9986            }
9987            entries
9988        } else {
9989            if signed_head.entry.kind != "push" {
9990                return Err(invalid_feed(
9991                    "delta snapshots must export the exact signed pack",
9992                ));
9993            }
9994            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
9995                invalid_feed("verified snapshot export carried neither a pack nor files")
9996            })?;
9997            let mut entries = Vec::with_capacity(files.len());
9998            for file in files {
9999                let path = file
10000                    .get("path")
10001                    .and_then(Value::as_str)
10002                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
10003                let content = file
10004                    .get("content")
10005                    .and_then(Value::as_str)
10006                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
10007                entries.push((path.to_string(), content.as_bytes().to_vec()));
10008            }
10009            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
10010            entries
10011        }
10012    };
10013
10014    // Gate the complete manifest before the first filesystem mutation.
10015    let mut seen = std::collections::HashSet::new();
10016    for (path, _) in &entries {
10017        if !safe_store_rel_path(path) {
10018            return Err(LinkError::UnsafePath { path: path.clone() });
10019        }
10020        if !seen.insert(path) {
10021            return Err(LinkError::InvalidPack {
10022                message: format!("duplicate path `{path}`"),
10023            });
10024        }
10025    }
10026    // Compute divergence against the still-live tree. The staged clone keeps
10027    // these extra locals byte-for-byte while overlaying the signed snapshot.
10028    let pulled: std::collections::BTreeSet<&str> =
10029        entries.iter().map(|(p, _)| p.as_str()).collect();
10030    let mut extra_local = Vec::new();
10031    if let Ok(store) = Store::open(&dest) {
10032        if let Ok(walked) = store.walk() {
10033            for rel in walked {
10034                let rel_str = rel.to_string_lossy().replace('\\', "/");
10035                if !pulled.contains(rel_str.as_str()) {
10036                    extra_local.push(rel_str);
10037                }
10038            }
10039        }
10040    }
10041    #[cfg(unix)]
10042    install_pulled_snapshot(&dest, &entries)?;
10043
10044    Ok(PullReport {
10045        brain: brain_id,
10046        slug,
10047        head_seq,
10048        files: entries.len(),
10049        dest: dest.to_string_lossy().into_owned(),
10050        extra_local,
10051        sync_status: "synced".to_string(),
10052    })
10053}
10054
10055#[cfg(unix)]
10056fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
10057    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
10058        path: display.to_string(),
10059    })
10060}
10061
10062#[cfg(unix)]
10063fn open_dir_at(
10064    parent: std::os::fd::RawFd,
10065    name: &std::ffi::CStr,
10066    display: &str,
10067) -> LinkResult<std::fs::File> {
10068    use std::os::fd::FromRawFd as _;
10069    let fd = unsafe {
10070        libc::openat(
10071            parent,
10072            name.as_ptr(),
10073            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10074        )
10075    };
10076    if fd < 0 {
10077        return Err(LinkError::UnsafePath {
10078            path: display.to_string(),
10079        });
10080    }
10081    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
10082}
10083
10084/// Open (and, where absent, create) a directory path without following a
10085/// symlink in any component. The returned directory capability remains bound
10086/// to the opened inode even if an attacker renames or replaces an ancestor.
10087#[cfg(unix)]
10088fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
10089    use std::os::fd::AsRawFd as _;
10090
10091    // macOS exposes a few system-owned compatibility symlinks at the root.
10092    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
10093    // caller-controlled ancestor.
10094    #[cfg(target_os = "macos")]
10095    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
10096        .into_iter()
10097        .find_map(|(alias, real)| {
10098            path.strip_prefix(alias)
10099                .ok()
10100                .map(|rest| Path::new(real).join(rest))
10101        })
10102        .unwrap_or_else(|| path.to_path_buf());
10103    #[cfg(not(target_os = "macos"))]
10104    let normalized = path.to_path_buf();
10105
10106    let start = if normalized.is_absolute() {
10107        std::fs::File::open("/")?
10108    } else {
10109        std::fs::File::open(".")?
10110    };
10111    let mut directory = start;
10112    for component in normalized.components() {
10113        use std::path::Component;
10114        let name = match component {
10115            Component::RootDir | Component::CurDir => continue,
10116            Component::Normal(name) => name,
10117            Component::ParentDir | Component::Prefix(_) => {
10118                return Err(LinkError::UnsafePath {
10119                    path: path.display().to_string(),
10120                });
10121            }
10122        };
10123        use std::os::unix::ffi::OsStrExt as _;
10124        let name = c_name(name.as_bytes(), &path.display().to_string())?;
10125        if create {
10126            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10127            if made != 0 {
10128                let error = std::io::Error::last_os_error();
10129                if error.raw_os_error() != Some(libc::EEXIST) {
10130                    return Err(error.into());
10131                }
10132            }
10133        }
10134        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
10135    }
10136    Ok(directory)
10137}
10138
10139#[cfg(unix)]
10140fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
10141    open_dir_path_nofollow(path, true)
10142}
10143
10144#[cfg(unix)]
10145fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
10146    open_dir_path_nofollow(path, false)
10147}
10148
10149#[cfg(unix)]
10150fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
10151    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
10152    let result =
10153        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
10154    if result == 0 {
10155        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
10156    }
10157    let error = std::io::Error::last_os_error();
10158    if error.kind() == std::io::ErrorKind::NotFound {
10159        Ok(None)
10160    } else {
10161        Err(error.into())
10162    }
10163}
10164
10165#[cfg(unix)]
10166fn create_dir_exclusive_at(
10167    parent: std::os::fd::RawFd,
10168    name: &std::ffi::CStr,
10169    display: &str,
10170) -> LinkResult<std::fs::File> {
10171    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
10172    if made != 0 {
10173        return Err(LinkError::UnsafePath {
10174            path: display.to_string(),
10175        });
10176    }
10177    open_dir_at(parent, name, display)
10178}
10179
10180#[cfg(unix)]
10181fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
10182    use std::os::fd::AsRawFd as _;
10183
10184    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
10185    if duplicate < 0 {
10186        return Err(std::io::Error::last_os_error().into());
10187    }
10188    let stream = unsafe { libc::fdopendir(duplicate) };
10189    if stream.is_null() {
10190        let error = std::io::Error::last_os_error();
10191        unsafe {
10192            libc::close(duplicate);
10193        }
10194        return Err(error.into());
10195    }
10196    let mut names = Vec::new();
10197    loop {
10198        let entry = unsafe { libc::readdir(stream) };
10199        if entry.is_null() {
10200            break;
10201        }
10202        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
10203        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
10204            names.push(raw.to_owned());
10205        }
10206    }
10207    if unsafe { libc::closedir(stream) } != 0 {
10208        return Err(std::io::Error::last_os_error().into());
10209    }
10210    Ok(names)
10211}
10212
10213/// Remove an entry tree relative to a held directory capability. Symlinks are
10214/// unlinked, never traversed, even when an old mirror contains hostile names.
10215#[cfg(unix)]
10216fn remove_tree_at(
10217    parent: std::os::fd::RawFd,
10218    name: &std::ffi::CStr,
10219    display: &str,
10220) -> LinkResult<()> {
10221    use std::os::fd::AsRawFd as _;
10222
10223    match entry_is_dir_at(parent, name)? {
10224        None => return Ok(()),
10225        Some(false) => {
10226            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
10227                return Err(std::io::Error::last_os_error().into());
10228            }
10229        }
10230        Some(true) => {
10231            let directory = open_dir_at(parent, name, display)?;
10232            for child in directory_entry_names(&directory)? {
10233                let child_display =
10234                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
10235                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
10236            }
10237            drop(directory);
10238            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
10239                return Err(std::io::Error::last_os_error().into());
10240            }
10241        }
10242    }
10243    Ok(())
10244}
10245
10246/// Clone a live destination into a private sibling stage without following
10247/// any symlink. Regular files are streamed between held descriptors; symlinks
10248/// are reproduced as links, never opened. Special files are refused.
10249#[cfg(unix)]
10250fn clone_tree_contents(
10251    source: &std::fs::File,
10252    destination: &std::fs::File,
10253    display: &str,
10254) -> LinkResult<()> {
10255    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10256
10257    for name in directory_entry_names(source)? {
10258        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
10259        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
10260        if unsafe {
10261            libc::fstatat(
10262                source.as_raw_fd(),
10263                name.as_ptr(),
10264                &mut stat,
10265                libc::AT_SYMLINK_NOFOLLOW,
10266            )
10267        } != 0
10268        {
10269            return Err(std::io::Error::last_os_error().into());
10270        }
10271        match stat.st_mode & libc::S_IFMT {
10272            libc::S_IFDIR => {
10273                if unsafe {
10274                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
10275                } != 0
10276                {
10277                    return Err(std::io::Error::last_os_error().into());
10278                }
10279                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
10280                let destination_child =
10281                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
10282                clone_tree_contents(&source_child, &destination_child, &child_display)?;
10283                destination_child.sync_all()?;
10284            }
10285            libc::S_IFREG => {
10286                let source_fd = unsafe {
10287                    libc::openat(
10288                        source.as_raw_fd(),
10289                        name.as_ptr(),
10290                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10291                    )
10292                };
10293                if source_fd < 0 {
10294                    return Err(std::io::Error::last_os_error().into());
10295                }
10296                let destination_fd = unsafe {
10297                    libc::openat(
10298                        destination.as_raw_fd(),
10299                        name.as_ptr(),
10300                        libc::O_WRONLY
10301                            | libc::O_CREAT
10302                            | libc::O_EXCL
10303                            | libc::O_CLOEXEC
10304                            | libc::O_NOFOLLOW,
10305                        (stat.st_mode & 0o777) as libc::c_uint,
10306                    )
10307                };
10308                if destination_fd < 0 {
10309                    unsafe {
10310                        libc::close(source_fd);
10311                    }
10312                    return Err(std::io::Error::last_os_error().into());
10313                }
10314                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
10315                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
10316                std::io::copy(&mut input, &mut output)?;
10317                output.sync_all()?;
10318            }
10319            libc::S_IFLNK => {
10320                let mut target = vec![0_u8; 4097];
10321                let length = unsafe {
10322                    libc::readlinkat(
10323                        source.as_raw_fd(),
10324                        name.as_ptr(),
10325                        target.as_mut_ptr().cast(),
10326                        target.len(),
10327                    )
10328                };
10329                if length < 0 || length as usize >= target.len() {
10330                    return Err(LinkError::UnsafePath {
10331                        path: child_display,
10332                    });
10333                }
10334                target.truncate(length as usize);
10335                let target = c_name(&target, &child_display)?;
10336                if unsafe {
10337                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
10338                } != 0
10339                {
10340                    return Err(std::io::Error::last_os_error().into());
10341                }
10342            }
10343            _ => {
10344                return Err(LinkError::UnsafePath {
10345                    path: child_display,
10346                });
10347            }
10348        }
10349    }
10350    destination.sync_all()?;
10351    Ok(())
10352}
10353
10354#[cfg(target_os = "linux")]
10355fn install_stage_at(
10356    parent: std::os::fd::RawFd,
10357    stage: &std::ffi::CStr,
10358    dest: &std::ffi::CStr,
10359    dest_exists: bool,
10360) -> LinkResult<()> {
10361    let flags = if dest_exists {
10362        libc::RENAME_EXCHANGE
10363    } else {
10364        libc::RENAME_NOREPLACE
10365    };
10366    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
10367    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
10368    // atomic exchange/no-replace boundary compiles for glibc and musl.
10369    let result = unsafe {
10370        libc::syscall(
10371            libc::SYS_renameat2,
10372            parent,
10373            stage.as_ptr(),
10374            parent,
10375            dest.as_ptr(),
10376            flags,
10377        )
10378    };
10379    if result == 0 {
10380        Ok(())
10381    } else {
10382        Err(std::io::Error::last_os_error().into())
10383    }
10384}
10385
10386#[cfg(target_os = "macos")]
10387fn install_stage_at(
10388    parent: std::os::fd::RawFd,
10389    stage: &std::ffi::CStr,
10390    dest: &std::ffi::CStr,
10391    dest_exists: bool,
10392) -> LinkResult<()> {
10393    let flags = if dest_exists {
10394        libc::RENAME_SWAP
10395    } else {
10396        libc::RENAME_EXCL
10397    };
10398    let result =
10399        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
10400    if result == 0 {
10401        Ok(())
10402    } else {
10403        Err(std::io::Error::last_os_error().into())
10404    }
10405}
10406
10407#[cfg(unix)]
10408fn write_pull_entries_beneath_dir(
10409    root: &std::fs::File,
10410    entries: &[(String, Vec<u8>)],
10411) -> LinkResult<()> {
10412    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10413
10414    for (path, content) in entries {
10415        let components: Vec<&str> = path.split('/').collect();
10416        let (leaf, parents) = components
10417            .split_last()
10418            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10419        let mut directory = root.try_clone()?;
10420        for component in parents {
10421            let name = c_name(component.as_bytes(), path)?;
10422            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10423            if made != 0 {
10424                let error = std::io::Error::last_os_error();
10425                if error.raw_os_error() != Some(libc::EEXIST) {
10426                    return Err(error.into());
10427                }
10428            }
10429            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10430        }
10431
10432        let leaf_name = c_name(leaf.as_bytes(), path)?;
10433        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
10434        let inspected = unsafe {
10435            libc::fstatat(
10436                directory.as_raw_fd(),
10437                leaf_name.as_ptr(),
10438                &mut existing,
10439                libc::AT_SYMLINK_NOFOLLOW,
10440            )
10441        };
10442        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
10443            return Err(LinkError::UnsafePath { path: path.clone() });
10444        }
10445
10446        let nonce = std::time::SystemTime::now()
10447            .duration_since(std::time::UNIX_EPOCH)
10448            .unwrap_or_default()
10449            .as_nanos();
10450        let temp_name = format!(
10451            ".dbmd-pull-{}-{nonce}-{}",
10452            std::process::id(),
10453            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
10454        );
10455        let temp = c_name(temp_name.as_bytes(), path)?;
10456        let fd = unsafe {
10457            libc::openat(
10458                directory.as_raw_fd(),
10459                temp.as_ptr(),
10460                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10461                0o600,
10462            )
10463        };
10464        if fd < 0 {
10465            return Err(std::io::Error::last_os_error().into());
10466        }
10467        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10468        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
10469            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10470            return Err(error.into());
10471        }
10472        drop(file);
10473        let renamed = unsafe {
10474            libc::renameat(
10475                directory.as_raw_fd(),
10476                temp.as_ptr(),
10477                directory.as_raw_fd(),
10478                leaf_name.as_ptr(),
10479            )
10480        };
10481        if renamed != 0 {
10482            let error = std::io::Error::last_os_error();
10483            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10484            return Err(error.into());
10485        }
10486        directory.sync_all()?;
10487    }
10488    root.sync_all()?;
10489    Ok(())
10490}
10491
10492#[cfg(unix)]
10493fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
10494    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10495
10496    let path = &entry.path;
10497    let components: Vec<&str> = path.split('/').collect();
10498    let (leaf, parents) = components
10499        .split_last()
10500        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10501    let mut directory = root.try_clone()?;
10502    for component in parents {
10503        let name = c_name(component.as_bytes(), path)?;
10504        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10505        if made != 0 {
10506            let error = std::io::Error::last_os_error();
10507            if error.raw_os_error() != Some(libc::EEXIST) {
10508                return Err(error.into());
10509            }
10510        }
10511        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10512    }
10513    let leaf_name = c_name(leaf.as_bytes(), path)?;
10514    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
10515    if unsafe {
10516        libc::fstatat(
10517            directory.as_raw_fd(),
10518            leaf_name.as_ptr(),
10519            &mut existing,
10520            libc::AT_SYMLINK_NOFOLLOW,
10521        )
10522    } == 0
10523        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
10524    {
10525        return Err(LinkError::UnsafePath { path: path.clone() });
10526    }
10527    let nonce = SystemTime::now()
10528        .duration_since(UNIX_EPOCH)
10529        .unwrap_or_default()
10530        .as_nanos();
10531    let temp_name = format!(
10532        ".dbmd-pull-{}-{nonce}-{}",
10533        std::process::id(),
10534        content_sha256(path.as_bytes())
10535    );
10536    let temp = c_name(temp_name.as_bytes(), path)?;
10537    let fd = unsafe {
10538        libc::openat(
10539            directory.as_raw_fd(),
10540            temp.as_ptr(),
10541            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10542            0o600,
10543        )
10544    };
10545    if fd < 0 {
10546        return Err(std::io::Error::last_os_error().into());
10547    }
10548    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
10549    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
10550    let mut digest = Sha256::new();
10551    let mut total = 0_u64;
10552    let mut buffer = [0_u8; 64 * 1024];
10553    let copied = (|| -> std::io::Result<()> {
10554        loop {
10555            let read = input.read(&mut buffer)?;
10556            if read == 0 {
10557                break;
10558            }
10559            total = total.saturating_add(read as u64);
10560            if total > entry.bytes {
10561                return Err(std::io::Error::new(
10562                    std::io::ErrorKind::InvalidData,
10563                    "staged sync source grew beyond its verified length",
10564                ));
10565            }
10566            digest.update(&buffer[..read]);
10567            output.write_all(&buffer[..read])?;
10568        }
10569        Ok(())
10570    })();
10571    if let Err(error) = copied {
10572        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10573        return Err(error.into());
10574    }
10575    drop(output);
10576    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
10577        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10578        return Err(invalid_feed(
10579            "private staged sync source failed final integrity verification",
10580        ));
10581    }
10582    if unsafe {
10583        libc::renameat(
10584            directory.as_raw_fd(),
10585            temp.as_ptr(),
10586            directory.as_raw_fd(),
10587            leaf_name.as_ptr(),
10588        )
10589    } != 0
10590    {
10591        let error = std::io::Error::last_os_error();
10592        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10593        return Err(error.into());
10594    }
10595    Ok(())
10596}
10597
10598#[cfg(unix)]
10599fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
10600    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10601
10602    let path = &entry.path;
10603    let components: Vec<&str> = path.split('/').collect();
10604    let (leaf, parents) = components
10605        .split_last()
10606        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10607    let mut directory = root.try_clone()?;
10608    for component in parents {
10609        directory = open_dir_at(
10610            directory.as_raw_fd(),
10611            &c_name(component.as_bytes(), path)?,
10612            path,
10613        )?;
10614    }
10615    let leaf = c_name(leaf.as_bytes(), path)?;
10616    let fd = unsafe {
10617        libc::openat(
10618            directory.as_raw_fd(),
10619            leaf.as_ptr(),
10620            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10621        )
10622    };
10623    if fd < 0 {
10624        return Err(std::io::Error::last_os_error().into());
10625    }
10626    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10627    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
10628        return Err(invalid_feed(
10629            "private pull stage changed before its durability barrier",
10630        ));
10631    }
10632    file.sync_all()?;
10633    Ok(())
10634}
10635
10636#[cfg(unix)]
10637fn run_pull_source_workers(
10638    root: &std::fs::File,
10639    entries: &[V2StagedFile],
10640    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
10641) -> LinkResult<()> {
10642    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10643
10644    let next = AtomicUsize::new(0);
10645    let failed = AtomicBool::new(false);
10646    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
10647    let mut first_error = None;
10648    std::thread::scope(|scope| {
10649        let (sender, receiver) = std::sync::mpsc::channel();
10650        for _ in 0..worker_count {
10651            let sender = sender.clone();
10652            let next = &next;
10653            let failed = &failed;
10654            scope.spawn(move || {
10655                while !failed.load(Ordering::Acquire) {
10656                    let index = next.fetch_add(1, Ordering::Relaxed);
10657                    let Some(entry) = entries.get(index) else {
10658                        break;
10659                    };
10660                    let result = operation(root, entry);
10661                    if result.is_err() {
10662                        failed.store(true, Ordering::Release);
10663                    }
10664                    if sender.send(result).is_err() {
10665                        break;
10666                    }
10667                }
10668            });
10669        }
10670        drop(sender);
10671        for result in receiver {
10672            if let Err(error) = result {
10673                if first_error.is_none() {
10674                    first_error = Some(error);
10675                }
10676            }
10677        }
10678    });
10679    if let Some(error) = first_error {
10680        return Err(error);
10681    }
10682    if next.load(Ordering::Relaxed) < entries.len() {
10683        return Err(invalid_feed(
10684            "a bounded pull worker stopped before reporting every file",
10685        ));
10686    }
10687    Ok(())
10688}
10689
10690#[cfg(unix)]
10691fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
10692    use std::os::fd::AsRawFd as _;
10693
10694    for name in directory_entry_names(root)? {
10695        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
10696            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
10697            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
10698            sync_pull_directory_tree(&child, &child_display)?;
10699        }
10700    }
10701    root.sync_all()?;
10702    Ok(())
10703}
10704
10705#[cfg(unix)]
10706fn write_pull_sources_beneath_dir(
10707    root: &std::fs::File,
10708    entries: &[V2StagedFile],
10709) -> LinkResult<()> {
10710    // The stage is still private and unreachable as the checkout here. Write
10711    // and verify every leaf first so the filesystem can coalesce dirty data,
10712    // then re-open/re-hash/fsync leaves through the held root capability. Only
10713    // after every file barrier succeeds do we flush each directory once. This
10714    // preserves the same crash durability as file-fsync + directory-fsync per
10715    // path without serializing 50,000 redundant directory barriers.
10716    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
10717    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
10718    sync_pull_directory_tree(root, "v2 pull stage")
10719}
10720
10721#[cfg(unix)]
10722fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
10723    use std::os::fd::AsRawFd as _;
10724    for path in paths {
10725        if !safe_store_rel_path(path) {
10726            return Err(LinkError::UnsafePath { path: path.clone() });
10727        }
10728        let components = path.split('/').collect::<Vec<_>>();
10729        let Some((leaf, parents)) = components.split_last() else {
10730            return Err(LinkError::UnsafePath { path: path.clone() });
10731        };
10732        let mut directory = root.try_clone()?;
10733        let mut missing = false;
10734        for component in parents {
10735            let name = c_name(component.as_bytes(), path)?;
10736            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
10737                None => {
10738                    missing = true;
10739                    break;
10740                }
10741                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
10742                Some(true) => {
10743                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10744                }
10745            }
10746        }
10747        if missing {
10748            continue;
10749        }
10750        let leaf = c_name(leaf.as_bytes(), path)?;
10751        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
10752            None => {}
10753            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
10754            Some(false) => {
10755                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
10756                    return Err(std::io::Error::last_os_error().into());
10757                }
10758                directory.sync_all()?;
10759            }
10760        }
10761    }
10762    Ok(())
10763}
10764
10765#[cfg(unix)]
10766fn install_pulled_delta(
10767    dest: &Path,
10768    entries: &[(String, Vec<u8>)],
10769    deleted: &[String],
10770    rebuild_indexes: bool,
10771) -> LinkResult<()> {
10772    use ring::rand::SecureRandom as _;
10773    use std::os::fd::AsRawFd as _;
10774    use std::os::unix::ffi::OsStrExt as _;
10775
10776    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10777    let name = dest
10778        .file_name()
10779        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10780        .ok_or_else(|| LinkError::UnsafePath {
10781            path: dest.display().to_string(),
10782        })?;
10783    let parent_dir = open_or_create_dir_nofollow(parent)?;
10784    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10785    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10786        None => false,
10787        Some(true) => true,
10788        Some(false) => {
10789            return Err(LinkError::UnsafePath {
10790                path: dest.display().to_string(),
10791            });
10792        }
10793    };
10794
10795    let mut nonce = [0_u8; 16];
10796    ring::rand::SystemRandom::new()
10797        .fill(&mut nonce)
10798        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10799    let stage_label = format!(
10800        ".{}.dbmd-pull-stage-{}",
10801        name.to_string_lossy(),
10802        URL_SAFE_NO_PAD.encode(nonce)
10803    );
10804    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10805    let stage_dir = create_dir_exclusive_at(
10806        parent_dir.as_raw_fd(),
10807        &stage_name,
10808        &dest.display().to_string(),
10809    )?;
10810
10811    let prepared = (|| -> LinkResult<()> {
10812        if dest_exists {
10813            let live = open_dir_at(
10814                parent_dir.as_raw_fd(),
10815                &dest_name,
10816                &dest.display().to_string(),
10817            )?;
10818            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10819        }
10820        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10821        write_pull_entries_beneath_dir(&stage_dir, entries)?;
10822        if rebuild_indexes {
10823            let stage_store =
10824                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10825                    .map_err(|error| LinkError::InvalidPack {
10826                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10827                    })?;
10828            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10829                LinkError::InvalidPack {
10830                    message: format!("could not materialize v2 local catalogs: {error}"),
10831                }
10832            })?;
10833        }
10834        stage_dir.sync_all()?;
10835        Ok(())
10836    })();
10837    if let Err(error) = prepared {
10838        let _ = remove_tree_at(
10839            parent_dir.as_raw_fd(),
10840            &stage_name,
10841            &dest.display().to_string(),
10842        );
10843        return Err(error);
10844    }
10845
10846    if let Err(error) =
10847        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10848    {
10849        let _ = remove_tree_at(
10850            parent_dir.as_raw_fd(),
10851            &stage_name,
10852            &dest.display().to_string(),
10853        );
10854        return Err(error);
10855    }
10856    parent_dir.sync_all()?;
10857    if dest_exists {
10858        // Commit already happened atomically. Cleanup is best-effort so a
10859        // failure cannot be reported as a failed pull after the live tree
10860        // changed; a crash may leave only this private old-tree sibling.
10861        let _ = remove_tree_at(
10862            parent_dir.as_raw_fd(),
10863            &stage_name,
10864            &dest.display().to_string(),
10865        );
10866        let _ = parent_dir.sync_all();
10867    }
10868    Ok(())
10869}
10870
10871#[cfg(unix)]
10872fn install_pulled_delta_sources(
10873    dest: &Path,
10874    entries: &[V2StagedFile],
10875    deleted: &[String],
10876    rebuild_indexes: bool,
10877    _previous: Option<&V2SyncBaseline>,
10878    _next: &V2VerifiedHead,
10879) -> LinkResult<()> {
10880    use ring::rand::SecureRandom as _;
10881    use std::os::fd::AsRawFd as _;
10882    use std::os::unix::ffi::OsStrExt as _;
10883
10884    // An established checkout is updated only at the changed coordinates
10885    // through the durable pull journal shared with Windows. Initial clones
10886    // still use a private complete stage and one atomic directory install.
10887    if let Ok(store) = Store::open_strict(dest) {
10888        return install_established_v2_delta(
10889            store,
10890            entries,
10891            deleted,
10892            rebuild_indexes,
10893            _previous,
10894            _next,
10895        );
10896    }
10897
10898    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10899    let name = dest
10900        .file_name()
10901        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10902        .ok_or_else(|| LinkError::UnsafePath {
10903            path: dest.display().to_string(),
10904        })?;
10905    let parent_dir = open_or_create_dir_nofollow(parent)?;
10906    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10907    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10908        None => false,
10909        Some(true) => true,
10910        Some(false) => {
10911            return Err(LinkError::UnsafePath {
10912                path: dest.display().to_string(),
10913            })
10914        }
10915    };
10916    let mut nonce = [0_u8; 16];
10917    ring::rand::SystemRandom::new()
10918        .fill(&mut nonce)
10919        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10920    let stage_label = format!(
10921        ".{}.dbmd-pull-stage-{}",
10922        name.to_string_lossy(),
10923        URL_SAFE_NO_PAD.encode(nonce)
10924    );
10925    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10926    let stage_dir = create_dir_exclusive_at(
10927        parent_dir.as_raw_fd(),
10928        &stage_name,
10929        &dest.display().to_string(),
10930    )?;
10931    let prepared = (|| -> LinkResult<()> {
10932        if dest_exists {
10933            let live = open_dir_at(
10934                parent_dir.as_raw_fd(),
10935                &dest_name,
10936                &dest.display().to_string(),
10937            )?;
10938            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10939        }
10940        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10941        write_pull_sources_beneath_dir(&stage_dir, entries)?;
10942        if rebuild_indexes {
10943            let stage_store =
10944                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10945                    .map_err(|error| LinkError::InvalidPack {
10946                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10947                    })?;
10948            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10949                LinkError::InvalidPack {
10950                    message: format!("could not materialize v2 local catalogs: {error}"),
10951                }
10952            })?;
10953        }
10954        stage_dir.sync_all()?;
10955        Ok(())
10956    })();
10957    if let Err(error) = prepared {
10958        let _ = remove_tree_at(
10959            parent_dir.as_raw_fd(),
10960            &stage_name,
10961            &dest.display().to_string(),
10962        );
10963        return Err(error);
10964    }
10965    if let Err(error) =
10966        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10967    {
10968        let _ = remove_tree_at(
10969            parent_dir.as_raw_fd(),
10970            &stage_name,
10971            &dest.display().to_string(),
10972        );
10973        return Err(error);
10974    }
10975    parent_dir.sync_all()?;
10976    if dest_exists {
10977        let _ = remove_tree_at(
10978            parent_dir.as_raw_fd(),
10979            &stage_name,
10980            &dest.display().to_string(),
10981        );
10982        let _ = parent_dir.sync_all();
10983    }
10984    Ok(())
10985}
10986
10987#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10988struct V2PullCoordinate {
10989    head_seq: Option<u64>,
10990    commit_hash: Option<String>,
10991    view_kind: Option<String>,
10992    view_revision: Option<String>,
10993}
10994
10995#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10996struct V2PullFileCoordinate {
10997    sha256: String,
10998    bytes: u64,
10999}
11000
11001#[derive(Debug, Clone, Deserialize, Serialize)]
11002struct V2PullJournalEntry {
11003    path: String,
11004    old: Option<V2PullFileCoordinate>,
11005    new: Option<V2PullFileCoordinate>,
11006    backup: Option<String>,
11007}
11008
11009#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11010#[serde(rename_all = "snake_case")]
11011enum V2PullPhase {
11012    Preparing,
11013    Ready,
11014}
11015
11016#[derive(Debug, Clone, Deserialize, Serialize)]
11017struct V2PullJournal {
11018    v: u8,
11019    phase: V2PullPhase,
11020    brain: String,
11021    previous: V2PullCoordinate,
11022    next: V2PullCoordinate,
11023    backup_dir: String,
11024    entries: Vec<V2PullJournalEntry>,
11025}
11026
11027const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
11028
11029fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
11030    V2PullCoordinate {
11031        head_seq: baseline.and_then(|value| value.head_seq),
11032        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
11033        view_kind: baseline.and_then(|value| value.view_kind.clone()),
11034        view_revision: baseline.and_then(|value| value.view_revision.clone()),
11035    }
11036}
11037
11038fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
11039    V2PullCoordinate {
11040        head_seq: head.pointer.as_ref().map(|value| value.seq),
11041        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
11042        view_kind: Some(head.view_kind.clone()),
11043        view_revision: Some(head.view_revision.clone()),
11044    }
11045}
11046
11047fn v2_pull_file_coordinate(
11048    store: &Store,
11049    path: &str,
11050    limit: u64,
11051) -> LinkResult<Option<V2PullFileCoordinate>> {
11052    let file = match store.open_regular(Path::new(path)) {
11053        Ok(file) => file,
11054        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11055        Err(error) => return Err(error.into()),
11056    };
11057    let bytes = file.metadata()?.len();
11058    if bytes > limit || bytes > MAX_STORE_BYTES {
11059        return Err(invalid_feed(
11060            "pull transaction file exceeds its declared bound",
11061        ));
11062    }
11063    Ok(Some(V2PullFileCoordinate {
11064        sha256: content_sha256_reader(file)?,
11065        bytes,
11066    }))
11067}
11068
11069fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
11070    let mut bytes = serde_json::to_vec_pretty(journal)
11071        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
11072    bytes.push(b'\n');
11073    Ok(bytes)
11074}
11075
11076fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
11077    let backup_prefix = ".dbmd/pull-backup-";
11078    let suffix = journal
11079        .backup_dir
11080        .strip_prefix(backup_prefix)
11081        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
11082    let mut paths = std::collections::BTreeSet::new();
11083    if journal.v != 1
11084        || !crate::ulid::is_ulid(&journal.brain)
11085        || !crate::ulid::is_ulid(suffix)
11086        || journal.entries.is_empty()
11087        || journal.entries.len() > MAX_PUSH_FILES + 4
11088        || journal.previous == journal.next
11089    {
11090        return Err(invalid_feed("v2 pull journal failed validation"));
11091    }
11092    for (index, entry) in journal.entries.iter().enumerate() {
11093        if !safe_store_rel_path(&entry.path)
11094            || entry.path == V2_PULL_JOURNAL
11095            || entry.path.starts_with(backup_prefix)
11096            || !paths.insert(entry.path.clone())
11097            || (entry.old.is_none() && entry.new.is_none())
11098            || entry
11099                .old
11100                .iter()
11101                .chain(entry.new.iter())
11102                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
11103            || entry.backup.as_deref()
11104                != entry
11105                    .old
11106                    .as_ref()
11107                    .map(|_| format!("{index:08x}"))
11108                    .as_deref()
11109        {
11110            return Err(invalid_feed("v2 pull journal entry failed validation"));
11111        }
11112    }
11113    Ok(())
11114}
11115
11116fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
11117    #[cfg(unix)]
11118    {
11119        use std::os::unix::fs::PermissionsExt as _;
11120        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
11121            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
11122                return Err(invalid_feed(
11123                    "v2 pull journal is accessible to group/other; set mode 0600",
11124                ));
11125            }
11126            Ok(_) => {}
11127            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11128            Err(error) => return Err(error.into()),
11129        }
11130    }
11131    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
11132        Ok(bytes) => bytes,
11133        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11134        Err(error) => return Err(error.into()),
11135    };
11136    let journal: V2PullJournal =
11137        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
11138    validate_v2_pull_journal(&journal)?;
11139    Ok(Some(journal))
11140}
11141
11142fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
11143    // The journal is the only recovery authority. Remove it first only after
11144    // rollback or baseline commit is durable; an orphan private backup is safe
11145    // and can be pruned, while a journal without its backups is not recoverable.
11146    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
11147        Ok(()) => {}
11148        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
11149        Err(error) => return Err(error.into()),
11150    }
11151    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
11152        Ok(()) => Ok(()),
11153        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
11154        Err(error) => Err(error.into()),
11155    }
11156}
11157
11158fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
11159    let names = match store.directory_names(Path::new(".dbmd")) {
11160        Ok(names) => names,
11161        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
11162        Err(error) => return Err(error.into()),
11163    };
11164    for name in names {
11165        let Some(name) = name.to_str() else {
11166            continue;
11167        };
11168        let Some(suffix) = name.strip_prefix("pull-backup-") else {
11169            continue;
11170        };
11171        if crate::ulid::is_ulid(suffix) {
11172            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
11173        }
11174    }
11175    Ok(())
11176}
11177
11178fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
11179    // Validate every live coordinate and every backup before the first restore.
11180    for entry in &journal.entries {
11181        let limit = entry
11182            .old
11183            .as_ref()
11184            .into_iter()
11185            .chain(entry.new.iter())
11186            .map(|value| value.bytes)
11187            .max()
11188            .unwrap_or(0);
11189        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
11190        if current != entry.old && current != entry.new {
11191            return Err(LinkError::InvalidPack {
11192                message: format!(
11193                    "cannot recover interrupted pull because `{}` changed afterward",
11194                    entry.path
11195                ),
11196            });
11197        }
11198        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
11199            let path = Path::new(&journal.backup_dir).join(backup);
11200            let file = store.open_regular(&path)?;
11201            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
11202                return Err(invalid_feed("v2 pull recovery backup failed verification"));
11203            }
11204        }
11205    }
11206    for entry in journal.entries.iter().rev() {
11207        match (&entry.old, &entry.backup) {
11208            (Some(old), Some(backup)) => {
11209                let bytes =
11210                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
11211                store.write_atomic(Path::new(&entry.path), &bytes)?;
11212            }
11213            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
11214                store.remove_file(Path::new(&entry.path))?;
11215            }
11216            (None, None) => {}
11217            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
11218        }
11219    }
11220    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
11221        message: format!("could not rebuild catalogs after pull recovery: {error}"),
11222    })?;
11223    cleanup_v2_pull_journal(store, journal)
11224}
11225
11226fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
11227    let Ok(store) = Store::open_strict(dest) else {
11228        return Ok(());
11229    };
11230    if let Some(journal) = load_v2_pull_journal(&store)? {
11231        if journal.brain != brain {
11232            return Err(invalid_feed("v2 pull journal belongs to another brain"));
11233        }
11234        if journal.phase == V2PullPhase::Preparing {
11235            cleanup_v2_pull_journal(&store, &journal)?;
11236        } else {
11237            let baseline = load_v2_baseline(cfg, brain, dest)?;
11238            let current = v2_pull_baseline_coordinate(baseline.as_ref());
11239            if current == journal.next {
11240                cleanup_v2_pull_journal(&store, &journal)?;
11241            } else {
11242                if current != journal.previous {
11243                    return Err(invalid_feed(
11244                        "cannot recover interrupted pull because its baseline changed afterward",
11245                    ));
11246                }
11247                rollback_v2_pull(&store, &journal)?;
11248            }
11249        }
11250    }
11251    // Cleanup removes the journal first after the state is durably old or new.
11252    // A hard kill in the tiny interval before deleting its backup directory can
11253    // therefore leave only an inert, private orphan. The per-brain operation
11254    // lock is held by the caller, so no live pull can own one here.
11255    prune_orphan_v2_pull_backups(&store)
11256}
11257
11258fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
11259    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
11260        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
11261    })?;
11262    if let Some(journal) = load_v2_pull_journal(&store)? {
11263        cleanup_v2_pull_journal(&store, &journal)?;
11264    }
11265    Ok(())
11266}
11267
11268#[cfg(windows)]
11269fn install_windows_initial_sources(
11270    dest: &Path,
11271    entries: &[V2StagedFile],
11272    rebuild_indexes: bool,
11273) -> LinkResult<()> {
11274    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
11275    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
11276        path: dest.display().to_string(),
11277    })?;
11278    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
11279    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
11280        return Err(LinkError::UnsafePath {
11281            path: dest.display().to_string(),
11282        });
11283    }
11284    let stage_name = format!(
11285        ".{}.dbmd-pull-stage-{}",
11286        name.to_string_lossy(),
11287        crate::ulid::mint()
11288    );
11289    let stage_path = parent.join(&stage_name);
11290    let stage_capability =
11291        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
11292    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
11293    let prepared = (|| -> LinkResult<()> {
11294        for entry in entries {
11295            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
11296            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
11297                return Err(invalid_feed(
11298                    "private staged sync source failed final integrity verification",
11299                ));
11300            }
11301            stage.write_atomic(Path::new(&entry.path), &bytes)?;
11302        }
11303        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
11304            .map_err(|error| LinkError::InvalidPack {
11305                message: format!("v2 staging tree is not a valid db.md store: {error}"),
11306            })?;
11307        if rebuild_indexes {
11308            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
11309                message: format!("could not materialize v2 local catalogs: {error}"),
11310            })?;
11311        }
11312        Ok(())
11313    })();
11314    if let Err(error) = prepared {
11315        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
11316        return Err(error);
11317    }
11318    crate::fsx::rename_directory_beneath(
11319        &parent_capability,
11320        Path::new(&stage_name),
11321        Path::new(name),
11322    )?;
11323    Ok(())
11324}
11325
11326fn install_established_v2_delta(
11327    store: Store,
11328    entries: &[V2StagedFile],
11329    deleted: &[String],
11330    rebuild_indexes: bool,
11331    previous: Option<&V2SyncBaseline>,
11332    next: &V2VerifiedHead,
11333) -> LinkResult<()> {
11334    if load_v2_pull_journal(&store)?.is_some() {
11335        return Err(invalid_feed(
11336            "an interrupted pull must be recovered before installing",
11337        ));
11338    }
11339    let mut sources = std::collections::BTreeMap::new();
11340    for entry in entries {
11341        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
11342            return Err(invalid_feed("pull mutation repeats a path"));
11343        }
11344    }
11345    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
11346    paths.extend(deleted.iter().cloned());
11347    paths.sort();
11348    paths.dedup();
11349    if paths.is_empty() {
11350        return Ok(());
11351    }
11352    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
11353    let mut journal = V2PullJournal {
11354        v: 1,
11355        phase: V2PullPhase::Preparing,
11356        brain: next.brain_id.clone(),
11357        previous: v2_pull_baseline_coordinate(previous),
11358        next: v2_pull_head_coordinate(next),
11359        backup_dir: backup_dir.clone(),
11360        entries: Vec::with_capacity(paths.len()),
11361    };
11362    for path in &paths {
11363        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
11364        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
11365            sha256: entry.sha256.clone(),
11366            bytes: entry.bytes,
11367        });
11368        if old == new {
11369            continue;
11370        }
11371        let index = journal.entries.len();
11372        journal.entries.push(V2PullJournalEntry {
11373            path: path.clone(),
11374            backup: old.as_ref().map(|_| format!("{index:08x}")),
11375            old,
11376            new,
11377        });
11378    }
11379    if journal.entries.is_empty() {
11380        return Ok(());
11381    }
11382    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
11383        entry
11384            .old
11385            .as_ref()
11386            .map_or(Some(total), |old| total.checked_add(old.bytes))
11387    });
11388    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
11389        return Err(LinkError::InvalidPack {
11390            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
11391        });
11392    }
11393    validate_v2_pull_journal(&journal)?;
11394    store.write_private_atomic_new(
11395        Path::new(V2_PULL_JOURNAL),
11396        &v2_pull_journal_bytes(&journal)?,
11397    )?;
11398    let prepared = (|| -> LinkResult<()> {
11399        store.create_private_dir_all(Path::new(&backup_dir))?;
11400        for entry in &journal.entries {
11401            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
11402                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
11403                if content_sha256(&bytes) != old.sha256 {
11404                    return Err(invalid_feed("live pull source changed during backup"));
11405                }
11406                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
11407            }
11408        }
11409        journal.phase = V2PullPhase::Ready;
11410        store.write_private_atomic(
11411            Path::new(V2_PULL_JOURNAL),
11412            &v2_pull_journal_bytes(&journal)?,
11413        )?;
11414        Ok(())
11415    })();
11416    if let Err(error) = prepared {
11417        let cleanup = cleanup_v2_pull_journal(&store, &journal);
11418        return match cleanup {
11419            Ok(()) => Err(error),
11420            Err(cleanup) => Err(LinkError::InvalidPack {
11421                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
11422            }),
11423        };
11424    }
11425    let installed = (|| -> LinkResult<()> {
11426        for entry in &journal.entries {
11427            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
11428                return Err(LinkError::InvalidPack {
11429                    message: format!("local path `{}` changed during pull", entry.path),
11430                });
11431            }
11432            if let Some(source) = sources.get(&entry.path) {
11433                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
11434                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
11435                    return Err(invalid_feed(
11436                        "private staged sync source failed final integrity verification",
11437                    ));
11438                }
11439                store.write_atomic(Path::new(&entry.path), &bytes)?;
11440            } else if entry.old.is_some() {
11441                store.remove_file(Path::new(&entry.path))?;
11442            }
11443        }
11444        if rebuild_indexes {
11445            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
11446                message: format!("could not materialize v2 local catalogs: {error}"),
11447            })?;
11448        }
11449        Ok(())
11450    })();
11451    if let Err(error) = installed {
11452        return match rollback_v2_pull(&store, &journal) {
11453            Ok(()) => Err(error),
11454            Err(rollback) => Err(LinkError::InvalidPack {
11455                message: format!("{error}; durable pull rollback also failed: {rollback}"),
11456            }),
11457        };
11458    }
11459    Ok(())
11460}
11461
11462#[cfg(windows)]
11463fn install_pulled_delta_sources(
11464    dest: &Path,
11465    entries: &[V2StagedFile],
11466    deleted: &[String],
11467    rebuild_indexes: bool,
11468    previous: Option<&V2SyncBaseline>,
11469    next: &V2VerifiedHead,
11470) -> LinkResult<()> {
11471    match Store::open_strict(dest) {
11472        Ok(store) => {
11473            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
11474        }
11475        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
11476    }
11477}
11478
11479#[cfg(not(any(unix, windows)))]
11480fn install_pulled_delta_sources(
11481    _dest: &Path,
11482    _entries: &[V2StagedFile],
11483    _deleted: &[String],
11484    _rebuild_indexes: bool,
11485    _previous: Option<&V2SyncBaseline>,
11486    _next: &V2VerifiedHead,
11487) -> LinkResult<()> {
11488    Err(LinkError::UnsupportedPlatform {
11489        operation: "atomic v2 pull install",
11490    })
11491}
11492
11493#[cfg(unix)]
11494fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
11495    install_pulled_delta(dest, entries, &[], false)
11496}
11497
11498#[cfg(not(windows))]
11499fn is_safe_slug(slug: &str) -> bool {
11500    !slug.is_empty()
11501        && slug.len() <= 63
11502        && !slug.starts_with('-')
11503        && !slug.ends_with('-')
11504        && slug
11505            .bytes()
11506            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
11507}
11508
11509fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
11510    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
11511}
11512
11513fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
11514    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
11515}
11516
11517fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
11518    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
11519}
11520
11521fn preflight_zip_central_directory(
11522    bytes: &[u8],
11523    offset: usize,
11524    size: usize,
11525    count: u64,
11526) -> LinkResult<()> {
11527    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
11528    let end = offset
11529        .checked_add(size)
11530        .filter(|end| *end <= bytes.len())
11531        .ok_or_else(|| LinkError::InvalidPack {
11532            message: "ZIP central directory is out of bounds".to_string(),
11533        })?;
11534    let mut cursor = offset;
11535    for _ in 0..count {
11536        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
11537            return Err(LinkError::InvalidPack {
11538                message: "ZIP central directory entry count is inconsistent".to_string(),
11539            });
11540        }
11541        if le_u16(bytes, cursor + 34) != Some(0) {
11542            return Err(LinkError::InvalidPack {
11543                message: "multi-disk ZIP archives are not supported".to_string(),
11544            });
11545        }
11546        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
11547            total.checked_add(le_u16(bytes, cursor + at)? as usize)
11548        });
11549        cursor = cursor
11550            .checked_add(46)
11551            .and_then(|fixed| fixed.checked_add(variable?))
11552            .filter(|cursor| *cursor <= end)
11553            .ok_or_else(|| LinkError::InvalidPack {
11554                message: "ZIP central directory entry is truncated".to_string(),
11555            })?;
11556    }
11557    if cursor != end {
11558        return Err(LinkError::InvalidPack {
11559            message: "ZIP central directory size is inconsistent".to_string(),
11560        });
11561    }
11562    Ok(())
11563}
11564
11565/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
11566/// metadata object per central-directory entry. Supports ordinary EOCD and the
11567/// Zip64 locator/record emitted for >65,535-entry archives.
11568fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
11569    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
11570    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
11571    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
11572    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
11573    let eocd = bytes[search_start..]
11574        .windows(4)
11575        .rposition(|window| window == EOCD_SIG)
11576        .map(|offset| search_start + offset)
11577        .ok_or_else(|| LinkError::InvalidPack {
11578            message: "ZIP has no end-of-central-directory record".to_string(),
11579        })?;
11580    let invalid_end = || LinkError::InvalidPack {
11581        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
11582    };
11583    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
11584    if eocd
11585        .checked_add(22)
11586        .and_then(|end| end.checked_add(comment_len))
11587        != Some(bytes.len())
11588    {
11589        // Do not fall back to an earlier signature. ZipArchive does, and a
11590        // fake low-count EOCD appended after a real Zip64 entry-count bomb
11591        // would otherwise bypass this allocation preflight.
11592        return Err(invalid_end());
11593    }
11594    let disk = le_u16(bytes, eocd + 4);
11595    let central_disk = le_u16(bytes, eocd + 6);
11596    if disk != Some(0) || central_disk != Some(0) {
11597        return Err(LinkError::InvalidPack {
11598            message: "multi-disk ZIP archives are not supported".to_string(),
11599        });
11600    }
11601    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
11602    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
11603    if entries_on_disk != ordinary {
11604        return Err(LinkError::InvalidPack {
11605            message: "multi-disk ZIP archives are not supported".to_string(),
11606        });
11607    }
11608    let zip64_locator = eocd
11609        .checked_sub(20)
11610        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
11611    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
11612        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
11613        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
11614        if central_offset
11615            .checked_add(central_size)
11616            .filter(|end| *end == eocd)
11617            .is_none()
11618        {
11619            return Err(invalid_end());
11620        }
11621        (ordinary as u64, central_offset, central_size)
11622    } else {
11623        let Some(locator) = zip64_locator else {
11624            return Err(invalid_end());
11625        };
11626        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
11627            return Err(LinkError::InvalidPack {
11628                message: "multi-disk ZIP64 archives are not supported".to_string(),
11629            });
11630        }
11631        let record = le_u64(bytes, locator + 8)
11632            .and_then(|offset| usize::try_from(offset).ok())
11633            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
11634            .ok_or_else(|| LinkError::InvalidPack {
11635                message: "ZIP64 archive has an invalid end record".to_string(),
11636            })?;
11637        let record_size = le_u64(bytes, record + 4)
11638            .and_then(|size| usize::try_from(size).ok())
11639            .filter(|size| *size >= 44)
11640            .ok_or_else(invalid_end)?;
11641        if record
11642            .checked_add(12)
11643            .and_then(|end| end.checked_add(record_size))
11644            != Some(locator)
11645            || le_u32(bytes, record + 16) != Some(0)
11646            || le_u32(bytes, record + 20) != Some(0)
11647        {
11648            return Err(invalid_end());
11649        }
11650        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
11651        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
11652        let central_size = le_u64(bytes, record + 40)
11653            .and_then(|size| usize::try_from(size).ok())
11654            .ok_or_else(invalid_end)?;
11655        let central_offset = le_u64(bytes, record + 48)
11656            .and_then(|offset| usize::try_from(offset).ok())
11657            .ok_or_else(invalid_end)?;
11658        if zip64_on_disk != zip64_total
11659            || central_offset
11660                .checked_add(central_size)
11661                .filter(|end| *end == record)
11662                .is_none()
11663        {
11664            return Err(invalid_end());
11665        }
11666        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
11667        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
11668        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
11669            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
11670        {
11671            return Err(invalid_end());
11672        }
11673        (zip64_total, central_offset, central_size)
11674    };
11675    if count == 0 || count > max_entries as u64 {
11676        return Err(LinkError::InvalidPack {
11677            message: format!("invalid file count {count}"),
11678        });
11679    }
11680    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
11681    Ok(())
11682}
11683
11684fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
11685    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
11686    let mut archive =
11687        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
11688            message: format!("ZIP parse failed: {err}"),
11689        })?;
11690    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
11691        return Err(LinkError::InvalidPack {
11692            message: format!("invalid file count {}", archive.len()),
11693        });
11694    }
11695    let mut total = 0u64;
11696    let mut seen = std::collections::HashSet::new();
11697    let mut entries = Vec::with_capacity(archive.len());
11698    for index in 0..archive.len() {
11699        let mut file = archive
11700            .by_index(index)
11701            .map_err(|err| LinkError::InvalidPack {
11702                message: format!("ZIP entry failed: {err}"),
11703            })?;
11704        if file.is_dir() {
11705            continue;
11706        }
11707        let path = file.name().to_string();
11708        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
11709            return Err(LinkError::UnsafePath { path });
11710        }
11711        if file
11712            .unix_mode()
11713            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
11714        {
11715            return Err(LinkError::InvalidPack {
11716                message: format!("non-file entry `{path}`"),
11717            });
11718        }
11719        if !seen.insert(path.clone()) {
11720            return Err(LinkError::InvalidPack {
11721                message: format!("duplicate path `{path}`"),
11722            });
11723        }
11724        let remaining = MAX_STORE_BYTES.saturating_sub(total);
11725        if file.size() > remaining {
11726            return Err(LinkError::InvalidPack {
11727                message: "expanded content exceeds the 512 MB limit".to_string(),
11728            });
11729        }
11730        let mut content = Vec::new();
11731        (&mut file)
11732            .take(remaining + 1)
11733            .read_to_end(&mut content)
11734            .map_err(|err| LinkError::InvalidPack {
11735                message: format!("could not decompress `{path}`: {err}"),
11736            })?;
11737        if content.len() as u64 > remaining {
11738            return Err(LinkError::InvalidPack {
11739                message: "expanded content exceeds the 512 MB limit".to_string(),
11740            });
11741        }
11742        if content.len() as u64 != file.size() {
11743            return Err(LinkError::InvalidPack {
11744                message: format!("length mismatch for `{path}`"),
11745            });
11746        }
11747        total += content.len() as u64;
11748        entries.push((path, content));
11749    }
11750    if entries.is_empty() {
11751        return Err(LinkError::InvalidPack {
11752            message: "pack contains no files".to_string(),
11753        });
11754    }
11755    Ok(entries)
11756}
11757
11758fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
11759    let mut expected = std::collections::BTreeMap::new();
11760    for file in signed {
11761        if !safe_store_rel_path(&file.path) {
11762            return Err(LinkError::UnsafePath {
11763                path: file.path.clone(),
11764            });
11765        }
11766        if !is_sha256(&file.sha256)
11767            || expected
11768                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11769                .is_some()
11770        {
11771            return Err(invalid_feed(
11772                "signed snapshot manifest contains an invalid or duplicate file",
11773            ));
11774        }
11775    }
11776    if expected.len() != entries.len() {
11777        return Err(invalid_feed(
11778            "downloaded pack file set differs from the signed snapshot manifest",
11779        ));
11780    }
11781    for (path, bytes) in entries {
11782        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
11783            return Err(invalid_feed(format!(
11784                "downloaded pack contains unsigned path `{path}`"
11785            )));
11786        };
11787        if *declared_bytes != bytes.len() as u64
11788            || *sha256 != format!("{:x}", Sha256::digest(bytes))
11789        {
11790            return Err(invalid_feed(format!(
11791                "downloaded file `{path}` differs from its signed manifest"
11792            )));
11793        }
11794    }
11795    Ok(())
11796}
11797
11798/// Collect the files a push sends: the store's owned source text — `DB.md`,
11799/// `assets.jsonl` when present, the canonical curator timeline (`log.md` plus
11800/// rotated `log/*.md` archives), and every content `.md` under `records/` and
11801/// `sources/`. Derived `index.*` catalogs remain local because the receiver
11802/// rebuilds them. Returns `(store-relative path, content)` pairs, path-sorted.
11803pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11804    require_hardened_filesystem("sync push")?;
11805    preflight_push_ownership(store)?;
11806    let mut out: Vec<(String, String)> = Vec::new();
11807    let mut total = 0u64;
11808
11809    let mut read_text = |rel: &str| -> LinkResult<String> {
11810        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11811        total = total
11812            .checked_add(bytes.len() as u64)
11813            .ok_or_else(|| LinkError::PushTooLarge {
11814                detail: "uncompressed byte count overflow".to_string(),
11815            })?;
11816        if total > MAX_STORE_BYTES {
11817            return Err(LinkError::PushTooLarge {
11818                detail: format!("{total} uncompressed bytes"),
11819            });
11820        }
11821        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11822            path: rel.to_string(),
11823        })
11824    };
11825
11826    out.push(("DB.md".to_string(), read_text("DB.md")?));
11827    if store
11828        .regular_file_exists(Path::new("assets.jsonl"))
11829        .unwrap_or(false)
11830    {
11831        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11832    }
11833    if store
11834        .regular_file_exists(Path::new("log.md"))
11835        .unwrap_or(false)
11836    {
11837        out.push(("log.md".to_string(), read_text("log.md")?));
11838    }
11839    if store.directory_exists(Path::new("log"))? {
11840        for rel in store.walk_regular_files(Path::new("log"))? {
11841            let rel_str = rel.to_string_lossy().replace('\\', "/");
11842            if rel.extension().and_then(std::ffi::OsStr::to_str) != Some("md") {
11843                continue;
11844            }
11845            if !safe_store_rel_path(&rel_str) {
11846                return Err(LinkError::UnsafePath { path: rel_str });
11847            }
11848            let content = read_text(&rel_str)?;
11849            out.push((rel_str, content));
11850        }
11851    }
11852
11853    for rel in store.walk()? {
11854        let rel_str = rel.to_string_lossy().replace('\\', "/");
11855        if !safe_store_rel_path(&rel_str) {
11856            // A locally-legal name outside the hub's portable charset cannot
11857            // travel this wire; refusing beats silently dropping it.
11858            return Err(LinkError::UnsafePath { path: rel_str });
11859        }
11860        let content = read_text(&rel_str)?;
11861        out.push((rel_str, content));
11862    }
11863
11864    out.sort_by(|a, b| a.0.cmp(&b.0));
11865    Ok(out)
11866}
11867
11868/// Refuse to build a destructive whole-store snapshot from an ambiguous local
11869/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
11870/// silently omitted them could delete the hosted copies of those paths.
11871fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11872    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11873        return Err(LinkError::from(std::io::Error::new(
11874            std::io::ErrorKind::PermissionDenied,
11875            format!("cannot push: nested db.md store at {}", nested.display()),
11876        )));
11877    }
11878
11879    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11880        return Err(LinkError::from(std::io::Error::new(
11881            std::io::ErrorKind::PermissionDenied,
11882            format!(
11883                "cannot push: {} is a symlink outside the store ownership model",
11884                symlink.display()
11885            ),
11886        )));
11887    }
11888    Ok(())
11889}
11890
11891/// Push `files` to `brain` as a whole-store snapshot — the hub's push
11892/// semantics: the hosted copy becomes exactly this set (pull first if the
11893/// hosted side may have records the local copy lacks). Client-side caps
11894/// mirror the hub's JSON-path limits so an oversized push fails before the
11895/// upload.
11896pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11897    require_safe_ref(brain)?;
11898    let remote = verified_remote_head(cfg, brain, false)?;
11899    if files.len() > MAX_PUSH_FILES {
11900        return Err(LinkError::PushTooLarge {
11901            detail: format!("{} files", files.len()),
11902        });
11903    }
11904    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11905    if raw_total > MAX_STORE_BYTES {
11906        return Err(LinkError::PushTooLarge {
11907            detail: format!("{raw_total} uncompressed bytes"),
11908        });
11909    }
11910
11911    // Self-custody (a brain key is configured): the JSON fast path is
11912    // hub-signed by construction, so every push goes through the pack flow
11913    // with a locally signed entry — the hub verifies and can never sign.
11914    if cfg.brain_key.is_none() {
11915        let body = json!({
11916            "files": files
11917                .iter()
11918                .map(|(p, c)| json!({ "path": p, "content": c }))
11919                .collect::<Vec<_>>(),
11920        });
11921        if body.to_string().len() <= MAX_PUSH_BYTES {
11922            let path = format!("/api/hub/brains/{brain}/push");
11923            let pushed = ensure_ok(
11924                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11925                "sync push",
11926            )?;
11927            return Ok(pushed);
11928        }
11929    }
11930
11931    let pack = build_store_pack(files)?;
11932    if pack.len() as u64 > MAX_PACK_BYTES {
11933        return Err(LinkError::PushTooLarge {
11934            detail: format!("{} pack bytes", pack.len()),
11935        });
11936    }
11937    let sha256 = format!("{:x}", Sha256::digest(&pack));
11938    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11939    if let Some(key) = &cfg.brain_key {
11940        if !remote.head.verified {
11941            return Err(invalid_feed(
11942                "self-custody push requires a fully verified, unscoped feed head",
11943            ));
11944        }
11945        let identity = remote
11946            .identity
11947            .as_ref()
11948            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
11949        let current_multikey = format!("ed25519:{}", identity.fingerprint);
11950        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
11951            return Err(invalid_feed(
11952                "configured brain key is not the verified current brain identity",
11953            ));
11954        }
11955        // The verified state pins seq + prev; a concurrent writer surfaces as
11956        // the hub's 422 on commit (re-run to retry against the new head).
11957        let next_seq = remote
11958            .head
11959            .seq
11960            .checked_add(1)
11961            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
11962        let mut manifest: Vec<WireFeedFile> = files
11963            .iter()
11964            .map(|(path, content)| WireFeedFile {
11965                path: path.clone(),
11966                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
11967                bytes: content.len() as u64,
11968            })
11969            .collect();
11970        manifest.sort_by(|a, b| a.path.cmp(&b.path));
11971        let ts = crate::now()
11972            .with_timezone(&chrono::Utc)
11973            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11974            .to_string();
11975        let entry = self_custody_entry(
11976            key,
11977            next_seq,
11978            ts,
11979            &sha256,
11980            &manifest,
11981            remote.head.feed_hash.as_deref(),
11982        )?;
11983        meta["entry"] = Value::String(entry);
11984    }
11985    let presigned = ensure_ok(
11986        request(
11987            cfg,
11988            "POST",
11989            &format!("/api/hub/brains/{brain}/packs/presign"),
11990            Some(&meta),
11991            Auth::Required,
11992        )?,
11993        "prepare pack upload",
11994    )?;
11995    let url = presigned
11996        .get("url")
11997        .and_then(Value::as_str)
11998        .ok_or_else(|| LinkError::InvalidPack {
11999            message: "the hub returned no upload URL".to_string(),
12000        })?;
12001    put_presigned(
12002        cfg,
12003        url,
12004        presigned.get("headers").unwrap_or(&Value::Null),
12005        &pack,
12006    )?;
12007    let committed = ensure_ok(
12008        request(
12009            cfg,
12010            "POST",
12011            &format!("/api/hub/brains/{brain}/packs/commit"),
12012            Some(&meta),
12013            Auth::Required,
12014        )?,
12015        "commit pack",
12016    )?;
12017    Ok(committed)
12018}
12019
12020fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
12021    const LOCAL_HEADER: u32 = 0x0403_4b50;
12022    const CENTRAL_HEADER: u32 = 0x0201_4b50;
12023    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
12024    const VERSION_20: u16 = 20;
12025    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
12026    const UTF8_FLAG: u16 = 1 << 11;
12027    const STORED: u16 = 0;
12028    const DOS_TIME_MIDNIGHT: u16 = 0;
12029    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
12030    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
12031
12032    struct CentralEntry<'a> {
12033        name: &'a [u8],
12034        crc32: u32,
12035        size: u32,
12036        local_offset: u32,
12037    }
12038
12039    fn push_u16(out: &mut Vec<u8>, value: u16) {
12040        out.extend_from_slice(&value.to_le_bytes());
12041    }
12042
12043    fn push_u32(out: &mut Vec<u8>, value: u32) {
12044        out.extend_from_slice(&value.to_le_bytes());
12045    }
12046
12047    if files.is_empty() {
12048        return Err(LinkError::InvalidPack {
12049            message: "cannot create an empty snapshot pack".to_string(),
12050        });
12051    }
12052    if files.len() > u16::MAX as usize {
12053        return Err(LinkError::PushTooLarge {
12054            detail: format!(
12055                "{} files (canonical ZIP32 packs cap at {})",
12056                files.len(),
12057                u16::MAX
12058            ),
12059        });
12060    }
12061
12062    let mut sorted: Vec<_> = files.iter().collect();
12063    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
12064    let mut previous: Option<&str> = None;
12065    for (path, content) in &sorted {
12066        if !safe_store_rel_path(path) {
12067            return Err(LinkError::UnsafePath {
12068                path: (*path).clone(),
12069            });
12070        }
12071        if previous == Some(path.as_str()) {
12072            return Err(LinkError::InvalidPack {
12073                message: format!("duplicate path `{path}`"),
12074            });
12075        }
12076        previous = Some(path.as_str());
12077        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
12078            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
12079        })?;
12080    }
12081
12082    let mut out = Vec::new();
12083    let mut central = Vec::with_capacity(sorted.len());
12084    for (path, content) in sorted {
12085        let name = path.as_bytes();
12086        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
12087            message: format!("ZIP entry name is too long: `{path}`"),
12088        })?;
12089        let bytes = content.as_bytes();
12090        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
12091            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
12092        })?;
12093        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
12094            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
12095        })?;
12096        let crc32 = crc32fast::hash(bytes);
12097
12098        // Canonical local header: sizes and CRC are known up front. Bit 3 is
12099        // deliberately clear, so there is no trailing data descriptor.
12100        push_u32(&mut out, LOCAL_HEADER);
12101        push_u16(&mut out, VERSION_20);
12102        push_u16(&mut out, UTF8_FLAG);
12103        push_u16(&mut out, STORED);
12104        push_u16(&mut out, DOS_TIME_MIDNIGHT);
12105        push_u16(&mut out, DOS_DATE_1980_01_01);
12106        push_u32(&mut out, crc32);
12107        push_u32(&mut out, size);
12108        push_u32(&mut out, size);
12109        push_u16(&mut out, name_len);
12110        push_u16(&mut out, 0); // no local extra data
12111        out.extend_from_slice(name);
12112        out.extend_from_slice(bytes);
12113
12114        central.push(CentralEntry {
12115            name,
12116            crc32,
12117            size,
12118            local_offset,
12119        });
12120    }
12121
12122    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
12123        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
12124    })?;
12125    for entry in &central {
12126        push_u32(&mut out, CENTRAL_HEADER);
12127        push_u16(&mut out, MADE_BY_UNIX_20);
12128        push_u16(&mut out, VERSION_20);
12129        push_u16(&mut out, UTF8_FLAG);
12130        push_u16(&mut out, STORED);
12131        push_u16(&mut out, DOS_TIME_MIDNIGHT);
12132        push_u16(&mut out, DOS_DATE_1980_01_01);
12133        push_u32(&mut out, entry.crc32);
12134        push_u32(&mut out, entry.size);
12135        push_u32(&mut out, entry.size);
12136        push_u16(&mut out, entry.name.len() as u16);
12137        push_u16(&mut out, 0); // no central extra data
12138        push_u16(&mut out, 0); // no file comment
12139        push_u16(&mut out, 0); // disk number
12140        push_u16(&mut out, 0); // internal attributes
12141        push_u32(&mut out, UNIX_REGULAR_0600);
12142        push_u32(&mut out, entry.local_offset);
12143        out.extend_from_slice(entry.name);
12144    }
12145    let central_size = u32::try_from(out.len())
12146        .ok()
12147        .and_then(|end| end.checked_sub(central_offset))
12148        .ok_or_else(|| LinkError::PushTooLarge {
12149            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
12150        })?;
12151    let entry_count = central.len() as u16;
12152
12153    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
12154    push_u16(&mut out, 0); // this disk
12155    push_u16(&mut out, 0); // central directory disk
12156    push_u16(&mut out, entry_count);
12157    push_u16(&mut out, entry_count);
12158    push_u32(&mut out, central_size);
12159    push_u32(&mut out, central_offset);
12160    push_u16(&mut out, 0); // no archive comment
12161
12162    if out.len() > u32::MAX as usize {
12163        return Err(LinkError::PushTooLarge {
12164            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
12165        });
12166    }
12167    Ok(out)
12168}
12169
12170// ─────────────────────────────────────────────────────────────────────────────
12171// grant — issue / list / revoke capabilities (owner-side)
12172// ─────────────────────────────────────────────────────────────────────────────
12173
12174/// The two capabilities a v0 hub enforces.
12175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12176pub enum Capability {
12177    /// Read the granted slice.
12178    Read,
12179    /// Read and push (whole-store; a path-scoped grant is read-only).
12180    Write,
12181}
12182
12183impl Capability {
12184    /// The wire form.
12185    pub fn as_str(self) -> &'static str {
12186        match self {
12187            Capability::Read => "read",
12188            Capability::Write => "write",
12189        }
12190    }
12191}
12192
12193/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
12194/// by email in v0 (the protocol's near-term simplification; key-named
12195/// grantees arrive with the signing layer). `scope` is a store-path prefix
12196/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
12197/// revoked.
12198pub fn grant_issue(
12199    cfg: &HubConfig,
12200    brain: &str,
12201    grantee: &str,
12202    can: Capability,
12203    scope: Option<&str>,
12204    until: Option<&str>,
12205) -> LinkResult<Value> {
12206    require_safe_ref(brain)?;
12207    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
12208    // multikey holder (link.md §6 cross-party keys — no hub account; the
12209    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
12210    // hub principal named by email.
12211    let is_key_grantee = URL_SAFE_NO_PAD
12212        .decode(grantee)
12213        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
12214        .unwrap_or(false);
12215    if let Some(head) = v2_verified_head(cfg, brain)? {
12216        if is_key_grantee {
12217            let scope = scope.unwrap_or("");
12218            let preset = match can {
12219                Capability::Read => "viewer",
12220                Capability::Write => "editor",
12221            };
12222            let entropy = format!(
12223                "{}\0{}\0{}\0{}\0{}\0{}\0{}",
12224                normalized_origin(&cfg.hub)?,
12225                head.brain_id,
12226                head.control_revision,
12227                grantee,
12228                preset,
12229                scope,
12230                until.unwrap_or("")
12231            );
12232            let mut body = json!({
12233                "context": "external",
12234                "expected_control_revision": head.control_revision,
12235                "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
12236                "preset": preset,
12237                "principal_kind": "key",
12238                "public_key": grantee,
12239                "scope": scope,
12240                "scope_kind": "prefix",
12241            });
12242            if let Some(value) = until {
12243                body["expires_at"] = json!(value);
12244            }
12245            let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
12246            let response = ensure_ok(
12247                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12248                "v2 grant issue",
12249            )?;
12250            let expected_fingerprint = identity_fingerprint(grantee)?;
12251            if response.get("v").and_then(Value::as_u64) != Some(2)
12252                || response
12253                    .get("id")
12254                    .and_then(Value::as_str)
12255                    .is_none_or(|id| !crate::ulid::is_ulid(id))
12256                || response.get("principal_kind").and_then(Value::as_str) != Some("key")
12257                || response.get("principal_id").and_then(Value::as_str)
12258                    != Some(expected_fingerprint.as_str())
12259                || response
12260                    .get("control_revision")
12261                    .and_then(Value::as_str)
12262                    .is_none_or(|value| !is_sha256(value))
12263            {
12264                return Err(invalid_feed(
12265                    "v2 grant issue response is not authority-bound",
12266                ));
12267            }
12268            return Ok(response);
12269        }
12270        // Account invitations remain a product workflow. The compatibility
12271        // endpoint resolves email/account identity, then commits a native v2
12272        // grant with the same control-plane recovery boundary. The v2 head
12273        // above is already the authenticated authority checkpoint; never
12274        // fall through to the retired v1 feed verifier for this request.
12275        let mut body = json!({ "email": grantee, "capability": can.as_str() });
12276        if let Some(value) = scope {
12277            body["scopePrefix"] = json!(value);
12278        }
12279        if let Some(value) = until {
12280            body["expiresAt"] = json!(value);
12281        }
12282        let path = format!("/api/hub/brains/{}/grants", head.brain_id);
12283        return ensure_ok(
12284            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12285            "account grant issue",
12286        );
12287    }
12288    let _ = verified_remote_head(cfg, brain, false)?;
12289    let mut body = if is_key_grantee {
12290        json!({ "keySpki": grantee, "capability": can.as_str() })
12291    } else {
12292        json!({ "email": grantee, "capability": can.as_str() })
12293    };
12294    if let Some(s) = scope {
12295        body["scopePrefix"] = json!(s);
12296    }
12297    if let Some(u) = until {
12298        body["expiresAt"] = json!(u);
12299    }
12300    let path = format!("/api/hub/brains/{brain}/grants");
12301    ensure_ok(
12302        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12303        "grant issue",
12304    )
12305}
12306
12307/// List the active grants (and pending invites) on `brain`. Owner-side.
12308pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
12309    require_safe_ref(brain)?;
12310    if let Some(head) = v2_verified_head(cfg, brain)? {
12311        let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
12312        let response = ensure_ok(
12313            request(cfg, "GET", &path, None, Auth::Required)?,
12314            "v2 grant list",
12315        )?;
12316        if response.get("v").and_then(Value::as_u64) != Some(2)
12317            || response.get("control_revision").and_then(Value::as_str)
12318                != Some(head.control_revision.as_str())
12319            || !response.get("grants").is_some_and(Value::is_array)
12320        {
12321            return Err(invalid_feed(
12322                "v2 grant list is not bound to the verified authority",
12323            ));
12324        }
12325        return Ok(response);
12326    }
12327    let _ = verified_remote_head(cfg, brain, false)?;
12328    let path = format!("/api/hub/brains/{brain}/grants");
12329    ensure_ok(
12330        request(cfg, "GET", &path, None, Auth::Required)?,
12331        "grant list",
12332    )
12333}
12334
12335/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
12336/// is soft on the hub (the audit trail survives).
12337pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
12338    require_safe_ref(brain)?;
12339    require_safe_grant_id(grant_id)?;
12340    if let Some(head) = v2_verified_head(cfg, brain)? {
12341        let entropy = format!(
12342            "{}\0{}\0{}\0{}",
12343            normalized_origin(&cfg.hub)?,
12344            head.brain_id,
12345            head.control_revision,
12346            grant_id
12347        );
12348        let body = json!({
12349            "expected_control_revision": head.control_revision,
12350            "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
12351        });
12352        let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
12353        let response = ensure_ok(
12354            request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12355            "v2 grant revoke",
12356        )?;
12357        if response.get("v").and_then(Value::as_u64) != Some(2)
12358            || response.get("id").and_then(Value::as_str) != Some(grant_id)
12359            || response.get("revoked").and_then(Value::as_bool) != Some(true)
12360            || response
12361                .get("control_revision")
12362                .and_then(Value::as_str)
12363                .is_none_or(|value| !is_sha256(value))
12364        {
12365            return Err(invalid_feed(
12366                "v2 grant revocation response is not authority-bound",
12367            ));
12368        }
12369        return Ok(response);
12370    }
12371    let _ = verified_remote_head(cfg, brain, false)?;
12372    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
12373    ensure_ok(
12374        request(cfg, "DELETE", &path, None, Auth::Required)?,
12375        "grant revoke",
12376    )
12377}
12378
12379// ─────────────────────────────────────────────────────────────────────────────
12380// permissioned v2 proposals — inspect, accept, reject
12381// ─────────────────────────────────────────────────────────────────────────────
12382
12383#[derive(Debug)]
12384struct VerifiedV2Proposal {
12385    value: Value,
12386    changes: Value,
12387    blobs: Vec<(String, u64, String)>,
12388}
12389
12390fn require_proposal_id(id: &str) -> LinkResult<()> {
12391    if crate::ulid::is_ulid(id) {
12392        Ok(())
12393    } else {
12394        Err(invalid_feed("proposal id is not a lowercase ULID"))
12395    }
12396}
12397
12398fn verified_v2_proposal(
12399    cfg: &HubConfig,
12400    head: &V2VerifiedHead,
12401    proposal_id: &str,
12402) -> LinkResult<VerifiedV2Proposal> {
12403    require_proposal_id(proposal_id)?;
12404    if head.view_kind != "full" {
12405        return Err(invalid_feed(
12406            "proposal review requires a full readable view",
12407        ));
12408    }
12409    let path = format!(
12410        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12411        head.brain_id
12412    );
12413    let value = ensure_ok(
12414        request_capped(
12415            cfg,
12416            "GET",
12417            &path,
12418            None,
12419            Auth::Required,
12420            MAX_FEED_RESPONSE_BYTES,
12421        )?,
12422        "v2 proposal",
12423    )?;
12424    verify_v2_proposal_value(head, proposal_id, value)
12425}
12426
12427fn verify_v2_proposal_value(
12428    head: &V2VerifiedHead,
12429    proposal_id: &str,
12430    value: Value,
12431) -> LinkResult<VerifiedV2Proposal> {
12432    if value.get("v").and_then(Value::as_u64) != Some(2) {
12433        return Err(invalid_feed("proposal response has an invalid version"));
12434    }
12435    let proposal = value
12436        .get("proposal")
12437        .and_then(Value::as_object)
12438        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
12439    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
12440        return Err(invalid_feed("proposal response changed its id"));
12441    }
12442    let payload_hash = proposal
12443        .get("payload_sha256")
12444        .and_then(Value::as_str)
12445        .filter(|hash| is_sha256(hash))
12446        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
12447    let clear_hash = proposal
12448        .get("clear_sha256")
12449        .and_then(Value::as_str)
12450        .filter(|hash| is_sha256(hash))
12451        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
12452    let submission_hash = proposal
12453        .get("submission_claim_sha256")
12454        .and_then(Value::as_str)
12455        .filter(|hash| is_sha256(hash))
12456        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
12457    let submission = STANDARD
12458        .decode(
12459            proposal
12460                .get("submission_claim_base64")
12461                .and_then(Value::as_str)
12462                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
12463        )
12464        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
12465    let submission_value: Value = serde_json::from_slice(&submission)
12466        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
12467    if crate::linkmd_v2::canonical_bytes(&submission_value)
12468        .map_err(|error| invalid_feed(error.to_string()))?
12469        != submission
12470        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
12471            .map_err(|error| invalid_feed(error.to_string()))?
12472            != submission_hash
12473    {
12474        return Err(invalid_feed(
12475            "proposal submission claim is not canonical or addressed",
12476        ));
12477    }
12478    let envelope = submission_value
12479        .as_object()
12480        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
12481    let claim = envelope
12482        .get("claim")
12483        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
12484    let claim_object = claim
12485        .as_object()
12486        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
12487    let actor_root = claim_object
12488        .get("actor_root")
12489        .and_then(Value::as_object)
12490        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
12491    let public_key = envelope
12492        .get("public_key")
12493        .and_then(Value::as_str)
12494        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
12495    let fingerprint = envelope
12496        .get("fingerprint")
12497        .and_then(Value::as_str)
12498        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
12499    let signature = envelope
12500        .get("sig")
12501        .and_then(Value::as_str)
12502        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
12503    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
12504        .map_err(|error| invalid_feed(error.to_string()))?;
12505    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
12506    let signer = format!("{fingerprint}:{public_key}");
12507    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
12508    let grants = actor_root.get("grants").and_then(Value::as_array);
12509    let grants_are_canonical = grants.is_some_and(|items| {
12510        let mut prior: Option<&str> = None;
12511        items.iter().all(|item| {
12512            let Some(grant) = item.as_str() else {
12513                return false;
12514            };
12515            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
12516                return false;
12517            }
12518            prior = Some(grant);
12519            true
12520        })
12521    });
12522    let optional_actor_field = |name: &str| {
12523        actor_root.get(name).is_some_and(|value| {
12524            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
12525        })
12526    };
12527    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
12528    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
12529        || format!("{:x}", Sha256::digest(&der)) != fingerprint
12530        || head
12531            .trust
12532            .hub_signer
12533            .as_ref()
12534            .is_some_and(|known| known != &signer)
12535        || !matches!(
12536            actor_class,
12537            Some(
12538                "user"
12539                    | "owned_agent"
12540                    | "foreign_key"
12541                    | "curation"
12542                    | "inbox"
12543                    | "restore"
12544                    | "migration"
12545                    | "operator_recovery"
12546            )
12547        )
12548        || actor_root
12549            .get("principal")
12550            .and_then(Value::as_str)
12551            .is_none_or(|value| value.is_empty())
12552        || actor_root
12553            .get("credential")
12554            .and_then(Value::as_str)
12555            .is_none_or(|value| value.is_empty())
12556        || !optional_actor_field("organization")
12557        || !optional_actor_field("role")
12558        || !grants_are_canonical
12559        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
12560        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12561        || !claim_object
12562            .get("mutation_id")
12563            .and_then(Value::as_str)
12564            .is_some_and(|value| {
12565                !value.is_empty()
12566                    && value.len() <= 128
12567                    && value.chars().enumerate().all(|(index, char)| {
12568                        char.is_ascii_alphanumeric()
12569                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
12570                    })
12571            })
12572        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
12573        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
12574        || !claim_object
12575            .get("control_revision")
12576            .and_then(Value::as_str)
12577            .is_some_and(is_sha256)
12578        || submitted_at.is_none_or(|value| {
12579            chrono::DateTime::parse_from_rfc3339(value).is_err()
12580                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
12581        })
12582        || !proposal
12583            .get("state")
12584            .and_then(Value::as_str)
12585            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
12586        || proposal
12587            .get("expires_at")
12588            .and_then(Value::as_str)
12589            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
12590        || proposal
12591            .get("proposer")
12592            .and_then(Value::as_object)
12593            .and_then(|value| value.get("class"))
12594            .and_then(Value::as_str)
12595            != actor_class
12596    {
12597        return Err(invalid_feed(
12598            "proposal submission claim does not bind the verified proposal",
12599        ));
12600    }
12601    let changes_b64 = proposal
12602        .get("changes_base64")
12603        .and_then(Value::as_str)
12604        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
12605    let changes_bytes = STANDARD
12606        .decode(changes_b64)
12607        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
12608    let changes: Value = serde_json::from_slice(&changes_bytes)
12609        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
12610    if crate::linkmd_v2::canonical_bytes(&changes)
12611        .map_err(|error| invalid_feed(error.to_string()))?
12612        != changes_bytes
12613        || changes.get("v").and_then(Value::as_u64) != Some(2)
12614        || !changes.get("operations").is_some_and(Value::is_array)
12615    {
12616        return Err(invalid_feed("proposal changeset is not canonical v2"));
12617    }
12618    let blob_values = proposal
12619        .get("blobs")
12620        .and_then(Value::as_array)
12621        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
12622    let mut blobs = Vec::with_capacity(blob_values.len());
12623    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
12624    let mut prior_hash: Option<String> = None;
12625    for item in blob_values {
12626        let hash = item
12627            .get("sha256")
12628            .and_then(Value::as_str)
12629            .filter(|hash| is_sha256(hash))
12630            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
12631        let bytes = item
12632            .get("bytes")
12633            .and_then(Value::as_u64)
12634            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
12635            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
12636        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
12637            return Err(invalid_feed(
12638                "proposal blob declarations are not unique and sorted",
12639            ));
12640        }
12641        prior_hash = Some(hash.to_string());
12642        let endpoint = item
12643            .get("endpoint")
12644            .and_then(Value::as_str)
12645            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
12646        let expected_endpoint = format!(
12647            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
12648            head.brain_id
12649        );
12650        if endpoint != expected_endpoint {
12651            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
12652        }
12653        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
12654        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
12655    }
12656    let descriptor = json!({
12657        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
12658        "blobs": descriptor_blobs,
12659        "changes_base64": changes_b64,
12660        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
12661        "v": 2,
12662    });
12663    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
12664        .map_err(|error| invalid_feed(error.to_string()))?;
12665    if content_sha256(&descriptor_bytes) != clear_hash {
12666        return Err(invalid_feed(
12667            "proposal clear payload differs from its signed submission claim",
12668        ));
12669    }
12670    Ok(VerifiedV2Proposal {
12671        value,
12672        changes,
12673        blobs,
12674    })
12675}
12676
12677pub fn proposal_list(
12678    cfg: &HubConfig,
12679    brain: &str,
12680    state: &str,
12681    after: Option<&str>,
12682    limit: usize,
12683) -> LinkResult<Value> {
12684    require_safe_ref(brain)?;
12685    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
12686        return Err(invalid_feed("proposal state is invalid"));
12687    }
12688    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
12689        return Err(invalid_feed("proposal cursor is invalid"));
12690    }
12691    let head = v2_verified_head(cfg, brain)?
12692        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12693    let path = format!(
12694        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
12695        head.brain_id,
12696        limit.clamp(1, 100),
12697        after.map_or_else(String::new, |value| format!("&after={value}"))
12698    );
12699    ensure_ok(
12700        request_capped(
12701            cfg,
12702            "GET",
12703            &path,
12704            None,
12705            Auth::Required,
12706            MAX_FEED_RESPONSE_BYTES,
12707        )?,
12708        "v2 proposal list",
12709    )
12710}
12711
12712pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
12713    require_safe_ref(brain)?;
12714    let head = v2_verified_head(cfg, brain)?
12715        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12716    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
12717}
12718
12719pub fn proposal_reject(
12720    cfg: &HubConfig,
12721    brain: &str,
12722    proposal_id: &str,
12723    mutation_id: &str,
12724    reason: &str,
12725) -> LinkResult<Value> {
12726    require_safe_ref(brain)?;
12727    require_proposal_id(proposal_id)?;
12728    let head = v2_verified_head(cfg, brain)?
12729        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12730    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
12731    let body = json!({
12732        "mutation_id": mutation_id,
12733        "control_revision": head.control_revision,
12734        "reason": reason,
12735    });
12736    let path = format!(
12737        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12738        head.brain_id
12739    );
12740    ensure_ok(
12741        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12742        "v2 proposal rejection",
12743    )
12744}
12745
12746pub fn proposal_accept_exact(
12747    cfg: &HubConfig,
12748    brain: &str,
12749    proposal_id: &str,
12750    mutation_id: &str,
12751    reason: &str,
12752) -> LinkResult<Value> {
12753    require_safe_ref(brain)?;
12754    require_proposal_id(proposal_id)?;
12755    let head = v2_verified_head(cfg, brain)?
12756        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12757    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
12758    let operations = proposal
12759        .changes
12760        .get("operations")
12761        .and_then(Value::as_array)
12762        .cloned()
12763        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
12764    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
12765        return Err(invalid_feed("proposal operation count is invalid"));
12766    }
12767    let mut downloaded = std::collections::BTreeMap::new();
12768    for (hash, bytes, endpoint) in &proposal.blobs {
12769        let body = ensure_raw_ok(
12770            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
12771            "v2 proposal blob",
12772        )?;
12773        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
12774            return Err(invalid_feed("proposal blob does not match its declaration"));
12775        }
12776        downloaded.insert(hash.clone(), body);
12777    }
12778    let remote = files_for_v2_view(
12779        &head,
12780        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
12781    );
12782    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
12783    let mut expected_candidate = remote.clone();
12784    let mut expected_candidate_assets = remote_assets;
12785    for operation in &operations {
12786        let op = operation
12787            .get("op")
12788            .and_then(Value::as_str)
12789            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
12790        match op {
12791            "put" | "restore" => {
12792                let path = operation
12793                    .get("path")
12794                    .and_then(Value::as_str)
12795                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
12796                crate::linkmd_v2::normalize_path(path)
12797                    .map_err(|error| invalid_feed(error.to_string()))?;
12798                let hash = operation
12799                    .get("blob")
12800                    .and_then(Value::as_str)
12801                    .filter(|hash| is_sha256(hash))
12802                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12803                let bytes = operation
12804                    .get("bytes")
12805                    .and_then(Value::as_u64)
12806                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12807                expected_candidate.insert(
12808                    path.to_string(),
12809                    V2BaselineFile {
12810                        sha256: hash.to_string(),
12811                        bytes,
12812                        proof: None,
12813                    },
12814                );
12815            }
12816            "delete" | "withdraw_from_hosting" => {
12817                let path = operation
12818                    .get("path")
12819                    .and_then(Value::as_str)
12820                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12821                crate::linkmd_v2::normalize_path(path)
12822                    .map_err(|error| invalid_feed(error.to_string()))?;
12823                expected_candidate.remove(path);
12824            }
12825            "rename" => {
12826                let from = operation
12827                    .get("from")
12828                    .and_then(Value::as_str)
12829                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12830                let to = operation
12831                    .get("to")
12832                    .and_then(Value::as_str)
12833                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12834                crate::linkmd_v2::normalize_path(from)
12835                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
12836                    .map_err(|error| invalid_feed(error.to_string()))?;
12837                let hash = operation
12838                    .get("blob")
12839                    .and_then(Value::as_str)
12840                    .filter(|hash| is_sha256(hash))
12841                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12842                let bytes = operation
12843                    .get("bytes")
12844                    .and_then(Value::as_u64)
12845                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12846                expected_candidate.remove(from);
12847                expected_candidate.insert(
12848                    to.to_string(),
12849                    V2BaselineFile {
12850                        sha256: hash.to_string(),
12851                        bytes,
12852                        proof: None,
12853                    },
12854                );
12855            }
12856            "asset_delete" => {
12857                let path = operation
12858                    .get("path")
12859                    .and_then(Value::as_str)
12860                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12861                expected_candidate_assets.remove(path);
12862            }
12863            "asset_withdraw" => {
12864                let path = operation
12865                    .get("path")
12866                    .and_then(Value::as_str)
12867                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12868                if !expected_candidate_assets.contains_key(path) {
12869                    return Err(invalid_feed("proposal withdraws an unknown asset"));
12870                }
12871                let Some(asset) = operation.get("asset").and_then(Value::as_object) else {
12872                    // Pre-supersession proposals carried no replacement leaf.
12873                    // Preserve their exact historical meaning while all new
12874                    // pushes include the explicit withheld value.
12875                    let prior = expected_candidate_assets
12876                        .get_mut(path)
12877                        .expect("presence checked above");
12878                    prior.disposition = "withheld".to_string();
12879                    prior.leaf_hash.clear();
12880                    continue;
12881                };
12882                let blob_sha256 = asset
12883                    .get("blob_sha256")
12884                    .and_then(Value::as_str)
12885                    .filter(|hash| is_sha256(hash))
12886                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12887                let bytes = asset
12888                    .get("bytes")
12889                    .and_then(Value::as_u64)
12890                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12891                let media_type = asset
12892                    .get("media_type")
12893                    .and_then(Value::as_str)
12894                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12895                let wrappers = asset
12896                    .get("wrappers")
12897                    .and_then(Value::as_array)
12898                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12899                    .iter()
12900                    .map(|wrapper| {
12901                        wrapper
12902                            .as_str()
12903                            .map(str::to_string)
12904                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12905                    })
12906                    .collect::<LinkResult<Vec<_>>>()?;
12907                let required = asset
12908                    .get("required")
12909                    .and_then(Value::as_bool)
12910                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12911                if asset.get("disposition").and_then(Value::as_str) != Some("withheld") {
12912                    return Err(invalid_feed("proposal asset withdrawal is not withheld"));
12913                }
12914                expected_candidate_assets.insert(
12915                    path.to_string(),
12916                    V2BaselineAsset {
12917                        blob_sha256: blob_sha256.to_string(),
12918                        bytes,
12919                        media_type: media_type.to_string(),
12920                        wrappers,
12921                        required,
12922                        disposition: "withheld".to_string(),
12923                        leaf_hash: String::new(),
12924                    },
12925                );
12926            }
12927            "asset_put" | "asset_resume" => {
12928                let path = operation
12929                    .get("path")
12930                    .and_then(Value::as_str)
12931                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12932                let asset = operation
12933                    .get("asset")
12934                    .and_then(Value::as_object)
12935                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12936                let blob_sha256 = asset
12937                    .get("blob_sha256")
12938                    .and_then(Value::as_str)
12939                    .filter(|hash| is_sha256(hash))
12940                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12941                let bytes = asset
12942                    .get("bytes")
12943                    .and_then(Value::as_u64)
12944                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12945                let media_type = asset
12946                    .get("media_type")
12947                    .and_then(Value::as_str)
12948                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12949                let wrappers = asset
12950                    .get("wrappers")
12951                    .and_then(Value::as_array)
12952                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12953                    .iter()
12954                    .map(|wrapper| {
12955                        wrapper
12956                            .as_str()
12957                            .map(str::to_string)
12958                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12959                    })
12960                    .collect::<LinkResult<Vec<_>>>()?;
12961                let required = asset
12962                    .get("required")
12963                    .and_then(Value::as_bool)
12964                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12965                let disposition = asset
12966                    .get("disposition")
12967                    .and_then(Value::as_str)
12968                    .filter(|value| matches!(*value, "hosted" | "withheld"))
12969                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
12970                expected_candidate_assets.insert(
12971                    path.to_string(),
12972                    V2BaselineAsset {
12973                        blob_sha256: blob_sha256.to_string(),
12974                        bytes,
12975                        media_type: media_type.to_string(),
12976                        wrappers,
12977                        required,
12978                        disposition: disposition.to_string(),
12979                        leaf_hash: String::new(),
12980                    },
12981                );
12982            }
12983            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
12984        }
12985    }
12986    let base = head.pointer.as_ref().map(|pointer| {
12987        json!({
12988            "seq": pointer.seq,
12989            "commit_hash": pointer.commit_hash,
12990            "content_root": pointer.content_root,
12991            "asset_root": pointer.asset_root,
12992        })
12993    });
12994    let mut body = json!({
12995        "mutation_id": mutation_id,
12996        "base": base,
12997        "rebase": "strict",
12998        "reason": reason,
12999        "operations": operations,
13000        "blobs": downloaded
13001            .iter()
13002            .map(|(sha256, bytes)| json!({
13003                "sha256": sha256,
13004                "bytes": bytes.len(),
13005                "content_base64": STANDARD.encode(bytes),
13006            }))
13007            .collect::<Vec<_>>(),
13008        "proposal_id": proposal_id,
13009        "proposal_mode": "exact",
13010    });
13011    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
13012        total
13013            .checked_add(bytes.len())
13014            .ok_or_else(|| LinkError::PushTooLarge {
13015                detail: "proposal changed-byte total overflow".to_string(),
13016            })
13017    })?;
13018    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
13019        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
13020        for operation in &operations {
13021            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
13022                return Err(invalid_feed("proposal upload operation has no kind"));
13023            };
13024            let hash = match kind {
13025                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
13026                "asset_put" | "asset_resume" => operation
13027                    .get("asset")
13028                    .and_then(|asset| asset.get("blob_sha256"))
13029                    .and_then(Value::as_str),
13030                _ => None,
13031            };
13032            let Some(hash) = hash else { continue };
13033            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
13034            if kind == "rename" {
13035                for field in ["from", "to"] {
13036                    coordinates.insert(
13037                        operation
13038                            .get(field)
13039                            .and_then(Value::as_str)
13040                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
13041                            .to_string(),
13042                    );
13043                }
13044            } else {
13045                let path = operation
13046                    .get("path")
13047                    .and_then(Value::as_str)
13048                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
13049                coordinates.insert(if kind.starts_with("asset_") {
13050                    format!("assets/{path}")
13051                } else {
13052                    path.to_string()
13053                });
13054            }
13055        }
13056        let declarations = downloaded
13057            .iter()
13058            .map(|(sha256, bytes)| {
13059                json!({
13060                    "sha256": sha256,
13061                    "bytes": bytes.len(),
13062                    "coordinates": coordinates_by_hash
13063                        .get(sha256)
13064                        .into_iter()
13065                        .flatten()
13066                        .collect::<Vec<_>>(),
13067                })
13068            })
13069            .collect::<Vec<_>>();
13070        let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
13071        for batch in batch_upload_declarations(declarations) {
13072            let reserved = reserve_upload_window(
13073                cfg,
13074                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
13075                &json!({ "blobs": batch }),
13076                "prepare proposal blob transport",
13077            )?;
13078            let reserved_items = reserved
13079                .get("uploads")
13080                .and_then(Value::as_array)
13081                .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
13082            items.extend(reserved_items.iter().cloned());
13083        }
13084        if items.len() != downloaded.len() {
13085            return Err(invalid_feed("proposal upload reservation changed the set"));
13086        }
13087        let mut references = Vec::with_capacity(items.len());
13088        for item in items {
13089            let hash = item
13090                .get("sha256")
13091                .and_then(Value::as_str)
13092                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
13093            let bytes = downloaded
13094                .get(hash)
13095                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
13096            let reservation_id = item
13097                .get("reservation_id")
13098                .and_then(Value::as_str)
13099                .filter(|id| crate::ulid::is_ulid(id))
13100                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
13101            let expected_coordinates = coordinates_by_hash
13102                .get(hash)
13103                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
13104            let returned_coordinates = item
13105                .get("coordinates")
13106                .and_then(Value::as_array)
13107                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
13108            if returned_coordinates.len() != expected_coordinates.len()
13109                || returned_coordinates
13110                    .iter()
13111                    .zip(expected_coordinates)
13112                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
13113            {
13114                return Err(invalid_feed(
13115                    "proposal upload reservation changed its coordinates",
13116                ));
13117            }
13118            match item.get("status").and_then(Value::as_str) {
13119                Some("upload") => put_presigned(
13120                    cfg,
13121                    item.get("url")
13122                        .and_then(Value::as_str)
13123                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
13124                    item.get("headers").unwrap_or(&Value::Null),
13125                    bytes,
13126                )?,
13127                Some("already_present") => {}
13128                _ => return Err(invalid_feed("proposal upload status is invalid")),
13129            }
13130            references.push(json!({
13131                "sha256": hash,
13132                "bytes": bytes.len(),
13133                "reservation_id": reservation_id,
13134            }));
13135        }
13136        body["blobs"] = Value::Array(references);
13137    }
13138    // A proposal can be queued through the staged lane, so acceptance has to be
13139    // able to restate it. Capping only this side would make a large proposal
13140    // permanently unacceptable.
13141    stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
13142    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
13143    let mut result = ensure_ok(
13144        request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
13145        "exact proposal acceptance",
13146    )?;
13147    let mut candidate_hub_signer = None;
13148    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
13149        let request_id = result
13150            .get("request_id")
13151            .and_then(Value::as_str)
13152            .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
13153            .to_string();
13154        let challenge = result
13155            .get("signing_challenge")
13156            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
13157        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
13158            cfg,
13159            &head,
13160            &expected_candidate,
13161            &expected_candidate_assets,
13162            mutation_id,
13163            &v2_signed_request_view(&body, &operations),
13164            challenge,
13165        )?;
13166        body["signing_challenge_id"] = Value::String(challenge_id);
13167        body["signature_base64url"] = Value::String(signature);
13168        candidate_hub_signer = Some(actor_signer);
13169        result = ensure_ok(
13170            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
13171            "signed exact proposal acceptance",
13172        )?;
13173    }
13174    let refreshed = v2_verified_head(cfg, brain)?
13175        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
13176    if candidate_hub_signer
13177        .as_ref()
13178        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
13179        || refreshed
13180            .pointer
13181            .as_ref()
13182            .map(|pointer| pointer.commit_hash.as_str())
13183            != result.get("commit_hash").and_then(Value::as_str)
13184        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
13185        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
13186    {
13187        return Err(LinkError::RemoteAdvancedDuringSync);
13188    }
13189    accept_v2_head(cfg, &refreshed)?;
13190    Ok(result)
13191}
13192
13193// ─────────────────────────────────────────────────────────────────────────────
13194// propose — write without trust: evidence into the owner's inbox
13195// ─────────────────────────────────────────────────────────────────────────────
13196
13197/// Submit `body` to the published site `handle`, addressed to its app page
13198/// `app` (a page that declares the `write-inbox` capability). Deliberately
13199/// unauthenticated — this is the cross-party door; the submission lands as
13200/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
13201/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
13202/// receipt.
13203pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
13204    require_valid_handle(handle)?;
13205    if body.len() as u64 > MAX_PROPOSE_BYTES {
13206        return Err(LinkError::ProposeTooLarge {
13207            bytes: body.len() as u64,
13208        });
13209    }
13210    let payload = json!({ "app": app, "body": body });
13211    // A ULID-shaped target is a bare brain address (link.md §7.4's
13212    // generalization): the brain inbox door, open on public brains, where a
13213    // configured credential earns a bigger actor-class budget. Anything else
13214    // is a published-site handle: that door is unauthenticated by design.
13215    let (path, auth) = if crate::ulid::is_ulid(handle) {
13216        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
13217    } else {
13218        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
13219    };
13220    ensure_ok(
13221        request(cfg, "POST", &path, Some(&payload), auth)?,
13222        "propose",
13223    )
13224}
13225
13226// ─────────────────────────────────────────────────────────────────────────────
13227// subscribe — follow feed-head movement
13228// ─────────────────────────────────────────────────────────────────────────────
13229
13230/// One observation of a brain's feed head.
13231#[derive(Debug, serde::Serialize)]
13232pub struct Head {
13233    /// The brain id.
13234    pub brain: String,
13235    /// The hub's durable feed cursor — advances on every accepted write.
13236    pub seq: u64,
13237    /// The hub's `updatedAt` for the brain, when present.
13238    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
13239    pub updated_at: Option<String>,
13240    /// SHA-256 of the exact signed head entry.
13241    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
13242    pub feed_hash: Option<String>,
13243    /// Whether the head entry's content hash, identity, and Ed25519 signature
13244    /// were verified locally. Path-scoped grants get head movement only.
13245    pub verified: bool,
13246}
13247
13248struct BoundedVecVisitor<T, const MAX: usize> {
13249    label: &'static str,
13250    marker: std::marker::PhantomData<T>,
13251}
13252
13253impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
13254where
13255    T: Deserialize<'de>,
13256{
13257    type Value = Vec<T>;
13258
13259    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13260        write!(formatter, "at most {MAX} {}", self.label)
13261    }
13262
13263    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
13264    where
13265        A: serde::de::SeqAccess<'de>,
13266    {
13267        if sequence.size_hint().is_some_and(|size| size > MAX) {
13268            return Err(serde::de::Error::custom(format!(
13269                "{} exceeds the {MAX}-item limit",
13270                self.label
13271            )));
13272        }
13273        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
13274        while let Some(value) = sequence.next_element()? {
13275            if values.len() == MAX {
13276                return Err(serde::de::Error::custom(format!(
13277                    "{} exceeds the {MAX}-item limit",
13278                    self.label
13279                )));
13280            }
13281            values.push(value);
13282        }
13283        Ok(values)
13284    }
13285}
13286
13287fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
13288    deserializer: D,
13289    label: &'static str,
13290) -> Result<Vec<T>, D::Error>
13291where
13292    D: serde::Deserializer<'de>,
13293    T: Deserialize<'de>,
13294{
13295    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
13296        label,
13297        marker: std::marker::PhantomData,
13298    })
13299}
13300
13301fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
13302where
13303    D: serde::Deserializer<'de>,
13304{
13305    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
13306}
13307
13308fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
13309where
13310    D: serde::Deserializer<'de>,
13311{
13312    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
13313}
13314
13315fn deserialize_previous_identities<'de, D>(
13316    deserializer: D,
13317) -> Result<Vec<PreviousIdentity>, D::Error>
13318where
13319    D: serde::Deserializer<'de>,
13320{
13321    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
13322        deserializer,
13323        "previous identities",
13324    )
13325}
13326
13327fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
13328where
13329    D: serde::Deserializer<'de>,
13330{
13331    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
13332        deserializer,
13333        "rotation statements",
13334    )
13335}
13336
13337fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
13338where
13339    D: serde::Deserializer<'de>,
13340{
13341    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
13342}
13343
13344#[derive(Debug, Clone, Deserialize, Serialize)]
13345struct FeedFile {
13346    path: String,
13347    sha256: String,
13348    bytes: u64,
13349}
13350
13351#[cfg(test)]
13352#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13353enum V1DisclosureError {
13354    DuplicateFile,
13355    DuplicateRemoved,
13356    PushManifestMismatch,
13357    EditMissingChange,
13358    EditFalseFile,
13359    RemovedMismatch,
13360}
13361
13362/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
13363/// the exact previous and resulting pack manifests. `edit` accepts the
13364/// historical superset form while still requiring every actual changed path.
13365#[cfg(test)]
13366fn verify_v1_manifest_disclosure(
13367    kind: &str,
13368    previous: &[FeedFile],
13369    resulting: &[FeedFile],
13370    files: &[FeedFile],
13371    removed: &[String],
13372) -> Result<(), V1DisclosureError> {
13373    fn as_map(
13374        files: &[FeedFile],
13375    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
13376        let mut result = std::collections::BTreeMap::new();
13377        for file in files {
13378            if result
13379                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
13380                .is_some()
13381            {
13382                return Err(V1DisclosureError::DuplicateFile);
13383            }
13384        }
13385        Ok(result)
13386    }
13387    let previous = as_map(previous)?;
13388    let resulting = as_map(resulting)?;
13389    let disclosed = as_map(files)?;
13390    let removed_set: std::collections::BTreeSet<&str> =
13391        removed.iter().map(String::as_str).collect();
13392    if removed_set.len() != removed.len() {
13393        return Err(V1DisclosureError::DuplicateRemoved);
13394    }
13395    let expected_removed: std::collections::BTreeSet<&str> = previous
13396        .keys()
13397        .copied()
13398        .filter(|path| !resulting.contains_key(path))
13399        .collect();
13400    if removed_set != expected_removed {
13401        return Err(V1DisclosureError::RemovedMismatch);
13402    }
13403    if kind == "push" {
13404        return if disclosed == resulting {
13405            Ok(())
13406        } else {
13407            Err(V1DisclosureError::PushManifestMismatch)
13408        };
13409    }
13410    if kind != "edit" {
13411        return Err(V1DisclosureError::EditFalseFile);
13412    }
13413    if disclosed
13414        .iter()
13415        .any(|(path, value)| resulting.get(path) != Some(value))
13416    {
13417        return Err(V1DisclosureError::EditFalseFile);
13418    }
13419    for (path, value) in &resulting {
13420        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
13421            return Err(V1DisclosureError::EditMissingChange);
13422        }
13423    }
13424    Ok(())
13425}
13426
13427#[derive(Debug, Clone, Deserialize, Serialize)]
13428struct FeedEntry {
13429    v: u8,
13430    seq: u64,
13431    ts: String,
13432    brain: String,
13433    public_key: String,
13434    kind: String,
13435    op: String,
13436    pack_sha256: String,
13437    #[serde(deserialize_with = "deserialize_feed_files")]
13438    files: Vec<FeedFile>,
13439    #[serde(deserialize_with = "deserialize_removed_paths")]
13440    removed: Vec<String>,
13441    prev_entry_hash: Option<String>,
13442    sig: String,
13443}
13444
13445#[derive(Serialize)]
13446struct UnsignedFeedEntry<'a> {
13447    v: u8,
13448    seq: u64,
13449    ts: &'a str,
13450    brain: &'a str,
13451    public_key: &'a str,
13452    kind: &'a str,
13453    op: &'a str,
13454    pack_sha256: &'a str,
13455    files: &'a [FeedFile],
13456    removed: &'a [String],
13457    prev_entry_hash: &'a Option<String>,
13458}
13459
13460#[derive(Debug, Clone, Deserialize, Serialize)]
13461struct FeedItem {
13462    hash: String,
13463    entry: FeedEntry,
13464}
13465
13466#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13467struct FeedIdentity {
13468    fingerprint: String,
13469    #[serde(rename = "publicKeySpki")]
13470    public_key_spki: String,
13471    /// Rotation history (link.md §9.1): identities this brain previously
13472    /// signed as. Entries verify against current OR previous — rotation
13473    /// never invalidates history.
13474    #[serde(default, deserialize_with = "deserialize_previous_identities")]
13475    previous: Vec<PreviousIdentity>,
13476    /// Exact normative rotation statements, oldest first. A list of previous
13477    /// public keys without these old-key signatures is not a trust chain.
13478    #[serde(default, deserialize_with = "deserialize_rotations")]
13479    rotations: Vec<String>,
13480}
13481
13482#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13483struct PreviousIdentity {
13484    fingerprint: String,
13485    #[serde(rename = "publicKeySpki")]
13486    public_key_spki: String,
13487}
13488
13489#[derive(Debug, Deserialize)]
13490struct FeedResponse {
13491    #[serde(rename = "headSeq")]
13492    head_seq: u64,
13493    #[serde(rename = "feedHash")]
13494    feed_hash: Option<String>,
13495    identity: Option<FeedIdentity>,
13496    #[serde(deserialize_with = "deserialize_feed_items")]
13497    entries: Vec<FeedItem>,
13498    #[serde(rename = "scopeLimited")]
13499    scope_limited: bool,
13500}
13501
13502#[derive(Debug, Deserialize, Serialize)]
13503#[serde(deny_unknown_fields)]
13504struct RotationStatement {
13505    v: u8,
13506    op: String,
13507    brain: String,
13508    public_key: String,
13509    new_brain: String,
13510    new_public_key: String,
13511    prior_head_seq: u64,
13512    prior_feed_hash: Option<String>,
13513    ts: String,
13514    sig: String,
13515}
13516
13517#[derive(Debug, Clone, Deserialize, Serialize)]
13518struct TrustState {
13519    v: u8,
13520    origin: String,
13521    /// The exact caller-visible ref whose resolution this checkpoint pins.
13522    /// Slugs/handles are mutable names at the hub; once observed, they may not
13523    /// silently resolve to a different canonical brain.
13524    #[serde(default)]
13525    requested: String,
13526    /// Canonical hub brain id returned for `requested`.
13527    brain: String,
13528    /// Federation home origin when this ref was learned from a registry.
13529    /// Once observed, the registry cannot silently relocate the same handle.
13530    #[serde(default, skip_serializing_if = "Option::is_none")]
13531    home: Option<String>,
13532    anchor: String,
13533    current: String,
13534    #[serde(rename = "headSeq")]
13535    head_seq: u64,
13536    #[serde(rename = "feedHash")]
13537    feed_hash: Option<String>,
13538    /// Exact accepted old-key-signed rotation statements, oldest first. v2
13539    /// checkpoints require this vector to be an immutable prefix of every
13540    /// subsequently served identity chain.
13541    #[serde(default)]
13542    rotations: Vec<String>,
13543    /// Hub pointer-signing identity pinned on first v2 observation. This is
13544    /// separate from the brain key, which signs commits.
13545    #[serde(default, skip_serializing_if = "Option::is_none")]
13546    hub_signer: Option<String>,
13547    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
13548    /// are recognized by their pinned hub signer during one-way migration.
13549    #[serde(default, skip_serializing_if = "Option::is_none")]
13550    protocol_profile: Option<String>,
13551}
13552
13553fn accepted_as_v2(state: &TrustState) -> bool {
13554    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
13555}
13556
13557fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
13558    let directory = open_trust_dir(cfg)?;
13559    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
13560        return Ok(true);
13561    }
13562    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
13563        return Ok(false);
13564    };
13565    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
13566}
13567
13568#[derive(Debug, Clone, Deserialize, Serialize)]
13569struct AliasBinding {
13570    v: u8,
13571    origin: String,
13572    requested: String,
13573    brain: String,
13574    #[serde(default, skip_serializing_if = "Option::is_none")]
13575    home: Option<String>,
13576}
13577
13578struct VerifiedRemote {
13579    head: Head,
13580    identity: Option<FeedIdentity>,
13581    head_entry: Option<FeedItem>,
13582    /// Present when the caller requested and verified the complete chain.
13583    entries: Vec<FeedItem>,
13584    anchor: Option<String>,
13585}
13586
13587fn invalid_feed(message: impl Into<String>) -> LinkError {
13588    LinkError::InvalidFeed {
13589        message: message.into(),
13590    }
13591}
13592
13593fn is_sha256(value: &str) -> bool {
13594    value.len() == 64
13595        && value
13596            .bytes()
13597            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
13598}
13599
13600fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
13601    let der = URL_SAFE_NO_PAD
13602        .decode(public_key_spki)
13603        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
13604    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
13605        return Err(invalid_feed(
13606            "identity public key is not a valid Ed25519 SPKI",
13607        ));
13608    }
13609    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
13610}
13611
13612/// Verify the hub's current identity and every old-key-signed rotation that
13613/// leads from the original TOFU anchor to it. `previous` alone is metadata,
13614/// never authority.
13615fn verify_identity_chain(
13616    identity: &FeedIdentity,
13617    pinned: Option<&TrustState>,
13618) -> LinkResult<String> {
13619    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
13620        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
13621    {
13622        return Err(invalid_feed(
13623            "identity rotation history exceeds the client cap",
13624        ));
13625    }
13626    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
13627        return Err(invalid_feed(
13628            "current identity fingerprint does not match its public key",
13629        ));
13630    }
13631    for previous in &identity.previous {
13632        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
13633            return Err(invalid_feed(
13634                "previous identity fingerprint does not match its public key",
13635            ));
13636        }
13637    }
13638    if identity.rotations.len() != identity.previous.len() {
13639        return Err(invalid_feed(
13640            "identity history is missing an old-key-signed rotation statement",
13641        ));
13642    }
13643
13644    // The HTTP identity lists prior identities newest first. Rotation
13645    // statements are chronological, so verification walks the reversed list
13646    // and ends at the current identity.
13647    let mut chain: Vec<(&str, &str)> = identity
13648        .previous
13649        .iter()
13650        .rev()
13651        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
13652        .collect();
13653    chain.push((&identity.fingerprint, &identity.public_key_spki));
13654
13655    for (index, raw) in identity.rotations.iter().enumerate() {
13656        let statement: RotationStatement = serde_json::from_str(raw)
13657            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
13658        let (old_fingerprint, old_spki) = chain[index];
13659        let (new_fingerprint, new_spki) = chain[index + 1];
13660        if statement.v != 1
13661            || statement.op != "rotate"
13662            || statement.brain != format!("ed25519:{old_fingerprint}")
13663            || statement.public_key != old_spki
13664            || statement.new_brain != format!("ed25519:{new_fingerprint}")
13665            || statement.new_public_key != new_spki
13666            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
13667            || (statement.prior_head_seq > 0
13668                && statement
13669                    .prior_feed_hash
13670                    .as_deref()
13671                    .is_none_or(|hash| !is_sha256(hash)))
13672        {
13673            return Err(invalid_feed(
13674                "rotation statement does not connect adjacent identities",
13675            ));
13676        }
13677        let unsigned = serde_json::to_string(&UnsignedRotation {
13678            v: statement.v,
13679            op: &statement.op,
13680            brain: &statement.brain,
13681            public_key: &statement.public_key,
13682            new_brain: &statement.new_brain,
13683            new_public_key: &statement.new_public_key,
13684            prior_head_seq: statement.prior_head_seq,
13685            prior_feed_hash: statement.prior_feed_hash.as_deref(),
13686            ts: statement.ts.clone(),
13687        })
13688        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
13689        let exact = format!(
13690            "{},\"sig\":\"{}\"}}",
13691            &unsigned[..unsigned.len() - 1],
13692            statement.sig
13693        );
13694        if exact != *raw {
13695            return Err(invalid_feed(
13696                "rotation statement is not in normative serialization",
13697            ));
13698        }
13699        let der = URL_SAFE_NO_PAD
13700            .decode(old_spki)
13701            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
13702        let signature = URL_SAFE_NO_PAD
13703            .decode(&statement.sig)
13704            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
13705        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
13706            .verify(unsigned.as_bytes(), &signature)
13707            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
13708        if index > 0 {
13709            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
13710                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
13711            if statement.prior_head_seq < prior.prior_head_seq {
13712                return Err(invalid_feed("rotation feed boundaries move backward"));
13713            }
13714        }
13715    }
13716
13717    let anchor = format!("ed25519:{}", chain[0].0);
13718    let current = format!("ed25519:{}", identity.fingerprint);
13719    if let Some(pin) = pinned {
13720        if pin.anchor != anchor {
13721            return Err(invalid_feed(
13722                "served identity chain does not descend from the pinned anchor",
13723            ));
13724        }
13725        if !chain
13726            .iter()
13727            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
13728        {
13729            return Err(invalid_feed(
13730                "served identity chain forked away from the last pinned identity",
13731            ));
13732        }
13733        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
13734            return Err(invalid_feed("served identity discarded its rotation chain"));
13735        }
13736        if pin.v >= 2
13737            && (identity.rotations.len() < pin.rotations.len()
13738                || identity.rotations[..pin.rotations.len()] != pin.rotations)
13739        {
13740            return Err(invalid_feed(
13741                "served identity rewrote the locally accepted rotation history",
13742            ));
13743        }
13744    }
13745    Ok(anchor)
13746}
13747
13748fn verify_rotation_feed_boundaries(
13749    identity: &FeedIdentity,
13750    pinned: Option<&TrustState>,
13751    observed: &[FeedItem],
13752    advertised_seq: u64,
13753) -> LinkResult<()> {
13754    let mut chain: Vec<String> = identity
13755        .previous
13756        .iter()
13757        .rev()
13758        .map(|previous| format!("ed25519:{}", previous.fingerprint))
13759        .collect();
13760    chain.push(format!("ed25519:{}", identity.fingerprint));
13761    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
13762
13763    for (index, raw) in identity.rotations.iter().enumerate() {
13764        let rotation: RotationStatement = serde_json::from_str(raw)
13765            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13766        if rotation.prior_head_seq > advertised_seq {
13767            return Err(invalid_feed(
13768                "rotation claims a feed boundary beyond the advertised head",
13769            ));
13770        }
13771        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
13772            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
13773                return Err(invalid_feed(
13774                    "newly disclosed rotation predates the local feed checkpoint",
13775                ));
13776            }
13777        }
13778        let actual = if rotation.prior_head_seq == 0 {
13779            None
13780        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
13781            pinned.and_then(|pin| pin.feed_hash.as_deref())
13782        } else {
13783            observed
13784                .iter()
13785                .find(|item| item.entry.seq == rotation.prior_head_seq)
13786                .map(|item| item.hash.as_str())
13787        };
13788        if let Some(actual) = actual {
13789            if rotation.prior_feed_hash.as_deref() != Some(actual) {
13790                return Err(invalid_feed(
13791                    "rotation statement does not commit the verified feed boundary",
13792                ));
13793            }
13794        } else if rotation.prior_head_seq == 0 {
13795            // The empty-feed boundary is represented by (0, null); there is
13796            // no entry hash to find in `observed`.
13797        } else if pinned.is_some_and(|pin| {
13798            pinned_index.is_some_and(|pin_index| index >= pin_index)
13799                || rotation.prior_head_seq >= pin.head_seq
13800        }) {
13801            return Err(invalid_feed(
13802                "rotation feed boundary was not present in the verified chain",
13803            ));
13804        }
13805    }
13806    Ok(())
13807}
13808
13809/// Once a client has checkpointed identity K, identities retired before K may
13810/// verify old history but can never regain authority over a later sequence.
13811/// This is also the safe migration rule for legacy v1 checkpoints that did not
13812/// persist the exact accepted rotation statements.
13813fn reject_retired_signer_after_checkpoint(
13814    identity: &FeedIdentity,
13815    pinned: Option<&TrustState>,
13816    item: &FeedItem,
13817) -> LinkResult<()> {
13818    let Some(pin) = pinned else {
13819        return Ok(());
13820    };
13821    if item.entry.seq <= pin.head_seq {
13822        return Ok(());
13823    }
13824    let mut chain: Vec<String> = identity
13825        .previous
13826        .iter()
13827        .rev()
13828        .map(|previous| format!("ed25519:{}", previous.fingerprint))
13829        .collect();
13830    chain.push(format!("ed25519:{}", identity.fingerprint));
13831    let pinned_index = chain
13832        .iter()
13833        .position(|key| key == &pin.current)
13834        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
13835    let signer_index = chain
13836        .iter()
13837        .position(|key| key == &item.entry.brain)
13838        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
13839    if signer_index < pinned_index {
13840        return Err(invalid_feed(
13841            "a retired identity attempted to sign after the local checkpoint",
13842        ));
13843    }
13844    Ok(())
13845}
13846
13847fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
13848    let origin = normalized_origin(&cfg.hub)?;
13849    let key = format!(
13850        "{:x}",
13851        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
13852    );
13853    Ok(format!("{key}.json"))
13854}
13855
13856fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13857    let origin = normalized_origin(&cfg.hub)?;
13858    let key = format!(
13859        "{:x}",
13860        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13861    );
13862    Ok(format!("alias-{key}.json"))
13863}
13864
13865#[cfg(any(unix, windows))]
13866struct TrustLock {
13867    _file: std::fs::File,
13868}
13869
13870#[cfg(unix)]
13871fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13872    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13873
13874    let lock_string = format!(".{state_name}.lock");
13875    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13876    let fd = unsafe {
13877        libc::openat(
13878            directory.as_raw_fd(),
13879            lock_name.as_ptr(),
13880            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13881            0o600,
13882        )
13883    };
13884    if fd < 0 {
13885        return Err(std::io::Error::last_os_error().into());
13886    }
13887    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13888    if !file.metadata()?.is_file() {
13889        return Err(LinkError::UnsafePath { path: lock_string });
13890    }
13891    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13892        return Err(std::io::Error::last_os_error().into());
13893    }
13894    Ok(TrustLock { _file: file })
13895}
13896
13897#[cfg(windows)]
13898fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13899    let lock_name = format!(".{state_name}.lock");
13900    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13901    Ok(TrustLock { _file: file })
13902}
13903
13904#[cfg(any(unix, windows))]
13905fn lock_trust_many(
13906    cfg: &HubConfig,
13907    directory: &std::fs::File,
13908    refs: &[&str],
13909) -> LinkResult<Vec<TrustLock>> {
13910    let mut names = refs
13911        .iter()
13912        .map(|reference| trust_file_name(cfg, reference))
13913        .collect::<LinkResult<Vec<_>>>()?;
13914    names.sort();
13915    names.dedup();
13916    names
13917        .iter()
13918        .map(|name| lock_trust_name(directory, name))
13919        .collect()
13920}
13921
13922#[cfg(not(any(unix, windows)))]
13923fn lock_trust_many(
13924    _cfg: &HubConfig,
13925    _directory: &TrustDirectory,
13926    _refs: &[&str],
13927) -> LinkResult<Vec<()>> {
13928    Err(LinkError::UnsupportedPlatform {
13929        operation: "verified link.md state",
13930    })
13931}
13932
13933#[cfg(any(unix, windows))]
13934type TrustDirectory = std::fs::File;
13935
13936#[cfg(not(any(unix, windows)))]
13937struct TrustDirectory;
13938
13939#[cfg(unix)]
13940fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13941    use std::os::fd::AsRawFd as _;
13942
13943    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
13944    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
13945        return Err(std::io::Error::last_os_error().into());
13946    }
13947    directory.sync_all()?;
13948    Ok(directory)
13949}
13950
13951#[cfg(windows)]
13952fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13953    let marker = cfg.state_dir.join("trust").join(".directory");
13954    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
13955    Ok(crate::fsx::open_directory_nofollow(
13956        marker.parent().expect("trust marker has a parent"),
13957    )?)
13958}
13959
13960#[cfg(not(any(unix, windows)))]
13961fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13962    Err(LinkError::UnsupportedPlatform {
13963        operation: "verified link.md state",
13964    })
13965}
13966
13967#[cfg(unix)]
13968fn load_trust_in(
13969    cfg: &HubConfig,
13970    directory: &TrustDirectory,
13971    requested: &str,
13972) -> LinkResult<Option<TrustState>> {
13973    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13974
13975    let name_string = trust_file_name(cfg, requested)?;
13976    let name = c_name(name_string.as_bytes(), &name_string)?;
13977    let fd = unsafe {
13978        libc::openat(
13979            directory.as_raw_fd(),
13980            name.as_ptr(),
13981            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13982        )
13983    };
13984    if fd < 0 {
13985        let error = std::io::Error::last_os_error();
13986        if error.kind() == std::io::ErrorKind::NotFound {
13987            return Ok(None);
13988        }
13989        return Err(LinkError::UnsafePath { path: name_string });
13990    }
13991    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13992    if !file.metadata()?.is_file() {
13993        return Err(LinkError::UnsafePath { path: name_string });
13994    }
13995    let mut bytes = Vec::new();
13996    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
13997    if bytes.len() > 1024 * 1024 {
13998        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
13999    }
14000    let mut state: TrustState = serde_json::from_slice(&bytes)
14001        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
14002    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
14003        return Err(invalid_feed(
14004            "local identity/feed checkpoint does not match this hub and brain",
14005        ));
14006    }
14007    if state.v == 1 {
14008        // Legacy files were keyed by canonical brain id. They can migrate only
14009        // when the caller used that exact id; old slug lookups had no durable
14010        // alias binding and therefore cannot be guessed safely.
14011        if state.brain != requested {
14012            return Err(invalid_feed(
14013                "legacy checkpoint is not bound to the requested brain id",
14014            ));
14015        }
14016        state.requested = requested.to_string();
14017    } else if state.requested != requested {
14018        return Err(invalid_feed(
14019            "local identity/feed checkpoint is bound to a different requested ref",
14020        ));
14021    }
14022    Ok(Some(state))
14023}
14024
14025#[cfg(windows)]
14026fn load_trust_in(
14027    cfg: &HubConfig,
14028    directory: &TrustDirectory,
14029    requested: &str,
14030) -> LinkResult<Option<TrustState>> {
14031    let name = trust_file_name(cfg, requested)?;
14032    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
14033    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
14034        Ok(bytes) => bytes,
14035        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
14036        Err(_) => return Err(LinkError::UnsafePath { path: name }),
14037    };
14038    let mut state: TrustState = serde_json::from_slice(&bytes)
14039        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
14040    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
14041        return Err(invalid_feed(
14042            "local identity/feed checkpoint does not match this hub and brain",
14043        ));
14044    }
14045    if state.v == 1 {
14046        if state.brain != requested {
14047            return Err(invalid_feed(
14048                "legacy checkpoint is not bound to the requested brain id",
14049            ));
14050        }
14051        state.requested = requested.to_string();
14052    } else if state.requested != requested {
14053        return Err(invalid_feed(
14054            "local identity/feed checkpoint is bound to a different requested ref",
14055        ));
14056    }
14057    Ok(Some(state))
14058}
14059
14060#[cfg(not(any(unix, windows)))]
14061fn load_trust_in(
14062    _cfg: &HubConfig,
14063    _directory: &TrustDirectory,
14064    _brain: &str,
14065) -> LinkResult<Option<TrustState>> {
14066    Err(LinkError::UnsupportedPlatform {
14067        operation: "verified link.md state",
14068    })
14069}
14070
14071#[cfg(all(test, any(unix, windows)))]
14072fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
14073    let directory = open_trust_dir(cfg)?;
14074    load_trust_in(cfg, &directory, requested)
14075}
14076
14077#[cfg(unix)]
14078fn save_trust_in(
14079    cfg: &HubConfig,
14080    directory: &TrustDirectory,
14081    state: &TrustState,
14082) -> LinkResult<()> {
14083    use std::os::fd::{AsRawFd as _, FromRawFd as _};
14084
14085    let name_string = trust_file_name(cfg, &state.requested)?;
14086    let name = c_name(name_string.as_bytes(), &name_string)?;
14087    let mut bytes = serde_json::to_vec(state)
14088        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
14089    bytes.push(b'\n');
14090
14091    let nonce = std::time::SystemTime::now()
14092        .duration_since(std::time::UNIX_EPOCH)
14093        .unwrap_or_default()
14094        .as_nanos();
14095    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
14096    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
14097    let fd = unsafe {
14098        libc::openat(
14099            directory.as_raw_fd(),
14100            temp.as_ptr(),
14101            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14102            0o600,
14103        )
14104    };
14105    if fd < 0 {
14106        return Err(std::io::Error::last_os_error().into());
14107    }
14108    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
14109    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
14110        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14111        return Err(error.into());
14112    }
14113    drop(file);
14114    if unsafe {
14115        libc::renameat(
14116            directory.as_raw_fd(),
14117            temp.as_ptr(),
14118            directory.as_raw_fd(),
14119            name.as_ptr(),
14120        )
14121    } != 0
14122    {
14123        let error = std::io::Error::last_os_error();
14124        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14125        return Err(error.into());
14126    }
14127    directory.sync_all()?;
14128    Ok(())
14129}
14130
14131#[cfg(windows)]
14132fn save_trust_in(
14133    cfg: &HubConfig,
14134    directory: &TrustDirectory,
14135    state: &TrustState,
14136) -> LinkResult<()> {
14137    let name = trust_file_name(cfg, &state.requested)?;
14138    let mut bytes = serde_json::to_vec(state)
14139        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
14140    bytes.push(b'\n');
14141    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
14142    Ok(())
14143}
14144
14145#[cfg(not(any(unix, windows)))]
14146fn save_trust_in(
14147    _cfg: &HubConfig,
14148    _directory: &TrustDirectory,
14149    _state: &TrustState,
14150) -> LinkResult<()> {
14151    Err(LinkError::UnsupportedPlatform {
14152        operation: "verified link.md state",
14153    })
14154}
14155
14156#[cfg(unix)]
14157fn load_alias_in(
14158    cfg: &HubConfig,
14159    directory: &TrustDirectory,
14160    requested: &str,
14161) -> LinkResult<Option<AliasBinding>> {
14162    use std::os::fd::{AsRawFd as _, FromRawFd as _};
14163
14164    let name_string = alias_file_name(cfg, requested)?;
14165    let name = c_name(name_string.as_bytes(), &name_string)?;
14166    let fd = unsafe {
14167        libc::openat(
14168            directory.as_raw_fd(),
14169            name.as_ptr(),
14170            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14171        )
14172    };
14173    if fd < 0 {
14174        let error = std::io::Error::last_os_error();
14175        if error.kind() == std::io::ErrorKind::NotFound {
14176            return Ok(None);
14177        }
14178        return Err(LinkError::UnsafePath { path: name_string });
14179    }
14180    let file = unsafe { std::fs::File::from_raw_fd(fd) };
14181    if !file.metadata()?.is_file() {
14182        return Err(LinkError::UnsafePath { path: name_string });
14183    }
14184    let mut bytes = Vec::new();
14185    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
14186    if bytes.len() > 64 * 1024 {
14187        return Err(invalid_feed("local alias binding is oversized"));
14188    }
14189    let alias: AliasBinding = serde_json::from_slice(&bytes)
14190        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
14191    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
14192    {
14193        return Err(invalid_feed(
14194            "local alias binding does not match this hub and requested ref",
14195        ));
14196    }
14197    Ok(Some(alias))
14198}
14199
14200#[cfg(windows)]
14201fn load_alias_in(
14202    cfg: &HubConfig,
14203    directory: &TrustDirectory,
14204    requested: &str,
14205) -> LinkResult<Option<AliasBinding>> {
14206    let name = alias_file_name(cfg, requested)?;
14207    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
14208    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
14209        Ok(bytes) => bytes,
14210        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
14211        Err(_) => return Err(LinkError::UnsafePath { path: name }),
14212    };
14213    let alias: AliasBinding = serde_json::from_slice(&bytes)
14214        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
14215    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
14216    {
14217        return Err(invalid_feed(
14218            "local alias binding does not match this hub and requested ref",
14219        ));
14220    }
14221    Ok(Some(alias))
14222}
14223
14224#[cfg(not(any(unix, windows)))]
14225fn load_alias_in(
14226    _cfg: &HubConfig,
14227    _directory: &TrustDirectory,
14228    _requested: &str,
14229) -> LinkResult<Option<AliasBinding>> {
14230    Err(LinkError::UnsupportedPlatform {
14231        operation: "verified link.md state",
14232    })
14233}
14234
14235#[cfg(unix)]
14236fn save_alias_in(
14237    cfg: &HubConfig,
14238    directory: &TrustDirectory,
14239    alias: &AliasBinding,
14240) -> LinkResult<()> {
14241    use std::os::fd::{AsRawFd as _, FromRawFd as _};
14242
14243    let name_string = alias_file_name(cfg, &alias.requested)?;
14244    let name = c_name(name_string.as_bytes(), &name_string)?;
14245    let mut bytes = serde_json::to_vec(alias)
14246        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
14247    bytes.push(b'\n');
14248    let nonce = std::time::SystemTime::now()
14249        .duration_since(std::time::UNIX_EPOCH)
14250        .unwrap_or_default()
14251        .as_nanos();
14252    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
14253    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
14254    let fd = unsafe {
14255        libc::openat(
14256            directory.as_raw_fd(),
14257            temp.as_ptr(),
14258            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14259            0o600,
14260        )
14261    };
14262    if fd < 0 {
14263        return Err(std::io::Error::last_os_error().into());
14264    }
14265    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
14266    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
14267        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14268        return Err(error.into());
14269    }
14270    drop(file);
14271    if unsafe {
14272        libc::renameat(
14273            directory.as_raw_fd(),
14274            temp.as_ptr(),
14275            directory.as_raw_fd(),
14276            name.as_ptr(),
14277        )
14278    } != 0
14279    {
14280        let error = std::io::Error::last_os_error();
14281        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14282        return Err(error.into());
14283    }
14284    directory.sync_all()?;
14285    Ok(())
14286}
14287
14288#[cfg(windows)]
14289fn save_alias_in(
14290    cfg: &HubConfig,
14291    directory: &TrustDirectory,
14292    alias: &AliasBinding,
14293) -> LinkResult<()> {
14294    let name = alias_file_name(cfg, &alias.requested)?;
14295    let mut bytes = serde_json::to_vec(alias)
14296        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
14297    bytes.push(b'\n');
14298    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
14299    Ok(())
14300}
14301
14302#[cfg(not(any(unix, windows)))]
14303fn save_alias_in(
14304    _cfg: &HubConfig,
14305    _directory: &TrustDirectory,
14306    _alias: &AliasBinding,
14307) -> LinkResult<()> {
14308    Err(LinkError::UnsupportedPlatform {
14309        operation: "verified link.md state",
14310    })
14311}
14312
14313/// Load the canonical checkpoint shared by every spelling of one brain and
14314/// the separate alias binding. This also performs the one-way migration from
14315/// pre-v3 checkpoints that stored a full trust state under the alias itself.
14316/// Callers hold both alias and canonical locks before entering.
14317fn load_canonical_pin(
14318    cfg: &HubConfig,
14319    directory: &TrustDirectory,
14320    requested: &str,
14321    resolved_brain: &str,
14322) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
14323    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
14324    if requested == resolved_brain {
14325        return Ok((canonical, None));
14326    }
14327
14328    let mut alias = load_alias_in(cfg, directory, requested)?;
14329    if let Some(binding) = &alias {
14330        if binding.brain != resolved_brain {
14331            return Err(LinkError::AliasRebindRequired {
14332                alias: requested.to_string(),
14333                from: binding.brain.clone(),
14334                to: resolved_brain.to_string(),
14335            });
14336        }
14337        return Ok((canonical, alias));
14338    }
14339
14340    // A v2 build stored the complete checkpoint under the requested slug.
14341    // Promote it to the canonical ULID key before creating the lightweight
14342    // alias binding. Never silently merge two independently advanced states.
14343    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
14344        if legacy.brain != resolved_brain {
14345            return Err(invalid_feed(
14346                "legacy alias checkpoint names a different canonical brain",
14347            ));
14348        }
14349        if let Some(existing) = &canonical {
14350            if existing.brain != legacy.brain
14351                || existing.anchor != legacy.anchor
14352                || existing.current != legacy.current
14353                || existing.head_seq != legacy.head_seq
14354                || existing.feed_hash != legacy.feed_hash
14355                || existing.rotations != legacy.rotations
14356            {
14357                return Err(invalid_feed(
14358                    "legacy alias checkpoint conflicts with the canonical checkpoint",
14359                ));
14360            }
14361        } else {
14362            let mut promoted = legacy.clone();
14363            promoted.requested = resolved_brain.to_string();
14364            promoted.home = None;
14365            save_trust_in(cfg, directory, &promoted)?;
14366            canonical = Some(promoted);
14367        }
14368        alias = Some(AliasBinding {
14369            v: 1,
14370            origin: normalized_origin(&cfg.hub)?,
14371            requested: requested.to_string(),
14372            brain: resolved_brain.to_string(),
14373            home: legacy.home,
14374        });
14375        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
14376    }
14377    Ok((canonical, alias))
14378}
14379
14380/// Explicitly replace one mutable alias binding after the operator reviews the
14381/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
14382/// or rewritten, so old history remains pinned and the new brain establishes
14383/// its own independently verified trust chain.
14384pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
14385    require_hardened_filesystem("verified alias rebind")?;
14386    require_safe_ref(alias)?;
14387    require_safe_ref(from)?;
14388    require_safe_ref(to)?;
14389    if crate::ulid::is_ulid(alias)
14390        || !crate::ulid::is_ulid(from)
14391        || !crate::ulid::is_ulid(to)
14392        || from == to
14393    {
14394        return Err(LinkError::InvalidPack {
14395            message:
14396                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
14397                    .to_string(),
14398        });
14399    }
14400
14401    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
14402        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
14403    })?;
14404    accept_v2_head(cfg, &verified)?;
14405
14406    let alias_response = ensure_ok(
14407        request(
14408            cfg,
14409            "GET",
14410            &format!("/api/hub/brains/{alias}/v2/head"),
14411            None,
14412            Auth::Required,
14413        )?,
14414        "resolve alias for explicit rebind",
14415    )?;
14416    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
14417        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
14418    if resolved.v != 2 || resolved.brain_id != to {
14419        return Err(LinkError::RemoteAdvancedDuringSync);
14420    }
14421
14422    let directory = open_trust_dir(cfg)?;
14423    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
14424    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
14425        message: "the requested alias has no existing local binding to replace".to_string(),
14426    })?;
14427    if binding.brain != from {
14428        return Err(LinkError::AliasRebindRequired {
14429            alias: alias.to_string(),
14430            from: binding.brain,
14431            to: to.to_string(),
14432        });
14433    }
14434    save_alias_in(
14435        cfg,
14436        &directory,
14437        &AliasBinding {
14438            v: 1,
14439            origin: normalized_origin(&cfg.hub)?,
14440            requested: alias.to_string(),
14441            brain: to.to_string(),
14442            home: binding.home,
14443        },
14444    )?;
14445    Ok(json!({
14446        "v": 2,
14447        "alias": alias,
14448        "from": from,
14449        "to": to,
14450        "outcome": "alias_rebound",
14451    }))
14452}
14453
14454fn save_canonical_pin_and_alias(
14455    cfg: &HubConfig,
14456    directory: &TrustDirectory,
14457    requested: &str,
14458    resolved_brain: &str,
14459    mut state: TrustState,
14460    existing_alias: Option<&AliasBinding>,
14461) -> LinkResult<()> {
14462    state.requested = resolved_brain.to_string();
14463    state.brain = resolved_brain.to_string();
14464    state.home = None;
14465    save_trust_in(cfg, directory, &state)?;
14466    if requested != resolved_brain {
14467        save_alias_in(
14468            cfg,
14469            directory,
14470            &AliasBinding {
14471                v: 1,
14472                origin: normalized_origin(&cfg.hub)?,
14473                requested: requested.to_string(),
14474                brain: resolved_brain.to_string(),
14475                home: existing_alias.and_then(|alias| alias.home.clone()),
14476            },
14477        )?;
14478    }
14479    Ok(())
14480}
14481
14482fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
14483    const ED25519_SPKI_PREFIX: &[u8] = &[
14484        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
14485    ];
14486    let entry = &item.entry;
14487    let public_der = URL_SAFE_NO_PAD
14488        .decode(&entry.public_key)
14489        .map_err(|_| invalid_feed("public key is not base64url"))?;
14490    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
14491        || !public_der.starts_with(ED25519_SPKI_PREFIX)
14492    {
14493        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
14494    }
14495    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
14496    if entry.brain != format!("ed25519:{fingerprint}") {
14497        return Err(invalid_feed(
14498            "brain fingerprint does not match its public key",
14499        ));
14500    }
14501    // Verify the complete rotation authority before using any previous key.
14502    let _ = verify_identity_chain(identity, None)?;
14503    let mut chain: Vec<(&str, &str)> = identity
14504        .previous
14505        .iter()
14506        .rev()
14507        .map(|previous| {
14508            (
14509                previous.fingerprint.as_str(),
14510                previous.public_key_spki.as_str(),
14511            )
14512        })
14513        .collect();
14514    chain.push((&identity.fingerprint, &identity.public_key_spki));
14515    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
14516        *known_fingerprint == fingerprint && *spki == entry.public_key
14517    });
14518    let Some(signer_index) = signer_index else {
14519        return Err(invalid_feed(
14520            "entry signer is not this brain's identity (current or rotated-from)",
14521        ));
14522    };
14523    let lower_boundary = if signer_index == 0 {
14524        None
14525    } else {
14526        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
14527            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
14528        Some(prior.prior_head_seq)
14529    };
14530    let upper_boundary = if signer_index == identity.rotations.len() {
14531        None
14532    } else {
14533        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
14534            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
14535        Some(next.prior_head_seq)
14536    };
14537    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
14538        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
14539    {
14540        return Err(invalid_feed(
14541            "entry signer is outside its authenticated rotation epoch",
14542        ));
14543    }
14544    let unsigned = UnsignedFeedEntry {
14545        v: entry.v,
14546        seq: entry.seq,
14547        ts: &entry.ts,
14548        brain: &entry.brain,
14549        public_key: &entry.public_key,
14550        kind: &entry.kind,
14551        op: &entry.op,
14552        pack_sha256: &entry.pack_sha256,
14553        files: &entry.files,
14554        removed: &entry.removed,
14555        prev_entry_hash: &entry.prev_entry_hash,
14556    };
14557    let message =
14558        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
14559    let signature = URL_SAFE_NO_PAD
14560        .decode(&entry.sig)
14561        .map_err(|_| invalid_feed("signature is not base64url"))?;
14562    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
14563        .verify(&message, &signature)
14564        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
14565
14566    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
14567    exact.push(b'\n');
14568    let actual_hash = format!("{:x}", Sha256::digest(&exact));
14569    if actual_hash != item.hash {
14570        return Err(invalid_feed("entry SHA-256 does not match"));
14571    }
14572    Ok(())
14573}
14574
14575// ─────────────────────────────────────────────────────────────────────────────
14576// key rotation — link.md §9.1: the new key, signed by the old one
14577// ─────────────────────────────────────────────────────────────────────────────
14578
14579/// The unsigned rotation statement in its normative field order.
14580#[derive(Serialize)]
14581struct UnsignedRotation<'a> {
14582    v: u8,
14583    op: &'a str,
14584    brain: &'a str,
14585    public_key: &'a str,
14586    new_brain: &'a str,
14587    new_public_key: &'a str,
14588    prior_head_seq: u64,
14589    prior_feed_hash: Option<&'a str>,
14590    ts: String,
14591}
14592
14593/// Durable intent for an in-flight key rotation. The hub's recovery contract
14594/// identifies an ambiguous retry by the exact statement bytes, so the
14595/// statement cannot be reconstructed from the key and feed boundary later:
14596/// its timestamp and signature would differ.
14597#[derive(Debug, Deserialize, Serialize)]
14598#[serde(deny_unknown_fields)]
14599struct RotationJournal {
14600    v: u8,
14601    origin: String,
14602    brain: String,
14603    old_brain: String,
14604    new_brain: String,
14605    prior_head_seq: u64,
14606    prior_feed_hash: Option<String>,
14607    statement: String,
14608}
14609
14610fn rotation_journal_path(key_path: &Path) -> PathBuf {
14611    let mut path = key_path.as_os_str().to_os_string();
14612    path.push(".rotation.json");
14613    PathBuf::from(path)
14614}
14615
14616fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
14617    #[cfg(unix)]
14618    let file = {
14619        use std::os::fd::{AsRawFd as _, FromRawFd as _};
14620        use std::os::unix::ffi::OsStrExt as _;
14621        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
14622            .map_err(|error| {
14623                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
14624            })?;
14625        let leaf_name = path
14626            .file_name()
14627            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
14628        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
14629        let fd = unsafe {
14630            libc::openat(
14631                parent.as_raw_fd(),
14632                leaf.as_ptr(),
14633                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14634            )
14635        };
14636        if fd < 0 {
14637            return Err(bad_agent_key(
14638                "the rotation journal must be an existing regular file without symlink ancestors",
14639            ));
14640        }
14641        unsafe { std::fs::File::from_raw_fd(fd) }
14642    };
14643    #[cfg(not(unix))]
14644    let file = std::fs::File::open(path)
14645        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
14646    let metadata = file
14647        .metadata()
14648        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
14649    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
14650        return Err(bad_agent_key(
14651            "the rotation journal must be a bounded regular file",
14652        ));
14653    }
14654    #[cfg(unix)]
14655    {
14656        use std::os::unix::fs::PermissionsExt as _;
14657        if metadata.permissions().mode() & 0o077 != 0 {
14658            return Err(bad_agent_key(
14659                "the rotation journal is accessible to group/other; set mode 0600",
14660            ));
14661        }
14662    }
14663    serde_json::from_reader(file)
14664        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
14665}
14666
14667fn remove_rotation_journal(path: &Path) {
14668    #[cfg(unix)]
14669    {
14670        use std::os::fd::AsRawFd as _;
14671        use std::os::unix::ffi::OsStrExt as _;
14672        let Ok(parent) =
14673            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
14674        else {
14675            return;
14676        };
14677        let Some(leaf_name) = path.file_name() else {
14678            return;
14679        };
14680        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
14681            return;
14682        };
14683        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
14684            let _ = parent.sync_all();
14685        }
14686    }
14687    #[cfg(not(unix))]
14688    {
14689        let _ = std::fs::remove_file(path);
14690    }
14691}
14692
14693fn validate_rotation_journal(
14694    journal: &RotationJournal,
14695    cfg: &HubConfig,
14696    canonical_brain: &str,
14697    old_key: &AgentSigningKey,
14698    new_key: &AgentSigningKey,
14699    head: &Head,
14700) -> LinkResult<()> {
14701    if journal.v != 1
14702        || journal.origin != normalized_origin(&cfg.hub)?
14703        || journal.brain != canonical_brain
14704        || journal.old_brain != old_key.multikey
14705        || journal.new_brain != new_key.multikey
14706        || journal.prior_head_seq != head.seq
14707        || journal.prior_feed_hash != head.feed_hash
14708    {
14709        return Err(invalid_feed(
14710            "rotation journal does not match the verified key and feed boundary",
14711        ));
14712    }
14713    let statement: RotationStatement = serde_json::from_str(&journal.statement)
14714        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
14715    if statement.prior_head_seq != journal.prior_head_seq
14716        || statement.prior_feed_hash != journal.prior_feed_hash
14717        || statement.brain != old_key.multikey
14718        || statement.public_key != old_key.public_key_spki
14719        || statement.new_brain != new_key.multikey
14720        || statement.new_public_key != new_key.public_key_spki
14721    {
14722        return Err(invalid_feed(
14723            "rotation journal statement does not match its durable intent",
14724        ));
14725    }
14726    let identity = FeedIdentity {
14727        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
14728        public_key_spki: new_key.public_key_spki.clone(),
14729        previous: vec![PreviousIdentity {
14730            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
14731            public_key_spki: old_key.public_key_spki.clone(),
14732        }],
14733        rotations: vec![journal.statement.clone()],
14734    };
14735    verify_identity_chain(&identity, None)?;
14736    Ok(())
14737}
14738
14739/// What `dbmd key rotate` returns.
14740#[derive(Debug, Serialize)]
14741pub struct RotationReport {
14742    /// The brain id rotated.
14743    pub brain: String,
14744    /// The NEW identity the hub now serves.
14745    pub multikey: String,
14746    /// Where the new PKCS#8 secret landed (0600).
14747    #[serde(rename = "keyFile")]
14748    pub key_file: String,
14749    /// Prior identities (newest first) the feed still verifies against.
14750    pub previous: Vec<String>,
14751}
14752
14753/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
14754/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
14755/// OLD key in normative serialization — and send it to the hub. The new secret
14756/// is durably created at 0600 before the POST; an existing output is reused for
14757/// idempotent retry/reconciliation. The old key is left untouched.
14758pub fn rotate_brain_key(
14759    cfg: &HubConfig,
14760    brain: &str,
14761    old_key: &AgentSigningKey,
14762    out: &Path,
14763) -> LinkResult<RotationReport> {
14764    require_hardened_filesystem("key rotation")?;
14765    require_safe_ref(brain)?;
14766    // The new private key must be durable *before* the hub can accept its
14767    // public half. An existing file is the retry/reconciliation path after an
14768    // ambiguous network failure: reuse it, never generate another identity.
14769    let new_key = if out.exists() {
14770        load_signing_key(out)?
14771    } else {
14772        let rng = ring::rand::SystemRandom::new();
14773        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
14774            .map_err(|_| bad_agent_key("key generation failed"))?;
14775        let pair = agent_keypair(pkcs8.as_ref())?;
14776        let (public_key_spki, multikey) = public_identity_for(&pair);
14777        write_secret_new(
14778            out,
14779            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
14780        )?;
14781        AgentSigningKey {
14782            pkcs8: pkcs8.as_ref().to_vec(),
14783            multikey,
14784            public_key_spki,
14785        }
14786    };
14787    let new_spki = new_key.public_key_spki.clone();
14788    let new_multikey = new_key.multikey.clone();
14789    let journal_path = rotation_journal_path(out);
14790    let before_v2 = v2_verified_head(cfg, brain)?;
14791    let (canonical_brain, served_identity, observed_head, v2_profile) =
14792        if let Some(head) = before_v2 {
14793            let observed = Head {
14794                brain: head.brain_id.clone(),
14795                seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14796                updated_at: head
14797                    .pointer
14798                    .as_ref()
14799                    .map(|pointer| pointer.signed_at.clone()),
14800                feed_hash: head
14801                    .pointer
14802                    .as_ref()
14803                    .map(|pointer| pointer.feed_hash.clone()),
14804                verified: true,
14805            };
14806            let identity = v2_identity(&head.identity);
14807            let canonical = head.brain_id.clone();
14808            accept_v2_head(cfg, &head)?;
14809            (canonical, identity, observed, true)
14810        } else {
14811            let remote = verified_remote_head(cfg, brain, false)?;
14812            let identity = remote
14813                .identity
14814                .clone()
14815                .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
14816            (remote.head.brain.clone(), identity, remote.head, false)
14817        };
14818    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
14819    let already_rotated = served_multikey == new_multikey;
14820    // A completed invocation removes the journal only after the server has
14821    // acknowledged its recovery boundary. If the verified identity advanced
14822    // but the journal remains, the prior response was ambiguous: replay the
14823    // exact signed statement so a v2 hub can repair/confirm its checkpoint.
14824    if already_rotated && !journal_path.exists() {
14825        remove_rotation_journal(&journal_path);
14826        return Ok(RotationReport {
14827            brain: brain.to_string(),
14828            multikey: new_multikey,
14829            key_file: out.display().to_string(),
14830            previous: served_identity
14831                .previous
14832                .iter()
14833                .map(|identity| format!("ed25519:{}", identity.fingerprint))
14834                .collect(),
14835        });
14836    }
14837    if !already_rotated && served_multikey != old_key.multikey {
14838        return Err(invalid_feed(
14839            "the supplied old key is not the brain's verified current identity",
14840        ));
14841    }
14842
14843    let journal = if journal_path.exists() {
14844        read_rotation_journal(&journal_path)?
14845    } else {
14846        let ts = crate::now()
14847            .with_timezone(&chrono::Utc)
14848            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14849            .to_string();
14850        let unsigned = serde_json::to_string(&UnsignedRotation {
14851            v: 1,
14852            op: "rotate",
14853            brain: &old_key.multikey,
14854            public_key: &old_key.public_key_spki,
14855            new_brain: &new_multikey,
14856            new_public_key: &new_spki,
14857            prior_head_seq: observed_head.seq,
14858            prior_feed_hash: observed_head.feed_hash.as_deref(),
14859            ts,
14860        })
14861        .expect("serialize rotation");
14862        let old_pair = agent_keypair(&old_key.pkcs8)?;
14863        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14864        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14865        let journal = RotationJournal {
14866            v: 1,
14867            origin: normalized_origin(&cfg.hub)?,
14868            brain: canonical_brain.clone(),
14869            old_brain: old_key.multikey.clone(),
14870            new_brain: new_multikey.clone(),
14871            prior_head_seq: observed_head.seq,
14872            prior_feed_hash: observed_head.feed_hash.clone(),
14873            statement,
14874        };
14875        let mut exact = serde_json::to_vec(&journal)
14876            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14877        exact.push(b'\n');
14878        if write_secret_new(&journal_path, &exact).is_err() {
14879            // A concurrent retry may have won the O_EXCL race. Only an exact,
14880            // fully validated journal is allowed to recover that race.
14881            read_rotation_journal(&journal_path)?
14882        } else {
14883            journal
14884        }
14885    };
14886    validate_rotation_journal(
14887        &journal,
14888        cfg,
14889        &canonical_brain,
14890        old_key,
14891        &new_key,
14892        &observed_head,
14893    )?;
14894
14895    let body = json!({ "statement": journal.statement });
14896    let path = format!("/api/hub/brains/{brain}/rotate");
14897    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14898    let attempted_failure = match attempted {
14899        Ok(response) if (200..300).contains(&response.status) => None,
14900        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14901        Err(error) => Some(error),
14902    };
14903
14904    // A 2xx body is not authority, and a failed response may have followed a
14905    // committed mutation. In both cases success means the normal verifier sees
14906    // an append-only rotation chain ending at the durable new key.
14907    let identity = if v2_profile {
14908        match v2_verified_head(cfg, brain) {
14909            Ok(Some(after)) => {
14910                let identity = v2_identity(&after.identity);
14911                accept_v2_head(cfg, &after)?;
14912                identity
14913            }
14914            Ok(None) => {
14915                return Err(attempted_failure.unwrap_or_else(|| {
14916                    invalid_feed("rotated v2 brain no longer serves a v2 head")
14917                }));
14918            }
14919            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14920        }
14921    } else {
14922        match verified_remote_head(cfg, brain, false) {
14923            Ok(after) => after
14924                .identity
14925                .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14926            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14927        }
14928    };
14929    if format!("ed25519:{}", identity.fingerprint) != new_multikey
14930        || identity.public_key_spki != new_spki
14931    {
14932        return Err(attempted_failure.unwrap_or_else(|| {
14933            invalid_feed("hub acknowledged rotation without committing the verified new identity")
14934        }));
14935    }
14936    if v2_profile {
14937        if let Some(error) = attempted_failure {
14938            // Seeing the new public identity is insufficient: v2 rotation is
14939            // successful only after the endpoint has forced and acknowledged
14940            // the recovery checkpoint that contains it. Keep the journal for
14941            // an exact retry after any non-2xx or transport ambiguity.
14942            return Err(error);
14943        }
14944    }
14945    let previous = identity
14946        .previous
14947        .iter()
14948        .map(|prior| format!("ed25519:{}", prior.fingerprint))
14949        .collect();
14950    remove_rotation_journal(&journal_path);
14951
14952    Ok(RotationReport {
14953        brain: brain.to_string(),
14954        multikey: new_multikey,
14955        key_file: out.display().to_string(),
14956        previous,
14957    })
14958}
14959
14960// ─────────────────────────────────────────────────────────────────────────────
14961// mirror — verified replication: the whole feed + files, re-servable
14962// ─────────────────────────────────────────────────────────────────────────────
14963
14964/// What `dbmd mirror` materialized.
14965#[derive(Debug, Serialize)]
14966pub struct MirrorReport {
14967    /// The brain id.
14968    pub brain: String,
14969    /// The mirrored feed head.
14970    #[serde(rename = "headSeq")]
14971    pub head_seq: u64,
14972    /// The head entry hash (the feed's advertised converged state).
14973    #[serde(rename = "feedHash")]
14974    pub feed_hash: Option<String>,
14975    /// Signed feed entries verified and stored.
14976    pub entries: u64,
14977    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
14978    pub pinned: String,
14979    /// Store files materialized by the pull.
14980    pub files: usize,
14981}
14982
14983/// The mirror state directory, relative to the mirror root.
14984pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
14985
14986/// Fully re-verified mirror material suitable for a read-only re-server.
14987#[derive(Debug)]
14988pub struct VerifiedMirrorMaterial {
14989    pub brain: String,
14990    pub head_seq: u64,
14991    pub feed_hash: Option<String>,
14992    pub identity: serde_json::Value,
14993    /// Sequence, exact normative entry JSON without newline, and entry hash.
14994    pub entries: Vec<(u64, String, String)>,
14995    pub pack_sha256: Option<String>,
14996}
14997
14998#[derive(Deserialize)]
14999#[serde(deny_unknown_fields)]
15000struct StoredMirrorHead {
15001    brain: String,
15002    #[serde(rename = "headSeq")]
15003    head_seq: u64,
15004    #[serde(rename = "feedHash")]
15005    feed_hash: Option<String>,
15006}
15007
15008/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
15009/// exact snapshot pack before `dbmd serve` exposes any bytes.
15010pub fn verify_mirror_material(
15011    head_bytes: &[u8],
15012    identity_bytes: &[u8],
15013    feed_bytes: &[Vec<u8>],
15014    snapshot_pack: Option<&[u8]>,
15015    expected_anchor: &str,
15016) -> LinkResult<VerifiedMirrorMaterial> {
15017    let snapshot_hash = snapshot_pack
15018        .filter(|pack| !pack.is_empty())
15019        .map(content_sha256);
15020    verify_mirror_material_with_pack_hash(
15021        head_bytes,
15022        identity_bytes,
15023        feed_bytes,
15024        snapshot_hash.as_deref(),
15025        expected_anchor,
15026    )
15027}
15028
15029/// Re-verify mirror metadata against a snapshot digest computed from a held
15030/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
15031/// large pack without ever buffering the pack in process memory.
15032pub fn verify_mirror_material_with_pack_hash(
15033    head_bytes: &[u8],
15034    identity_bytes: &[u8],
15035    feed_bytes: &[Vec<u8>],
15036    snapshot_pack_sha256: Option<&str>,
15037    expected_anchor: &str,
15038) -> LinkResult<VerifiedMirrorMaterial> {
15039    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
15040        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
15041    require_safe_ref(&head.brain)?;
15042    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
15043        return Err(invalid_feed(
15044            "stored mirror feed count does not match its bounded head sequence",
15045        ));
15046    }
15047    let aggregate = feed_bytes
15048        .iter()
15049        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
15050        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
15051    if aggregate > MAX_FEED_REPLAY_BYTES {
15052        return Err(invalid_feed(
15053            "stored mirror feed metadata exceeds the aggregate limit",
15054        ));
15055    }
15056    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
15057        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
15058    let anchor = verify_identity_chain(&identity, None)?;
15059    if anchor != expected_anchor {
15060        return Err(invalid_feed(
15061            "stored mirror identity does not descend from the explicitly trusted anchor",
15062        ));
15063    }
15064
15065    let mut entries = Vec::with_capacity(feed_bytes.len());
15066    let mut items = Vec::with_capacity(feed_bytes.len());
15067    let mut previous_hash = None;
15068    let mut pack_sha256 = None;
15069    for (index, bytes) in feed_bytes.iter().enumerate() {
15070        let exact = bytes
15071            .strip_suffix(b"\n")
15072            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
15073        if exact.ends_with(b"\n") {
15074            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
15075        }
15076        let entry: FeedEntry = serde_json::from_slice(exact)
15077            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
15078        let expected_seq = index as u64 + 1;
15079        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
15080            return Err(invalid_feed(
15081                "stored mirror feed is not contiguous and hash-chained",
15082            ));
15083        }
15084        let canonical = serde_json::to_vec(&entry)
15085            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
15086        if canonical != exact {
15087            return Err(invalid_feed(
15088                "stored feed entry is not in normative serialization",
15089            ));
15090        }
15091        let hash = content_sha256(bytes);
15092        let item = FeedItem {
15093            hash: hash.clone(),
15094            entry,
15095        };
15096        verify_feed_item(&item, &identity)?;
15097        previous_hash = Some(hash.clone());
15098        if expected_seq == head.head_seq {
15099            pack_sha256 = Some(item.entry.pack_sha256.clone());
15100        }
15101        entries.push((
15102            expected_seq,
15103            std::str::from_utf8(exact)
15104                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
15105                .to_string(),
15106            hash,
15107        ));
15108        items.push(item);
15109    }
15110    if previous_hash != head.feed_hash {
15111        return Err(invalid_feed(
15112            "stored mirror feed does not converge on its advertised head",
15113        ));
15114    }
15115    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
15116    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
15117        (0, None, None) => {}
15118        (_, Some(actual), Some(expected)) if actual == expected => {}
15119        _ => {
15120            return Err(LinkError::InvalidPack {
15121                message: "stored snapshot pack does not match the signed head digest".to_string(),
15122            });
15123        }
15124    }
15125    let identity_value = serde_json::to_value(&identity)
15126        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
15127    Ok(VerifiedMirrorMaterial {
15128        brain: head.brain,
15129        head_seq: head.head_seq,
15130        feed_hash: head.feed_hash,
15131        identity: identity_value,
15132        entries,
15133        pack_sha256,
15134    })
15135}
15136
15137/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
15138/// entry hash every consumer recomputes (SPEC §5.3).
15139pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
15140    format!(
15141        "{:x}",
15142        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
15143    )
15144}
15145
15146/// SHA-256 hex for content a signed manifest names. Exposed for the thin
15147/// `dbmd serve` adapter; cryptographic verification remains centralized here.
15148pub fn content_sha256(bytes: &[u8]) -> String {
15149    format!("{:x}", Sha256::digest(bytes))
15150}
15151
15152/// SHA-256 a stream with a fixed-size working buffer.
15153pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
15154    let mut digest = Sha256::new();
15155    let mut buffer = [0u8; 64 * 1024];
15156    loop {
15157        let read = reader.read(&mut buffer)?;
15158        if read == 0 {
15159            break;
15160        }
15161        digest.update(&buffer[..read]);
15162    }
15163    Ok(format!("{:x}", digest.finalize()))
15164}
15165
15166/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
15167/// chain, not just the head): every entry's signature, hash, sequence
15168/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
15169/// checked in a sibling staging directory. Only then is the old mirror swapped
15170/// out through an atomic directory exchange. Every stage, install, and cleanup
15171/// operation is relative to one held no-follow parent-directory capability, so
15172/// renaming an ancestor cannot redirect any write or deletion.
15173#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
15174pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
15175    require_hardened_filesystem("mirror")?;
15176    require_safe_ref(brain)?;
15177    #[cfg(windows)]
15178    {
15179        let _ = (cfg, dest);
15180        return Err(LinkError::UnsupportedPlatform {
15181            operation: "atomic whole-mirror replacement on Windows",
15182        });
15183    }
15184    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
15185    let name = dest
15186        .file_name()
15187        .and_then(|name| name.to_str())
15188        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
15189        .ok_or_else(|| LinkError::UnsafePath {
15190            path: dest.display().to_string(),
15191        })?;
15192    #[cfg(unix)]
15193    let parent_dir = open_or_create_dir_nofollow(parent)?;
15194    #[cfg(unix)]
15195    use std::os::fd::AsRawFd as _;
15196    #[cfg(unix)]
15197    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
15198    #[cfg(unix)]
15199    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
15200        None => false,
15201        Some(true) => true,
15202        Some(false) => {
15203            return Err(LinkError::UnsafePath {
15204                path: dest.display().to_string(),
15205            });
15206        }
15207    };
15208
15209    // A fixed backup was used by pre-hardening builds. Never interpret or
15210    // delete an attacker-planted entry at that name; require manual recovery.
15211    #[cfg(unix)]
15212    let legacy_backup_name = c_name(
15213        format!(".{name}.dbmd-backup").as_bytes(),
15214        &dest.display().to_string(),
15215    )?;
15216    #[cfg(unix)]
15217    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
15218        return Err(LinkError::UnsafePath {
15219            path: parent
15220                .join(format!(".{name}.dbmd-backup"))
15221                .display()
15222                .to_string(),
15223        });
15224    }
15225
15226    let nonce = std::time::SystemTime::now()
15227        .duration_since(std::time::UNIX_EPOCH)
15228        .unwrap_or_default()
15229        .as_nanos();
15230    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
15231    #[cfg(unix)]
15232    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
15233    #[cfg(unix)]
15234    let stage_dir = create_dir_exclusive_at(
15235        parent_dir.as_raw_fd(),
15236        &stage_name,
15237        &dest.display().to_string(),
15238    )?;
15239
15240    let assembled = (|| -> LinkResult<MirrorReport> {
15241        let remote = verified_remote_head(cfg, brain, true)?;
15242        let brain_id = remote.head.brain.clone();
15243        let identity = remote
15244            .identity
15245            .as_ref()
15246            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
15247        let anchor = remote
15248            .anchor
15249            .clone()
15250            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
15251        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
15252        let snapshot_entries = parse_store_pack(pack.clone())?;
15253        let snapshot_count = snapshot_entries.len();
15254        let mut staged_entries = snapshot_entries;
15255        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
15256        for item in &remote.entries {
15257            let mut exact = serde_json::to_vec(&item.entry)
15258                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
15259            exact.push(b'\n');
15260            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
15261                return Err(invalid_feed(
15262                    "serialized mirror entry differs from its verified hash",
15263                ));
15264            }
15265            staged_entries.push((
15266                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
15267                exact,
15268            ));
15269        }
15270        let mut identity_bytes = serde_json::to_vec(identity)
15271            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
15272        identity_bytes.push(b'\n');
15273        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
15274        let mut head_bytes = serde_json::to_vec(&json!({
15275            "brain": brain_id,
15276            "headSeq": remote.head.seq,
15277            "feedHash": remote.head.feed_hash,
15278        }))
15279        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
15280        head_bytes.push(b'\n');
15281        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
15282        staged_entries.push((
15283            CONFIG_REL_PATH.to_string(),
15284            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
15285        ));
15286        #[cfg(unix)]
15287        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
15288
15289        Ok(MirrorReport {
15290            brain: brain_id,
15291            head_seq: remote.head.seq,
15292            feed_hash: remote.head.feed_hash,
15293            entries: remote.entries.len() as u64,
15294            pinned: anchor,
15295            files: snapshot_count,
15296        })
15297    })();
15298
15299    let report = match assembled {
15300        Ok(report) => report,
15301        Err(error) => {
15302            #[cfg(unix)]
15303            let _ = remove_tree_at(
15304                parent_dir.as_raw_fd(),
15305                &stage_name,
15306                &dest.display().to_string(),
15307            );
15308            return Err(error);
15309        }
15310    };
15311
15312    #[cfg(unix)]
15313    if let Err(error) =
15314        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
15315    {
15316        let _ = remove_tree_at(
15317            parent_dir.as_raw_fd(),
15318            &stage_name,
15319            &dest.display().to_string(),
15320        );
15321        return Err(error);
15322    }
15323    // An exchange leaves the old mirror at the unique stage name. Cleanup is
15324    // capability-relative and never follows symlinks in hostile old content.
15325    #[cfg(unix)]
15326    if dest_exists {
15327        remove_tree_at(
15328            parent_dir.as_raw_fd(),
15329            &stage_name,
15330            &dest.display().to_string(),
15331        )?;
15332    }
15333    #[cfg(unix)]
15334    parent_dir.sync_all()?;
15335    Ok(report)
15336}
15337
15338fn verified_remote_head(
15339    cfg: &HubConfig,
15340    brain: &str,
15341    require_full_chain: bool,
15342) -> LinkResult<VerifiedRemote> {
15343    require_hardened_filesystem("verified link.md state")?;
15344    require_safe_ref(brain)?;
15345    // Refuse an unsafe state root before sending credentials or consulting an
15346    // untrusted card, and retain this exact directory inode for the complete
15347    // checkpoint transaction.
15348    let trust_directory = open_trust_dir(cfg)?;
15349    let path = format!("/api/hub/brains/{brain}");
15350    let body = ensure_ok(
15351        request(cfg, "GET", &path, None, Auth::Required)?,
15352        "subscribe",
15353    )?;
15354    let resolved_brain = body
15355        .get("id")
15356        .and_then(Value::as_str)
15357        .filter(|id| crate::ulid::is_ulid(id))
15358        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
15359        .to_string();
15360    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
15361        return Err(invalid_feed(
15362            "brain card id differs from the explicitly requested brain id",
15363        ));
15364    }
15365    // The card supplies the canonical ULID. Lock alias + canonical keys in
15366    // deterministic filename order, then hold both through verify + save.
15367    // Concurrent aliases for the same brain therefore converge on one
15368    // checkpoint instead of establishing independent TOFU universes.
15369    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
15370    let (pinned, alias_binding) =
15371        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
15372    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
15373    let advertised_hash = body
15374        .get("feedHash")
15375        .and_then(Value::as_str)
15376        .map(str::to_string);
15377    let updated_at = body
15378        .get("updatedAt")
15379        .and_then(Value::as_str)
15380        .map(str::to_string);
15381    if let Some(pin) = &pinned {
15382        if seq < pin.head_seq {
15383            return Err(invalid_feed(format!(
15384                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
15385                pin.head_seq
15386            )));
15387        }
15388        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
15389            return Err(invalid_feed(
15390                "feed equivocation: the checkpoint sequence now has a different hash",
15391            ));
15392        }
15393    }
15394    if seq == 0 {
15395        if advertised_hash.is_some() {
15396            return Err(invalid_feed("an empty feed advertised a head hash"));
15397        }
15398        let identity: FeedIdentity = serde_json::from_value(
15399            body.get("identity")
15400                .cloned()
15401                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
15402        )
15403        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
15404        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
15405        // A valid old-key-signed rotation is still inconsistent if it commits
15406        // to history that the same card now claims never existed. Run the
15407        // identical feed-boundary proof used by non-empty heads before this
15408        // identity can become a durable TOFU checkpoint.
15409        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
15410        save_canonical_pin_and_alias(
15411            cfg,
15412            &trust_directory,
15413            brain,
15414            &resolved_brain,
15415            TrustState {
15416                v: 2,
15417                origin: normalized_origin(&cfg.hub)?,
15418                requested: resolved_brain.clone(),
15419                brain: resolved_brain.clone(),
15420                home: None,
15421                anchor: anchor.clone(),
15422                current: format!("ed25519:{}", identity.fingerprint),
15423                head_seq: 0,
15424                feed_hash: None,
15425                rotations: identity.rotations.clone(),
15426                hub_signer: None,
15427                protocol_profile: None,
15428            },
15429            alias_binding.as_ref(),
15430        )?;
15431        return Ok(VerifiedRemote {
15432            head: Head {
15433                brain: resolved_brain,
15434                seq,
15435                updated_at,
15436                feed_hash: None,
15437                verified: true,
15438            },
15439            identity: Some(identity),
15440            head_entry: None,
15441            entries: Vec::new(),
15442            anchor: Some(anchor),
15443        });
15444    }
15445    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
15446        return Err(invalid_feed(
15447            "non-empty feed did not advertise a valid SHA-256 head",
15448        ));
15449    }
15450
15451    // On first contact the signed head itself is the TOFU checkpoint. A full
15452    // history replay cannot add authority before an anchor exists; mirrors
15453    // still request the complete chain because they promise an archival copy.
15454    let replay_head_only = !require_full_chain
15455        && pinned
15456            .as_ref()
15457            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
15458    let mut after = if replay_head_only {
15459        seq - 1
15460    } else if require_full_chain || pinned.is_none() {
15461        0
15462    } else {
15463        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
15464    };
15465    let mut expected_seq = after + 1;
15466    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
15467        None
15468    } else {
15469        pinned
15470            .as_ref()
15471            .and_then(|checkpoint| checkpoint.feed_hash.clone())
15472    };
15473    let mut identity: Option<FeedIdentity> = None;
15474    let mut anchor: Option<String> = None;
15475    let mut head_entry: Option<FeedItem> = None;
15476    let mut all_entries = Vec::new();
15477    let mut observed_entries = Vec::new();
15478    let replay_count = seq
15479        .checked_sub(after)
15480        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
15481    if replay_count > MAX_FEED_REPLAY_ENTRIES {
15482        return Err(invalid_feed(format!(
15483            "feed replay requires {replay_count} entries, over the client cap"
15484        )));
15485    }
15486    let mut replay_bytes = 0u64;
15487
15488    loop {
15489        let feed_bytes = ensure_raw_ok(
15490            request_raw(
15491                cfg,
15492                "GET",
15493                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
15494                None,
15495                Auth::Required,
15496                MAX_FEED_RESPONSE_BYTES,
15497            )?,
15498            "subscribe feed",
15499        )?;
15500        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
15501            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
15502        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
15503            return Err(invalid_feed("brain card and feed head disagree"));
15504        }
15505        if feed.entries.len() > FEED_PAGE_LIMIT {
15506            return Err(invalid_feed("feed page exceeds the requested entry limit"));
15507        }
15508        if feed.scope_limited {
15509            if require_full_chain {
15510                return Err(invalid_feed(
15511                    "path-scoped grants cannot verify a full snapshot chain",
15512                ));
15513            }
15514            return Ok(VerifiedRemote {
15515                head: Head {
15516                    brain: resolved_brain,
15517                    seq,
15518                    updated_at,
15519                    feed_hash: advertised_hash,
15520                    verified: false,
15521                },
15522                identity: None,
15523                head_entry: None,
15524                entries: Vec::new(),
15525                anchor: None,
15526            });
15527        }
15528        let page_identity = feed
15529            .identity
15530            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
15531        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
15532        if identity
15533            .as_ref()
15534            .is_some_and(|existing| existing != &page_identity)
15535        {
15536            return Err(invalid_feed("identity changed while reading the feed"));
15537        }
15538        if anchor
15539            .as_ref()
15540            .is_some_and(|existing| existing != &page_anchor)
15541        {
15542            return Err(invalid_feed(
15543                "identity anchor changed while reading the feed",
15544            ));
15545        }
15546        identity = Some(page_identity.clone());
15547        if anchor.is_none() {
15548            anchor = Some(page_anchor);
15549        }
15550        if feed.entries.is_empty() {
15551            return Err(invalid_feed("feed page was empty before the signed head"));
15552        }
15553
15554        for item in feed.entries {
15555            if item.entry.seq != expected_seq {
15556                return Err(invalid_feed(format!(
15557                    "expected entry {expected_seq}, feed served {}",
15558                    item.entry.seq
15559                )));
15560            }
15561            if item.entry.seq > seq {
15562                return Err(invalid_feed("feed advanced past the card snapshot"));
15563            }
15564            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
15565                return Err(invalid_feed(format!(
15566                    "entry {} does not chain to the local checkpoint",
15567                    item.entry.seq
15568                )));
15569            }
15570            verify_feed_item(&item, &page_identity)?;
15571            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
15572            replay_bytes = replay_bytes.saturating_add(
15573                serde_json::to_vec(&item)
15574                    .map_err(|_| invalid_feed("could not size feed entry"))?
15575                    .len() as u64,
15576            );
15577            if replay_bytes > MAX_FEED_REPLAY_BYTES {
15578                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
15579            }
15580            previous_hash = Some(item.hash.clone());
15581            after = item.entry.seq;
15582            expected_seq = expected_seq
15583                .checked_add(1)
15584                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
15585            if require_full_chain {
15586                all_entries.push(item.clone());
15587            }
15588            observed_entries.push(item.clone());
15589            head_entry = Some(item);
15590        }
15591        if after == seq {
15592            break;
15593        }
15594    }
15595
15596    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
15597        return Err(invalid_feed(
15598            "verified chain does not converge on the advertised head",
15599        ));
15600    }
15601    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
15602    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
15603    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
15604    save_canonical_pin_and_alias(
15605        cfg,
15606        &trust_directory,
15607        brain,
15608        &resolved_brain,
15609        TrustState {
15610            v: 2,
15611            origin: normalized_origin(&cfg.hub)?,
15612            requested: resolved_brain.clone(),
15613            brain: resolved_brain.clone(),
15614            home: None,
15615            anchor: anchor.clone(),
15616            current: format!("ed25519:{}", identity.fingerprint),
15617            head_seq: seq,
15618            feed_hash: advertised_hash.clone(),
15619            rotations: identity.rotations.clone(),
15620            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
15621            protocol_profile: pinned
15622                .as_ref()
15623                .and_then(|state| state.protocol_profile.clone()),
15624        },
15625        alias_binding.as_ref(),
15626    )?;
15627    Ok(VerifiedRemote {
15628        head: Head {
15629            brain: resolved_brain,
15630            seq,
15631            updated_at,
15632            feed_hash: advertised_hash,
15633            verified: true,
15634        },
15635        identity: Some(identity),
15636        head_entry,
15637        entries: all_entries,
15638        anchor: Some(anchor),
15639    })
15640}
15641
15642/// Read and locally verify the brain's current signed feed head. Identity
15643/// rotation is accepted only through an old-key-signed chain rooted at the
15644/// local TOFU anchor; sequence and hash checkpoints reject rollback and
15645/// equivocation across invocations.
15646pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
15647    if let Some(verified) = v2_verified_head(cfg, brain)? {
15648        let observation = Head {
15649            brain: verified.brain_id.clone(),
15650            seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
15651            updated_at: verified
15652                .pointer
15653                .as_ref()
15654                .map(|pointer| pointer.signed_at.clone()),
15655            feed_hash: verified
15656                .pointer
15657                .as_ref()
15658                .map(|pointer| pointer.feed_hash.clone()),
15659            verified: true,
15660        };
15661        accept_v2_head(cfg, &verified)?;
15662        return Ok(observation);
15663    }
15664    Ok(verified_remote_head(cfg, brain, false)?.head)
15665}
15666
15667#[cfg(test)]
15668mod tests {
15669    use super::*;
15670
15671    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
15672
15673    fn drain_test_http_request(stream: &mut std::net::TcpStream) {
15674        use std::io::Read as _;
15675
15676        stream
15677            .set_read_timeout(Some(std::time::Duration::from_secs(2)))
15678            .unwrap();
15679        let mut request = Vec::new();
15680        let mut chunk = [0_u8; 4096];
15681        loop {
15682            let read = stream.read(&mut chunk).unwrap();
15683            assert!(read > 0, "test client closed before its request completed");
15684            request.extend_from_slice(&chunk[..read]);
15685            let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") else {
15686                continue;
15687            };
15688            let headers = String::from_utf8_lossy(&request[..header_end]);
15689            let content_length = headers
15690                .lines()
15691                .find_map(|line| {
15692                    let (name, value) = line.split_once(':')?;
15693                    name.eq_ignore_ascii_case("content-length")
15694                        .then(|| value.trim().parse::<usize>().unwrap())
15695                })
15696                .unwrap_or(0);
15697            if request.len() >= header_end + 4 + content_length {
15698                return;
15699            }
15700        }
15701    }
15702
15703    fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
15704        json!({
15705            "sha256": "a".repeat(64),
15706            "bytes": 10,
15707            "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
15708        })
15709    }
15710
15711    #[test]
15712    fn upload_reservations_batch_by_count_and_by_size() {
15713        // A real store's first push declares one blob per changed file, which
15714        // does not fit in one request. Every batch has to stay inside both
15715        // bounds, and concatenating them has to reproduce the original order.
15716        let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
15717        let batches = batch_upload_declarations(declarations.clone());
15718
15719        assert!(batches.len() > 1, "5,000 blobs must not ride one request");
15720        for batch in &batches {
15721            assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
15722            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15723                .expect("batch serializes")
15724                .len();
15725            assert!(
15726                bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
15727                "batch body {bytes} exceeds the reservation budget"
15728            );
15729        }
15730        let flattened: Vec<Value> = batches.into_iter().flatten().collect();
15731        assert_eq!(
15732            flattened, declarations,
15733            "batching must preserve the set and order"
15734        );
15735    }
15736
15737    #[test]
15738    fn only_load_shaped_hub_answers_are_worth_asking_again() {
15739        // A migration opens dozens of staging windows, so a busy hub's 429 or
15740        // 5xx must not end the push — while a refusal that states something
15741        // about the request itself has to surface immediately rather than
15742        // being repeated six times behind a minute of waiting.
15743        for status in [408, 429, 500, 502, 503, 504] {
15744            assert!(is_retryable_hub_status(status), "{status} is load-shaped");
15745        }
15746        for status in [400, 401, 403, 404, 409, 413, 422] {
15747            assert!(
15748                !is_retryable_hub_status(status),
15749                "{status} states something about the request"
15750            );
15751        }
15752        // The waits must reach past a whole limiter window to be worth taking.
15753        let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
15754        assert!(total >= 60_000, "backoff totals only {total}ms");
15755    }
15756
15757    #[test]
15758    fn a_batch_shares_a_connection_only_within_one_authority() {
15759        // Sharing a pooled agent is what makes a migration stage in minutes,
15760        // but it must never widen where bytes can go: a batch that spans two
15761        // authorities, or names an unsafe URL, falls back to per-object agents
15762        // rather than reusing a connection pinned to somewhere else.
15763        let cfg = HubConfig {
15764            hub: "https://www.sevrahq.com".to_string(),
15765            key: Some("k".to_string()),
15766            agent_key: None,
15767            brain_key: None,
15768            state_dir: PathBuf::from("."),
15769            store_selected: false,
15770        };
15771        assert!(shared_staging_agent(&cfg, &[]).is_none());
15772        assert!(
15773            shared_staging_agent(
15774                &cfg,
15775                &[
15776                    "https://one.example.com/a?sig=1",
15777                    "https://two.example.com/b?sig=2",
15778                ]
15779            )
15780            .is_none(),
15781            "two authorities must not share a pinned pool"
15782        );
15783        assert!(
15784            shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
15785            "an unsafe object-store URL must not produce an agent"
15786        );
15787        assert!(
15788            shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
15789            "credentials in the URL must not produce an agent"
15790        );
15791    }
15792
15793    #[test]
15794    fn a_staged_change_states_only_operations_and_blobs() {
15795        // The hub refuses a manifest with any other key, so the two sides have
15796        // to agree exactly. A drifting extra field would fail a whole migration
15797        // at the commit, after every byte had already been staged.
15798        let operations = vec![json!({
15799            "op": "put",
15800            "path": "records/a.md",
15801            "blob": "a".repeat(64),
15802            "bytes": 3,
15803        })];
15804        let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
15805        let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
15806        let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
15807        let keys: Vec<&str> = parsed
15808            .as_object()
15809            .expect("manifest is an object")
15810            .keys()
15811            .map(String::as_str)
15812            .collect();
15813        assert_eq!(keys, ["blobs", "operations"]);
15814        assert_eq!(parsed["operations"], Value::Array(operations));
15815        assert_eq!(parsed["blobs"], blobs);
15816    }
15817
15818    #[test]
15819    fn a_staged_push_signs_the_change_not_the_transport() {
15820        // Self-custody signs a request hash the hub recomputes from the parsed
15821        // change. A staged body no longer states its operations, so the signed
15822        // view has to restore them or every large self-custodied push would
15823        // sign a different request than the one the hub validated.
15824        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15825        let staged = json!({
15826            "mutation_id": "dbmd-1",
15827            "rebase": "strict",
15828            "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
15829        });
15830        let view = v2_signed_request_view(&staged, &operations);
15831        assert_eq!(view["operations"], Value::Array(operations.clone()));
15832        assert!(view.get("staged_change").is_none());
15833        assert_eq!(view["mutation_id"], staged["mutation_id"]);
15834
15835        let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
15836        assert_eq!(v2_signed_request_view(&inline, &[]), inline);
15837    }
15838
15839    #[test]
15840    fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
15841        let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
15842        let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
15843            .expect_err("an oversized change must not be staged");
15844        assert!(
15845            matches!(error, LinkError::PushTooLarge { .. }),
15846            "expected a size refusal, got {error:?}"
15847        );
15848    }
15849
15850    #[test]
15851    fn a_push_that_fits_the_request_is_left_inline() {
15852        // The staged lane costs a reservation and a round trip, so it must
15853        // engage only when the body genuinely does not fit. The hub here is a
15854        // closed port: reaching for it at all would fail this test.
15855        let cfg = HubConfig {
15856            hub: "http://127.0.0.1:9".to_string(),
15857            key: Some("k".to_string()),
15858            agent_key: None,
15859            brain_key: None,
15860            state_dir: PathBuf::from("."),
15861            store_selected: false,
15862        };
15863        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15864        let mut body = json!({
15865            "mutation_id": "dbmd-1",
15866            "operations": operations,
15867            "blobs": [],
15868        });
15869        stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
15870        assert!(body.get("staged_change").is_none());
15871        assert_eq!(body["operations"], Value::Array(operations));
15872    }
15873
15874    #[test]
15875    fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
15876        // Byte budget, not just count. One hash can carry many coordinates
15877        // (the same bytes reachable at several paths), so a batch sized only by
15878        // count would blow the body budget on a deduplicated store.
15879        let declarations: Vec<Value> = (0..2_000)
15880            .map(|index| {
15881                json!({
15882                    "sha256": "a".repeat(64),
15883                    "bytes": 10,
15884                    "coordinates": (0..24)
15885                        .map(|slot| format!(
15886                            "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15887                        ))
15888                        .collect::<Vec<_>>(),
15889                })
15890            })
15891            .collect();
15892        let batches = batch_upload_declarations(declarations);
15893        assert!(
15894            batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15895            "wide coordinate sets must bound the batch by size"
15896        );
15897        for batch in &batches {
15898            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15899                .expect("batch serializes")
15900                .len();
15901            assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15902        }
15903    }
15904
15905    #[test]
15906    fn a_small_push_still_rides_exactly_one_request() {
15907        let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
15908        assert_eq!(batch_upload_declarations(declarations).len(), 1);
15909        assert!(batch_upload_declarations(Vec::new()).is_empty());
15910    }
15911
15912    #[test]
15913    fn exact_source_move_becomes_one_provenance_preserving_rename() {
15914        let hash = "a".repeat(64);
15915        let operations = vec![
15916            json!({
15917                "op": "put",
15918                "path": "sources/curated/item.md",
15919                "expected": { "kind": "absent" },
15920                "blob": hash,
15921                "bytes": 19,
15922            }),
15923            json!({
15924                "op": "delete",
15925                "path": "sources/inbox/item.md",
15926                "expected": { "kind": "blob", "hash": hash },
15927            }),
15928        ];
15929
15930        assert_eq!(
15931            infer_exact_source_promotions(operations),
15932            vec![json!({
15933                "op": "rename",
15934                "from": "sources/inbox/item.md",
15935                "to": "sources/curated/item.md",
15936                "expected_from": { "kind": "blob", "hash": hash },
15937                "expected_to": { "kind": "absent" },
15938                "blob": hash,
15939                "bytes": 19,
15940            })]
15941        );
15942    }
15943
15944    #[test]
15945    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
15946        let hash = "b".repeat(64);
15947        let operations = vec![
15948            json!({
15949                "op": "delete",
15950                "path": "sources/inbox/a.md",
15951                "expected": { "kind": "blob", "hash": hash },
15952            }),
15953            json!({
15954                "op": "delete",
15955                "path": "sources/inbox/b.md",
15956                "expected": { "kind": "blob", "hash": hash },
15957            }),
15958            json!({
15959                "op": "put",
15960                "path": "sources/curated/item.md",
15961                "expected": { "kind": "absent" },
15962                "blob": hash,
15963                "bytes": 19,
15964            }),
15965        ];
15966
15967        assert_eq!(
15968            infer_exact_source_promotions(operations.clone()),
15969            operations,
15970            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
15971        );
15972    }
15973
15974    #[test]
15975    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
15976        let hash = "c".repeat(64);
15977        let mut candidate = std::collections::BTreeMap::from([(
15978            "sources/inbox/item.md".to_string(),
15979            V2BaselineFile {
15980                sha256: hash.clone(),
15981                bytes: 19,
15982                proof: None,
15983            },
15984        )]);
15985        let mut candidate_assets = std::collections::BTreeMap::new();
15986        let operations = vec![
15987            json!({
15988                "op": "rename",
15989                "from": "sources/inbox/item.md",
15990                "to": "sources/curated/item.md",
15991                "expected_from": { "kind": "blob", "hash": hash },
15992                "expected_to": { "kind": "absent" },
15993                "blob": hash,
15994                "bytes": 19,
15995            }),
15996            json!({
15997                "op": "put",
15998                "path": "records/rsvps/item.md",
15999                "expected": { "kind": "absent" },
16000                "blob": "d".repeat(64),
16001                "bytes": 23,
16002            }),
16003        ];
16004
16005        assert!(!apply_generated_v2_operations(
16006            &operations,
16007            &std::collections::BTreeMap::new(),
16008            &mut candidate,
16009            &mut candidate_assets,
16010        )
16011        .unwrap());
16012        assert!(!candidate.contains_key("sources/inbox/item.md"));
16013        assert_eq!(
16014            candidate
16015                .get("sources/curated/item.md")
16016                .map(|file| (&file.sha256, file.bytes)),
16017            Some((&hash, 19))
16018        );
16019        assert_eq!(
16020            candidate
16021                .get("records/rsvps/item.md")
16022                .map(|file| (file.sha256.as_str(), file.bytes)),
16023            Some((
16024                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
16025                23
16026            ))
16027        );
16028    }
16029
16030    fn merge_fixture(
16031        base: Option<&str>,
16032        remote: Option<&str>,
16033        local: Option<&str>,
16034        keep_local: bool,
16035    ) -> V2PulledMerge<String> {
16036        let map = |value: Option<&str>| {
16037            value
16038                .map(|value| [("records/a.md".to_string(), value.to_string())])
16039                .into_iter()
16040                .flatten()
16041                .collect::<std::collections::BTreeMap<_, _>>()
16042        };
16043        merge_v2_pulled_records(
16044            &map(base),
16045            &map(remote),
16046            &map(local),
16047            |value, _| value.clone(),
16048            |value, _| value.clone(),
16049            |_| keep_local,
16050        )
16051    }
16052
16053    #[test]
16054    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
16055        let path = "records/a.md".to_string();
16056
16057        let local_add = merge_fixture(None, None, Some("local"), false);
16058        assert_eq!(
16059            local_add.records.get(&path).map(String::as_str),
16060            Some("local")
16061        );
16062        assert!(local_add.accept_remote.is_empty());
16063        assert!(local_add.conflicts.is_empty());
16064
16065        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
16066        assert_eq!(
16067            local_edit.records.get(&path).map(String::as_str),
16068            Some("local")
16069        );
16070        assert!(local_edit.accept_remote.is_empty());
16071        assert!(local_edit.conflicts.is_empty());
16072
16073        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
16074        assert!(!local_delete.records.contains_key(&path));
16075        assert!(local_delete.accept_remote.is_empty());
16076        assert!(local_delete.conflicts.is_empty());
16077
16078        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
16079        assert_eq!(
16080            remote_edit.records.get(&path).map(String::as_str),
16081            Some("remote")
16082        );
16083        assert!(remote_edit.accept_remote.contains(&path));
16084        assert!(remote_edit.conflicts.is_empty());
16085
16086        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
16087        assert!(!remote_delete.records.contains_key(&path));
16088        assert!(remote_delete.accept_remote.contains(&path));
16089        assert!(remote_delete.conflicts.is_empty());
16090
16091        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
16092        assert_eq!(
16093            same_edit.records.get(&path).map(String::as_str),
16094            Some("same")
16095        );
16096        assert!(same_edit.accept_remote.contains(&path));
16097        assert!(same_edit.conflicts.is_empty());
16098
16099        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
16100        assert_eq!(conflict.conflicts, vec![path.clone()]);
16101        assert_eq!(
16102            conflict.records.get(&path).map(String::as_str),
16103            Some("local")
16104        );
16105        assert!(conflict.accept_remote.is_empty());
16106
16107        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
16108        assert_eq!(
16109            kept_home.records.get(&path).map(String::as_str),
16110            Some("local")
16111        );
16112        assert!(kept_home.accept_remote.is_empty());
16113        assert!(kept_home.conflicts.is_empty());
16114    }
16115
16116    #[test]
16117    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
16118        let path = "sources/report.pdf";
16119        let record = crate::AssetRecord {
16120            path: path.to_string(),
16121            sha256: "a".repeat(64),
16122            bytes: 42,
16123            media_type: "application/pdf".to_string(),
16124            wrappers: vec!["gzip".to_string()],
16125            required: true,
16126        };
16127        let mut remote = V2BaselineAsset {
16128            blob_sha256: record.sha256.clone(),
16129            bytes: record.bytes,
16130            media_type: record.media_type.clone(),
16131            wrappers: record.wrappers.clone(),
16132            required: record.required,
16133            disposition: "withheld".to_string(),
16134            leaf_hash: "b".repeat(64),
16135        };
16136
16137        assert!(v2_asset_resumes_hosting(
16138            Some(&remote),
16139            path,
16140            &record,
16141            "hosted"
16142        ));
16143        assert!(!v2_asset_resumes_hosting(
16144            Some(&remote),
16145            path,
16146            &record,
16147            "withheld"
16148        ));
16149
16150        remote.disposition = "hosted".to_string();
16151        assert!(!v2_asset_resumes_hosting(
16152            Some(&remote),
16153            path,
16154            &record,
16155            "hosted"
16156        ));
16157
16158        remote.disposition = "withheld".to_string();
16159        remote.blob_sha256 = "c".repeat(64);
16160        assert!(!v2_asset_resumes_hosting(
16161            Some(&remote),
16162            path,
16163            &record,
16164            "hosted"
16165        ));
16166        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
16167    }
16168
16169    #[test]
16170    fn v2_fresh_clone_preserves_only_exact_inherited_withheld_asset_absence() {
16171        let path = "sources/report.pdf";
16172        let record = crate::AssetRecord {
16173            path: path.to_string(),
16174            sha256: "a".repeat(64),
16175            bytes: 42,
16176            media_type: "application/pdf".to_string(),
16177            wrappers: vec!["records/report.md".to_string()],
16178            required: true,
16179        };
16180        let mut base = V2BaselineAsset {
16181            blob_sha256: record.sha256.clone(),
16182            bytes: record.bytes,
16183            media_type: record.media_type.clone(),
16184            wrappers: record.wrappers.clone(),
16185            required: record.required,
16186            disposition: "withheld".to_string(),
16187            leaf_hash: "b".repeat(64),
16188        };
16189
16190        assert!(v2_asset_inherits_withheld_absence(
16191            Some(&base),
16192            Some(&record),
16193            Some(&record),
16194            false,
16195        ));
16196        assert!(!v2_asset_inherits_withheld_absence(
16197            Some(&base),
16198            Some(&record),
16199            Some(&record),
16200            true,
16201        ));
16202
16203        base.disposition = "hosted".to_string();
16204        assert!(!v2_asset_inherits_withheld_absence(
16205            Some(&base),
16206            Some(&record),
16207            Some(&record),
16208            false,
16209        ));
16210
16211        base.disposition = "withheld".to_string();
16212        let mut changed = record.clone();
16213        changed.bytes += 1;
16214        assert!(!v2_asset_inherits_withheld_absence(
16215            Some(&base),
16216            Some(&record),
16217            Some(&changed),
16218            false,
16219        ));
16220        assert!(!v2_asset_inherits_withheld_absence(
16221            None,
16222            None,
16223            Some(&record),
16224            false,
16225        ));
16226    }
16227
16228    #[test]
16229    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
16230        let path = "records/team/alpha.md".to_string();
16231        let deleted_path = "records/team/deleted.md".to_string();
16232        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
16233            sha256,
16234            bytes,
16235            file: None,
16236        };
16237        let files = vec![
16238            V2ConflictFile {
16239                path: path.clone(),
16240                base: coordinate(None, None),
16241                local: coordinate(Some("b".repeat(64)), Some(7)),
16242                remote: coordinate(Some("a".repeat(64)), Some(5)),
16243            },
16244            V2ConflictFile {
16245                path: deleted_path.clone(),
16246                base: coordinate(Some("c".repeat(64)), Some(9)),
16247                local: coordinate(Some("d".repeat(64)), Some(11)),
16248                remote: coordinate(None, None),
16249            },
16250        ];
16251        let proven = V2BaselineFile {
16252            sha256: "a".repeat(64),
16253            bytes: 5,
16254            proof: None,
16255        };
16256        let current = [(path.clone(), proven.clone())]
16257            .into_iter()
16258            .collect::<std::collections::BTreeMap<_, _>>();
16259
16260        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
16261        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
16262        assert_eq!(deleted, vec![deleted_path.clone()]);
16263
16264        let changed = [(
16265            path.clone(),
16266            V2BaselineFile {
16267                sha256: "e".repeat(64),
16268                bytes: 5,
16269                proof: None,
16270            },
16271        )]
16272        .into_iter()
16273        .collect::<std::collections::BTreeMap<_, _>>();
16274        assert!(v2_take_remote_selection(&files, &changed).is_err());
16275
16276        let resurrected = [
16277            (path, proven),
16278            (
16279                deleted_path,
16280                V2BaselineFile {
16281                    sha256: "f".repeat(64),
16282                    bytes: 13,
16283                    proof: None,
16284                },
16285            ),
16286        ]
16287        .into_iter()
16288        .collect::<std::collections::BTreeMap<_, _>>();
16289        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
16290    }
16291
16292    #[cfg(target_os = "linux")]
16293    #[test]
16294    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
16295        use std::os::fd::AsRawFd as _;
16296
16297        let sandbox = tempfile::TempDir::new().unwrap();
16298        let parent = std::fs::File::open(sandbox.path()).unwrap();
16299        let stage = std::ffi::CString::new("stage").unwrap();
16300        let destination = std::ffi::CString::new("brain").unwrap();
16301
16302        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
16303        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
16304        install_stage_at(
16305            parent.as_raw_fd(),
16306            stage.as_c_str(),
16307            destination.as_c_str(),
16308            false,
16309        )
16310        .unwrap();
16311        assert!(!sandbox.path().join("stage").exists());
16312        assert_eq!(
16313            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
16314            b"created"
16315        );
16316
16317        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
16318        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
16319        install_stage_at(
16320            parent.as_raw_fd(),
16321            stage.as_c_str(),
16322            destination.as_c_str(),
16323            true,
16324        )
16325        .unwrap();
16326        assert_eq!(
16327            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
16328            b"replacement"
16329        );
16330        assert_eq!(
16331            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
16332            b"created",
16333            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
16334        );
16335    }
16336
16337    struct SignedRemoteFixture {
16338        card: String,
16339        feed: String,
16340        key: AgentSigningKey,
16341        identity: FeedIdentity,
16342    }
16343
16344    fn signed_remote_fixture() -> SignedRemoteFixture {
16345        let rng = ring::rand::SystemRandom::new();
16346        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16347        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16348        let (public_key, multikey) = public_identity_for(&pair);
16349        let identity = FeedIdentity {
16350            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16351            public_key_spki: public_key.clone(),
16352            previous: Vec::new(),
16353            rotations: Vec::new(),
16354        };
16355        let mut entry = FeedEntry {
16356            v: 1,
16357            seq: 1,
16358            ts: "2026-07-30T12:00:00.000Z".to_string(),
16359            brain: multikey.clone(),
16360            public_key: public_key.clone(),
16361            kind: "push".to_string(),
16362            op: "snapshot".to_string(),
16363            pack_sha256: "a".repeat(64),
16364            files: Vec::new(),
16365            removed: Vec::new(),
16366            prev_entry_hash: None,
16367            sig: String::new(),
16368        };
16369        let unsigned = UnsignedFeedEntry {
16370            v: entry.v,
16371            seq: entry.seq,
16372            ts: &entry.ts,
16373            brain: &entry.brain,
16374            public_key: &entry.public_key,
16375            kind: &entry.kind,
16376            op: &entry.op,
16377            pack_sha256: &entry.pack_sha256,
16378            files: &entry.files,
16379            removed: &entry.removed,
16380            prev_entry_hash: &entry.prev_entry_hash,
16381        };
16382        entry.sig =
16383            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16384        let mut exact = serde_json::to_vec(&entry).unwrap();
16385        exact.push(b'\n');
16386        let hash = content_sha256(&exact);
16387        let card = json!({
16388            "id": TEST_BRAIN_ID,
16389            "headSeq": 1,
16390            "feedHash": hash,
16391            "identity": identity.clone(),
16392        })
16393        .to_string();
16394        let feed = json!({
16395            "headSeq": 1,
16396            "feedHash": hash,
16397            "identity": identity.clone(),
16398            "entries": [{"hash": hash, "entry": entry}],
16399            "scopeLimited": false,
16400        })
16401        .to_string();
16402        SignedRemoteFixture {
16403            card,
16404            feed,
16405            key: AgentSigningKey {
16406                pkcs8: pkcs8.as_ref().to_vec(),
16407                multikey,
16408                public_key_spki: public_key,
16409            },
16410            identity,
16411        }
16412    }
16413
16414    #[test]
16415    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
16416        let file = |path: &str, byte: char| FeedFile {
16417            path: path.to_string(),
16418            sha256: byte.to_string().repeat(64),
16419            bytes: 1,
16420        };
16421        let a0 = file("records/a.md", 'a');
16422        let a1 = file("records/a.md", 'b');
16423        let stable = file("records/stable.md", 'c');
16424        let added = file("records/added.md", 'd');
16425        let removed_file = file("records/removed.md", 'e');
16426        let previous = vec![a0, stable.clone(), removed_file.clone()];
16427        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
16428        let removed = vec![removed_file.path.clone()];
16429
16430        assert_eq!(
16431            verify_v1_manifest_disclosure(
16432                "edit",
16433                &previous,
16434                &resulting,
16435                &[a1.clone(), added.clone()],
16436                &removed,
16437            ),
16438            Ok(())
16439        );
16440        assert_eq!(
16441            verify_v1_manifest_disclosure(
16442                "edit",
16443                &previous,
16444                &resulting,
16445                &[stable.clone(), added.clone(), a1.clone()],
16446                &removed,
16447            ),
16448            Ok(())
16449        );
16450        assert_eq!(
16451            verify_v1_manifest_disclosure(
16452                "edit",
16453                &previous,
16454                &resulting,
16455                std::slice::from_ref(&added),
16456                &removed,
16457            ),
16458            Err(V1DisclosureError::EditMissingChange)
16459        );
16460        assert_eq!(
16461            verify_v1_manifest_disclosure(
16462                "edit",
16463                &previous,
16464                &resulting,
16465                &[file("records/a.md", 'f'), added.clone()],
16466                &removed,
16467            ),
16468            Err(V1DisclosureError::EditFalseFile)
16469        );
16470        assert_eq!(
16471            verify_v1_manifest_disclosure(
16472                "edit",
16473                &previous,
16474                &resulting,
16475                &[a1.clone(), added.clone()],
16476                &[],
16477            ),
16478            Err(V1DisclosureError::RemovedMismatch)
16479        );
16480        assert_eq!(
16481            verify_v1_manifest_disclosure(
16482                "push",
16483                &previous,
16484                &resulting,
16485                &[added.clone(), stable, a1],
16486                &removed,
16487            ),
16488            Ok(())
16489        );
16490        assert_eq!(
16491            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
16492            Err(V1DisclosureError::PushManifestMismatch)
16493        );
16494    }
16495
16496    #[test]
16497    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
16498        let fixture = signed_remote_fixture();
16499        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
16500        let item = feed["entries"][0].to_string();
16501        let oversized_page = format!(
16502            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
16503            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
16504                .collect::<Vec<_>>()
16505                .join(",")
16506        );
16507        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
16508
16509        let oversized_identity = format!(
16510            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
16511            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
16512                .collect::<Vec<_>>()
16513                .join(",")
16514        );
16515        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
16516
16517        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
16518        let oversized_entry = format!(
16519            "{{\"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\"}}",
16520            "a".repeat(64),
16521            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
16522                .collect::<Vec<_>>()
16523                .join(",")
16524        );
16525        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
16526    }
16527
16528    #[test]
16529    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
16530        let id = "01arz3ndektsv4rrffq69g5fav";
16531        let digest = "a".repeat(64);
16532        assert_eq!(
16533            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
16534            V2BulkConfirmation {
16535                id: id.to_string(),
16536                digest,
16537            }
16538        );
16539        for invalid in [
16540            "",
16541            "01arz3ndektsv4rrffq69g5fav",
16542            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
16543            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
16544            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
16545        ] {
16546            assert!(matches!(
16547                V2BulkConfirmation::parse(invalid),
16548                Err(LinkError::InvalidPack { .. })
16549            ));
16550        }
16551    }
16552
16553    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
16554        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
16555        use std::net::TcpListener;
16556
16557        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16558        let url = format!("http://{}", listener.local_addr().unwrap());
16559        let handle = std::thread::spawn(move || {
16560            for (status, body) in responses {
16561                let (stream, _) = listener.accept().unwrap();
16562                let mut reader = BufReader::new(stream);
16563                let mut line = String::new();
16564                reader.read_line(&mut line).unwrap();
16565                let mut content_length = 0usize;
16566                loop {
16567                    line.clear();
16568                    reader.read_line(&mut line).unwrap();
16569                    if line == "\r\n" || line == "\n" || line.is_empty() {
16570                        break;
16571                    }
16572                    if let Some((name, value)) = line.split_once(':') {
16573                        if name.eq_ignore_ascii_case("content-length") {
16574                            content_length = value.trim().parse().unwrap();
16575                        }
16576                    }
16577                }
16578                let mut request_body = vec![0_u8; content_length];
16579                reader.read_exact(&mut request_body).unwrap();
16580                let response = format!(
16581                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
16582                    body.len()
16583                );
16584                reader.get_mut().write_all(response.as_bytes()).unwrap();
16585            }
16586        });
16587        (url, handle)
16588    }
16589
16590    fn routed_json_hub(
16591        requests: usize,
16592        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
16593    ) -> (String, std::thread::JoinHandle<()>) {
16594        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
16595        use std::net::TcpListener;
16596
16597        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16598        let url = format!("http://{}", listener.local_addr().unwrap());
16599        let handle = std::thread::spawn(move || {
16600            for _ in 0..requests {
16601                let (stream, _) = listener.accept().unwrap();
16602                let mut reader = BufReader::new(stream);
16603                let mut line = String::new();
16604                reader.read_line(&mut line).unwrap();
16605                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
16606                let mut content_length = 0usize;
16607                loop {
16608                    line.clear();
16609                    reader.read_line(&mut line).unwrap();
16610                    if line == "\r\n" || line == "\n" || line.is_empty() {
16611                        break;
16612                    }
16613                    if let Some((name, value)) = line.split_once(':') {
16614                        if name.eq_ignore_ascii_case("content-length") {
16615                            content_length = value.trim().parse().unwrap();
16616                        }
16617                    }
16618                }
16619                let mut request_body = vec![0_u8; content_length];
16620                reader.read_exact(&mut request_body).unwrap();
16621                let (status, body) = respond(&path);
16622                let response = format!(
16623                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
16624                    body.len()
16625                );
16626                reader.get_mut().write_all(response.as_bytes()).unwrap();
16627            }
16628        });
16629        (url, handle)
16630    }
16631
16632    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
16633        HubConfig {
16634            hub,
16635            key: Some("test-key".to_string()),
16636            agent_key: None,
16637            brain_key: None,
16638            state_dir,
16639            store_selected: false,
16640        }
16641    }
16642
16643    #[cfg(any(unix, windows))]
16644    #[test]
16645    fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
16646        use std::sync::{Arc, Mutex};
16647
16648        let bytes = b"immutable asset bytes".to_vec();
16649        let sha256 = content_sha256(&bytes);
16650        let commit_hash = "c".repeat(64);
16651        let base_url = Arc::new(Mutex::new(String::new()));
16652        let server_base = Arc::clone(&base_url);
16653        let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16654        let server_attempt = Arc::clone(&object_attempt);
16655        let response_bytes = bytes.clone();
16656        let response_sha = sha256.clone();
16657        let response_commit = commit_hash.clone();
16658        let (hub, server) = routed_json_hub(4, move |path| {
16659            if path.contains("/v2/assets/downloads") {
16660                let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
16661                let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
16662                return (
16663                    200,
16664                    json!({
16665                        "v": 2,
16666                        "commit": response_commit,
16667                        "downloads": [{
16668                            "path": "assets/proof.bin",
16669                            "sha256": response_sha,
16670                            "bytes": response_bytes.len(),
16671                            "url": url,
16672                            "method": "GET"
16673                        }]
16674                    })
16675                    .to_string(),
16676                );
16677            }
16678            let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16679            if attempt == 0 {
16680                (403, "{}".to_string())
16681            } else {
16682                (200, String::from_utf8(response_bytes.clone()).unwrap())
16683            }
16684        });
16685        *base_url.lock().unwrap() = hub.clone();
16686
16687        let temp = tempfile::tempdir().unwrap();
16688        let cache = temp.path().join("cache");
16689        std::fs::create_dir(&cache).unwrap();
16690        let cfg = test_hub_config(hub, temp.path().to_path_buf());
16691        let pointer = V2PointerBody {
16692            v: 2,
16693            brain: TEST_BRAIN_ID.to_string(),
16694            seq: 1,
16695            commit_hash,
16696            feed_hash: "f".repeat(64),
16697            content_root: Some("a".repeat(64)),
16698            asset_root: Some("b".repeat(64)),
16699            materializer: "m".repeat(64),
16700            signer_epoch: 1,
16701            control_revision: "d".repeat(64),
16702            backup_preparation: "ready".to_string(),
16703            prior_pointer_hash: None,
16704            signed_at: "2026-08-23T00:00:00Z".to_string(),
16705        };
16706        let path = "assets/proof.bin".to_string();
16707        let asset = V2BaselineAsset {
16708            blob_sha256: sha256.clone(),
16709            bytes: bytes.len() as u64,
16710            media_type: "application/octet-stream".to_string(),
16711            wrappers: Vec::new(),
16712            required: true,
16713            disposition: "hosted".to_string(),
16714            leaf_hash: "e".repeat(64),
16715        };
16716
16717        let staged = stage_v2_asset_download_window(
16718            &cfg,
16719            TEST_BRAIN_ID,
16720            &pointer,
16721            &cache,
16722            &[(&path, &asset)],
16723        )
16724        .expect("a fresh authority-checked capability recovers an expired one");
16725        assert_eq!(staged.len(), 1);
16726        assert_eq!(staged[0].path, path);
16727        assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
16728        assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
16729        server.join().unwrap();
16730    }
16731
16732    #[cfg(any(unix, windows))]
16733    #[test]
16734    fn asset_capability_windows_cannot_exceed_the_worker_bound() {
16735        let temp = tempfile::tempdir().unwrap();
16736        let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
16737        let pointer = V2PointerBody {
16738            v: 2,
16739            brain: TEST_BRAIN_ID.to_string(),
16740            seq: 1,
16741            commit_hash: "c".repeat(64),
16742            feed_hash: "f".repeat(64),
16743            content_root: Some("a".repeat(64)),
16744            asset_root: Some("b".repeat(64)),
16745            materializer: "m".repeat(64),
16746            signer_epoch: 1,
16747            control_revision: "d".repeat(64),
16748            backup_preparation: "ready".to_string(),
16749            prior_pointer_hash: None,
16750            signed_at: "2026-08-23T00:00:00Z".to_string(),
16751        };
16752        let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
16753            .map(|index| format!("assets/{index}.bin"))
16754            .collect::<Vec<_>>();
16755        let assets = paths
16756            .iter()
16757            .map(|_| V2BaselineAsset {
16758                blob_sha256: "a".repeat(64),
16759                bytes: 1,
16760                media_type: "application/octet-stream".to_string(),
16761                wrappers: Vec::new(),
16762                required: true,
16763                disposition: "hosted".to_string(),
16764                leaf_hash: "b".repeat(64),
16765            })
16766            .collect::<Vec<_>>();
16767        let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
16768
16769        let error =
16770            stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
16771                .expect_err("an oversized window must fail before any network request");
16772        assert!(matches!(error, LinkError::InvalidFeed { .. }));
16773    }
16774
16775    #[test]
16776    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
16777        use ring::signature::KeyPair as _;
16778
16779        let rng = ring::rand::SystemRandom::new();
16780        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16781        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16782        let (spki, multikey) = public_identity_for(&pair);
16783        let key = AgentSigningKey {
16784            pkcs8: pkcs8.as_ref().to_vec(),
16785            multikey,
16786            public_key_spki: spki,
16787        };
16788        let header = linkmd_sig_header(
16789            &key,
16790            "https://hub-a.example",
16791            "post",
16792            "/api/hub/brains/brain/push?mode=exact",
16793            Some("{\"ok\":true}"),
16794        )
16795        .unwrap();
16796        assert!(header.starts_with("LinkMD-Sig v2,"));
16797        let ts = header
16798            .split(",ts=")
16799            .nth(1)
16800            .unwrap()
16801            .split(',')
16802            .next()
16803            .unwrap();
16804        let signature = URL_SAFE_NO_PAD
16805            .decode(header.rsplit(",sig=").next().unwrap())
16806            .unwrap();
16807        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
16808        let accepted = format!(
16809            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16810        );
16811        let replayed = format!(
16812            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16813        );
16814        let public = pair.public_key().as_ref();
16815        assert!(UnparsedPublicKey::new(&ED25519, public)
16816            .verify(accepted.as_bytes(), &signature)
16817            .is_ok());
16818        assert!(
16819            UnparsedPublicKey::new(&ED25519, public)
16820                .verify(replayed.as_bytes(), &signature)
16821                .is_err(),
16822            "a proof captured at hub A must not authenticate at hub B"
16823        );
16824    }
16825
16826    #[test]
16827    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
16828        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16829        let card = json!({
16830            "id": other,
16831            "headSeq": 0,
16832            "identity": signed_remote_fixture().identity,
16833        })
16834        .to_string();
16835        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16836        let state = tempfile::tempdir().unwrap();
16837        let cfg = test_hub_config(hub, state.path().to_path_buf());
16838        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16839        assert!(
16840            error.contains("differs from the explicitly requested"),
16841            "{error}"
16842        );
16843        server.join().unwrap();
16844    }
16845
16846    #[test]
16847    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
16848        let first = signed_remote_fixture().identity;
16849        let second = signed_remote_fixture().identity;
16850        let card = |identity: FeedIdentity| {
16851            json!({
16852                "id": TEST_BRAIN_ID,
16853                "headSeq": 0,
16854                "identity": identity,
16855            })
16856            .to_string()
16857        };
16858        let (hub, server) = scripted_json_hub(vec![
16859            (404, "{}".to_string()),
16860            (200, card(first)),
16861            (404, "{}".to_string()),
16862            (200, card(second)),
16863        ]);
16864        let state = tempfile::tempdir().unwrap();
16865        let cfg = test_hub_config(hub, state.path().to_path_buf());
16866        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16867        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16868        assert!(
16869            error.contains("pinned anchor") || error.contains("forked away"),
16870            "{error}"
16871        );
16872        server.join().unwrap();
16873    }
16874
16875    #[test]
16876    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
16877        let old = signed_remote_fixture();
16878        let new = signed_remote_fixture();
16879        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
16880        let unsigned = serde_json::to_string(&UnsignedRotation {
16881            v: 1,
16882            op: "rotate",
16883            brain: &old.key.multikey,
16884            public_key: &old.key.public_key_spki,
16885            new_brain: &new.key.multikey,
16886            new_public_key: &new.key.public_key_spki,
16887            prior_head_seq: 1,
16888            prior_feed_hash: Some(&"a".repeat(64)),
16889            ts: "2026-07-30T12:00:00.000Z".to_string(),
16890        })
16891        .unwrap();
16892        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
16893        let rotation = format!(
16894            "{},\"sig\":\"{}\"}}",
16895            &unsigned[..unsigned.len() - 1],
16896            signature
16897        );
16898        let identity = FeedIdentity {
16899            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
16900            public_key_spki: new.key.public_key_spki,
16901            previous: vec![PreviousIdentity {
16902                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
16903                public_key_spki: old.key.public_key_spki,
16904            }],
16905            rotations: vec![rotation],
16906        };
16907        let card = json!({
16908            "id": TEST_BRAIN_ID,
16909            "headSeq": 0,
16910            "feedHash": null,
16911            "identity": identity,
16912        })
16913        .to_string();
16914        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16915        let state = tempfile::tempdir().unwrap();
16916        let cfg = test_hub_config(hub, state.path().to_path_buf());
16917        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16918        assert!(
16919            error.contains("rotation claims a feed boundary beyond the advertised head"),
16920            "{error}"
16921        );
16922        assert!(
16923            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
16924            "an inconsistent empty-head identity must not become the TOFU checkpoint"
16925        );
16926        server.join().unwrap();
16927    }
16928
16929    #[test]
16930    fn trust_checkpoint_rejects_a_later_fork() {
16931        let fixture = signed_remote_fixture();
16932        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
16933        fork["feedHash"] = Value::String("b".repeat(64));
16934        let (hub, server) = scripted_json_hub(vec![
16935            (404, "{}".to_string()),
16936            (200, fixture.card),
16937            (200, fixture.feed),
16938            (404, "{}".to_string()),
16939            (200, fork.to_string()),
16940        ]);
16941        let state = tempfile::tempdir().unwrap();
16942        let cfg = test_hub_config(hub, state.path().to_path_buf());
16943        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16944        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
16945        server.join().unwrap();
16946    }
16947
16948    #[test]
16949    fn alias_and_canonical_id_share_one_identity_checkpoint() {
16950        let trusted = signed_remote_fixture();
16951        let attacker = signed_remote_fixture();
16952        let (hub, server) = scripted_json_hub(vec![
16953            (404, "{}".to_string()),
16954            (200, trusted.card),
16955            (200, trusted.feed),
16956            (404, "{}".to_string()),
16957            (200, attacker.card),
16958        ]);
16959        let state = tempfile::tempdir().unwrap();
16960        let cfg = test_hub_config(hub, state.path().to_path_buf());
16961        assert!(head(&cfg, "trusted-slug").unwrap().verified);
16962        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16963        assert!(
16964            error.contains("equivocation")
16965                || error.contains("pinned")
16966                || error.contains("identity"),
16967            "{error}"
16968        );
16969        server.join().unwrap();
16970    }
16971
16972    #[test]
16973    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
16974        let state = tempfile::tempdir().unwrap();
16975        let cfg = test_hub_config(
16976            "https://hub.example".to_string(),
16977            state.path().to_path_buf(),
16978        );
16979        let directory = open_trust_dir(&cfg).unwrap();
16980        let old = TEST_BRAIN_ID;
16981        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16982        save_alias_in(
16983            &cfg,
16984            &directory,
16985            &AliasBinding {
16986                v: 1,
16987                origin: normalized_origin(&cfg.hub).unwrap(),
16988                requested: "company-brain".to_string(),
16989                brain: old.to_string(),
16990                home: Some("company-brain".to_string()),
16991            },
16992        )
16993        .unwrap();
16994
16995        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
16996        assert!(matches!(
16997            error,
16998            LinkError::AliasRebindRequired {
16999                alias,
17000                from,
17001                to
17002            } if alias == "company-brain" && from == old && to == new
17003        ));
17004        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
17005            .unwrap()
17006            .unwrap();
17007        assert_eq!(unchanged.brain, old);
17008        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
17009    }
17010
17011    #[test]
17012    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
17013        let alpha = signed_remote_fixture();
17014        let beta = signed_remote_fixture();
17015        let alpha_card = alpha.card.clone();
17016        let alpha_feed = alpha.feed.clone();
17017        let beta_card = beta.card.clone();
17018        let beta_feed = beta.feed.clone();
17019        let (hub, server) = routed_json_hub(5, move |path| {
17020            if path.ends_with("/v2/head") {
17021                (404, "{}".to_string())
17022            } else if path.contains("/alpha/feed?") {
17023                (200, alpha_feed.clone())
17024            } else if path.contains("/beta/feed?") {
17025                (200, beta_feed.clone())
17026            } else if path.ends_with("/alpha") {
17027                (200, alpha_card.clone())
17028            } else if path.ends_with("/beta") {
17029                (200, beta_card.clone())
17030            } else {
17031                (500, r#"{"error":"unexpected path"}"#.to_string())
17032            }
17033        });
17034        let state = tempfile::tempdir().unwrap();
17035        let cfg = test_hub_config(hub, state.path().to_path_buf());
17036        let alpha_cfg = cfg.clone();
17037        let beta_cfg = cfg;
17038        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
17039        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
17040        let results = [first.join().unwrap(), second.join().unwrap()];
17041        assert_eq!(
17042            results.iter().filter(|result| result.is_ok()).count(),
17043            1,
17044            "only one alias identity may establish canonical TOFU: {results:?}"
17045        );
17046        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
17047        server.join().unwrap();
17048    }
17049
17050    #[cfg(unix)]
17051    #[test]
17052    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
17053        use std::os::unix::fs::symlink;
17054
17055        let fixture = signed_remote_fixture();
17056        let card = json!({
17057            "id": TEST_BRAIN_ID,
17058            "headSeq": 0,
17059            "feedHash": Value::Null,
17060            "identity": fixture.identity,
17061        })
17062        .to_string();
17063        let work = tempfile::tempdir().unwrap();
17064        let outside = tempfile::tempdir().unwrap();
17065        let state = work.path().join("state");
17066        let moved = work.path().join("state-held");
17067        let swap_state = state.clone();
17068        let swap_moved = moved.clone();
17069        let outside_path = outside.path().to_path_buf();
17070        let (hub, server) = routed_json_hub(1, move |_| {
17071            // The client has already opened state/trust before this response.
17072            std::fs::rename(&swap_state, &swap_moved).unwrap();
17073            symlink(&outside_path, &swap_state).unwrap();
17074            (200, card.clone())
17075        });
17076        let cfg = test_hub_config(hub, state);
17077
17078        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
17079        assert_eq!(verified.head.seq, 0);
17080        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
17081        assert!(std::fs::read_dir(moved.join("trust"))
17082            .unwrap()
17083            .flatten()
17084            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
17085        server.join().unwrap();
17086    }
17087
17088    #[test]
17089    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
17090        let remote = signed_remote_fixture();
17091        let unrelated = signed_remote_fixture().key;
17092        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
17093        let state = tempfile::tempdir().unwrap();
17094        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
17095        cfg.brain_key = Some(unrelated);
17096        let error = sync_push(
17097            &cfg,
17098            TEST_BRAIN_ID,
17099            &[("DB.md".to_string(), "signed local content".to_string())],
17100        )
17101        .unwrap_err()
17102        .to_string();
17103        assert!(
17104            error.contains("not the verified current brain identity"),
17105            "{error}"
17106        );
17107        server.join().unwrap();
17108    }
17109
17110    #[test]
17111    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
17112        let remote = signed_remote_fixture();
17113        let new = signed_remote_fixture().key;
17114        let state = tempfile::tempdir().unwrap();
17115        let new_file = state.path().join("new.key");
17116        std::fs::write(
17117            &new_file,
17118            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
17119        )
17120        .unwrap();
17121        #[cfg(unix)]
17122        {
17123            use std::os::unix::fs::PermissionsExt as _;
17124            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
17125        }
17126        let forged = json!({
17127            "brain": TEST_BRAIN_ID,
17128            "identity": {
17129                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
17130                "publicKeySpki": new.public_key_spki,
17131            }
17132        })
17133        .to_string();
17134        let (hub, server) = scripted_json_hub(vec![
17135            (404, "{}".to_string()),
17136            (200, remote.card.clone()),
17137            (200, remote.feed.clone()),
17138            (200, forged),
17139            (200, remote.card),
17140            (200, remote.feed),
17141        ]);
17142        let cfg = test_hub_config(hub, state.path().to_path_buf());
17143        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
17144            .unwrap_err()
17145            .to_string();
17146        assert!(
17147            error.contains("without committing the verified new identity"),
17148            "{error}"
17149        );
17150        server.join().unwrap();
17151    }
17152
17153    #[test]
17154    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
17155        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17156        let raw = format!(
17157            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
17158        );
17159        let pack = build_store_pack(&[
17160            (
17161                "DB.md".to_string(),
17162                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
17163            ),
17164            ("records/clients/truth.md".to_string(), raw.clone()),
17165        ])
17166        .unwrap();
17167        let by_id = resolve_from_verified_pack(
17168            "01j5qc3v9k4ym8rwbn2tqe6f7d",
17169            &AddressTarget::Id(record_id.to_string()),
17170            pack.clone(),
17171        )
17172        .unwrap();
17173        assert_eq!(by_id["document"]["summary"], "Signed truth");
17174        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
17175        assert_eq!(
17176            by_id["document"]["contentSha"],
17177            content_sha256(raw.as_bytes())
17178        );
17179
17180        let by_path = resolve_from_verified_pack(
17181            "01j5qc3v9k4ym8rwbn2tqe6f7d",
17182            &AddressTarget::Path("records/clients/truth.md".to_string()),
17183            pack,
17184        )
17185        .unwrap();
17186        assert_eq!(by_path["document"]["id"], record_id);
17187        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
17188
17189        let wrong_id = resolve_from_verified_record_bytes(
17190            TEST_BRAIN_ID,
17191            &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
17192            "records/clients/truth.md".to_string(),
17193            raw.as_bytes().to_vec(),
17194        )
17195        .unwrap_err()
17196        .to_string();
17197        assert!(wrong_id.contains("id differs"), "{wrong_id}");
17198
17199        let wrong_path = resolve_from_verified_record_bytes(
17200            TEST_BRAIN_ID,
17201            &AddressTarget::Path("records/clients/other.md".to_string()),
17202            "records/clients/truth.md".to_string(),
17203            raw.into_bytes(),
17204        )
17205        .unwrap_err()
17206        .to_string();
17207        assert!(wrong_path.contains("path differs"), "{wrong_path}");
17208    }
17209
17210    #[test]
17211    fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
17212        let path = "records/clients/truth.md";
17213        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17214        let raw = format!(
17215            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
17216        );
17217        let sha256 = content_sha256(raw.as_bytes());
17218        let mut nonce = 0_u128;
17219        let tree = crate::linkmd_v2::build_content_tree(
17220            &[crate::linkmd_v2::ContentFile {
17221                path: path.to_string(),
17222                blob_hash: sha256.clone(),
17223                bytes: raw.len() as u64,
17224            }],
17225            None,
17226            &mut || {
17227                nonce += 1;
17228                format!("{nonce:032x}")
17229            },
17230        )
17231        .unwrap();
17232        let root = tree.root.clone().unwrap();
17233        let mut directory_root = root.clone();
17234        let mut proof = Vec::new();
17235        for component in path.split('/') {
17236            let inclusion =
17237                crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
17238            let child = match &inclusion {
17239                crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
17240                crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
17241                    panic!("fixture path must have an inclusion proof")
17242                }
17243            };
17244            proof.push(json!({
17245                "directory_root": directory_root,
17246                "component": component,
17247                "proof": inclusion,
17248            }));
17249            directory_root = child;
17250        }
17251        let commit_hash = "c".repeat(64);
17252        let pointer = V2PointerBody {
17253            v: 2,
17254            brain: TEST_BRAIN_ID.to_string(),
17255            seq: 1,
17256            commit_hash: commit_hash.clone(),
17257            feed_hash: "f".repeat(64),
17258            content_root: Some(root.clone()),
17259            asset_root: None,
17260            materializer: "dbmd-projection-v1".to_string(),
17261            signer_epoch: 1,
17262            control_revision: "d".repeat(64),
17263            backup_preparation: "e".repeat(64),
17264            prior_pointer_hash: None,
17265            signed_at: "2026-08-21T12:00:00.000Z".to_string(),
17266        };
17267        let manifest = json!({
17268            "v": 2,
17269            "commit": commit_hash,
17270            "content_root": root,
17271            "files": [{
17272                "path": path,
17273                "sha256": sha256,
17274                "bytes": raw.len(),
17275                "proof": proof,
17276            }],
17277            "next_cursor": Value::Null,
17278        })
17279        .to_string();
17280
17281        let path_manifest = manifest.clone();
17282        let (hub, server) = routed_json_hub(1, move |request| {
17283            assert_eq!(
17284                request,
17285                format!(
17286                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
17287                    "c".repeat(64)
17288                )
17289            );
17290            (200, path_manifest.clone())
17291        });
17292        let state = tempfile::tempdir().unwrap();
17293        let cfg = test_hub_config(hub, state.path().to_path_buf());
17294        let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
17295            .unwrap()
17296            .unwrap();
17297        assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
17298        assert!(by_path.proof.is_some());
17299        server.join().unwrap();
17300
17301        let (hub, server) = routed_json_hub(1, move |request| {
17302            assert_eq!(
17303                request,
17304                format!(
17305                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
17306                    "c".repeat(64)
17307                )
17308            );
17309            (404, r#"{"error":"File not found"}"#.to_string())
17310        });
17311        let state = tempfile::tempdir().unwrap();
17312        let cfg = test_hub_config(hub, state.path().to_path_buf());
17313        assert!(
17314            v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
17315                .unwrap()
17316                .is_none()
17317        );
17318        server.join().unwrap();
17319
17320        let id_manifest = manifest;
17321        let (hub, server) = routed_json_hub(1, move |request| {
17322            assert_eq!(
17323                request,
17324                format!(
17325                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
17326                    "c".repeat(64)
17327                )
17328            );
17329            (200, id_manifest.clone())
17330        });
17331        let state = tempfile::tempdir().unwrap();
17332        let cfg = test_hub_config(hub, state.path().to_path_buf());
17333        let (located_path, by_id) =
17334            v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
17335        assert_eq!(located_path, path);
17336        assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
17337        server.join().unwrap();
17338    }
17339
17340    #[test]
17341    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
17342        let unsorted = vec![
17343            ("records/a.md".to_string(), "alpha\n".to_string()),
17344            ("DB.md".to_string(), "# db\n".to_string()),
17345        ];
17346        let sorted = vec![
17347            ("DB.md".to_string(), "# db\n".to_string()),
17348            ("records/a.md".to_string(), "alpha\n".to_string()),
17349        ];
17350        let pack = build_store_pack(&unsorted).unwrap();
17351
17352        // This digest is shared with the hub implementation. It locks every
17353        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
17354        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
17355        // absence of descriptors/extras/comments/ZIP64.
17356        assert_eq!(pack.len(), 219);
17357        assert_eq!(
17358            content_sha256(&pack),
17359            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
17360        );
17361        assert_eq!(pack, build_store_pack(&sorted).unwrap());
17362        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
17363        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
17364        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
17365
17366        assert_eq!(
17367            parse_store_pack(pack).unwrap(),
17368            vec![
17369                ("DB.md".to_string(), b"# db\n".to_vec()),
17370                ("records/a.md".to_string(), b"alpha\n".to_vec()),
17371            ]
17372        );
17373    }
17374
17375    #[test]
17376    fn canonical_store_pack_validates_every_path_before_writing() {
17377        let duplicate = vec![
17378            ("DB.md".to_string(), "first".to_string()),
17379            ("DB.md".to_string(), "second".to_string()),
17380        ];
17381        assert!(build_store_pack(&duplicate)
17382            .unwrap_err()
17383            .to_string()
17384            .contains("duplicate path"));
17385        assert!(matches!(
17386            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
17387            Err(LinkError::UnsafePath { .. })
17388        ));
17389    }
17390
17391    #[test]
17392    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
17393        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
17394        // One byte stands in for the central directory. The trailer itself is
17395        // structurally valid; the count is the sole reason for refusal.
17396        let mut bytes = vec![0_u8];
17397        let zip64_offset = bytes.len() as u64;
17398        bytes.extend_from_slice(b"PK\x06\x06");
17399        bytes.extend_from_slice(&44_u64.to_le_bytes());
17400        bytes.extend_from_slice(&[0_u8; 12]);
17401        bytes.extend_from_slice(&COUNT.to_le_bytes());
17402        bytes.extend_from_slice(&COUNT.to_le_bytes());
17403        bytes.extend_from_slice(&1_u64.to_le_bytes());
17404        bytes.extend_from_slice(&0_u64.to_le_bytes());
17405        bytes.extend_from_slice(b"PK\x06\x07");
17406        bytes.extend_from_slice(&0_u32.to_le_bytes());
17407        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
17408        bytes.extend_from_slice(&1_u32.to_le_bytes());
17409        bytes.extend_from_slice(b"PK\x05\x06");
17410        bytes.extend_from_slice(&0_u16.to_le_bytes());
17411        bytes.extend_from_slice(&0_u16.to_le_bytes());
17412        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17413        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17414        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17415        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17416        bytes.extend_from_slice(&0_u16.to_le_bytes());
17417
17418        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
17419            .unwrap_err()
17420            .to_string();
17421        assert!(error.contains("invalid file count"), "{error}");
17422    }
17423
17424    #[test]
17425    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
17426        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
17427        let mut bytes = vec![0_u8];
17428        let zip64_offset = bytes.len() as u64;
17429        bytes.extend_from_slice(b"PK\x06\x06");
17430        bytes.extend_from_slice(&44_u64.to_le_bytes());
17431        bytes.extend_from_slice(&[0_u8; 12]);
17432        bytes.extend_from_slice(&COUNT.to_le_bytes());
17433        bytes.extend_from_slice(&COUNT.to_le_bytes());
17434        bytes.extend_from_slice(&1_u64.to_le_bytes());
17435        bytes.extend_from_slice(&0_u64.to_le_bytes());
17436        bytes.extend_from_slice(b"PK\x06\x07");
17437        bytes.extend_from_slice(&0_u32.to_le_bytes());
17438        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
17439        bytes.extend_from_slice(&1_u32.to_le_bytes());
17440        bytes.extend_from_slice(b"PK\x05\x06");
17441        bytes.extend_from_slice(&0_u16.to_le_bytes());
17442        bytes.extend_from_slice(&0_u16.to_le_bytes());
17443        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17444        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17445        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17446        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17447        bytes.extend_from_slice(&0_u16.to_le_bytes());
17448        // The old parser trusted this last low-count signature, while
17449        // ZipArchive fell back to the real Zip64 directory and allocated for
17450        // COUNT entries. Its central-directory offsets are deliberately fake.
17451        let fake_eocd = bytes.len() as u32;
17452        bytes.extend_from_slice(b"PK\x05\x06");
17453        bytes.extend_from_slice(&0_u16.to_le_bytes());
17454        bytes.extend_from_slice(&0_u16.to_le_bytes());
17455        bytes.extend_from_slice(&1_u16.to_le_bytes());
17456        bytes.extend_from_slice(&1_u16.to_le_bytes());
17457        bytes.extend_from_slice(&0_u32.to_le_bytes());
17458        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
17459        bytes.extend_from_slice(&0_u16.to_le_bytes());
17460
17461        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
17462            .unwrap_err()
17463            .to_string();
17464        assert!(error.contains("central directory"), "{error}");
17465    }
17466
17467    #[test]
17468    fn strict_http_status_handling_rejects_redirects_without_panicking() {
17469        let error = ensure_ok(
17470            HubResponse {
17471                status: 302,
17472                body: Some(json!({"redirect": "/elsewhere"})),
17473            },
17474            "mutation",
17475        )
17476        .unwrap_err();
17477        assert!(matches!(error, LinkError::Http { status: 302, .. }));
17478
17479        let error = ensure_raw_ok(
17480            RawHubResponse {
17481                status: 302,
17482                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
17483            },
17484            "feed",
17485        )
17486        .unwrap_err();
17487        assert!(matches!(error, LinkError::Http { status: 302, .. }));
17488    }
17489
17490    #[cfg(unix)]
17491    #[test]
17492    fn collect_push_files_refuses_external_symlink_and_nested_store() {
17493        use std::os::unix::fs::symlink;
17494
17495        let root = tempfile::tempdir().unwrap();
17496        std::fs::write(
17497            root.path().join("DB.md"),
17498            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
17499        )
17500        .unwrap();
17501        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
17502
17503        let external = tempfile::tempdir().unwrap();
17504        let secret = external.path().join("secret.md");
17505        std::fs::write(&secret, "TOP SECRET").unwrap();
17506        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
17507
17508        let store = Store::open_strict(root.path()).unwrap();
17509        let err = collect_push_files(&store).unwrap_err().to_string();
17510        assert!(err.contains("cannot push"), "{err}");
17511        assert!(
17512            !err.contains("TOP SECRET"),
17513            "external bytes must never leak"
17514        );
17515
17516        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
17517        let nested = root.path().join("records/nested");
17518        std::fs::create_dir_all(&nested).unwrap();
17519        std::fs::write(
17520            nested.join("DB.md"),
17521            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
17522        )
17523        .unwrap();
17524        let err = collect_push_files(&store).unwrap_err().to_string();
17525        assert!(err.contains("nested db.md store"), "{err}");
17526    }
17527
17528    #[test]
17529    fn collect_push_files_carries_curator_history_but_not_derived_catalogs() {
17530        let root = tempfile::tempdir().unwrap();
17531        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
17532        std::fs::create_dir_all(root.path().join("log")).unwrap();
17533        std::fs::write(
17534            root.path().join("DB.md"),
17535            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
17536        )
17537        .unwrap();
17538        std::fs::write(root.path().join("index.md"), "derived root catalog\n").unwrap();
17539        std::fs::write(
17540            root.path().join("records/notes/index.md"),
17541            "derived type catalog\n",
17542        )
17543        .unwrap();
17544        std::fs::write(
17545            root.path().join("records/notes/owned.md"),
17546            "---\ntype: note\nsummary: owned\ncreated: 2026-08-26T00:00:00Z\nupdated: 2026-08-26T00:00:00Z\n---\n",
17547        )
17548        .unwrap();
17549        std::fs::write(
17550            root.path().join("log.md"),
17551            "---\ntype: log\n---\n\n# Curator log\n",
17552        )
17553        .unwrap();
17554        std::fs::write(
17555            root.path().join("log/2026-07.md"),
17556            "---\ntype: log\n---\n\n# Curator log — 2026-07\n",
17557        )
17558        .unwrap();
17559        std::fs::write(root.path().join("log/README.txt"), "not a log archive\n").unwrap();
17560
17561        let store = Store::open_strict(root.path()).unwrap();
17562        let paths: Vec<String> = collect_push_files(&store)
17563            .unwrap()
17564            .into_iter()
17565            .map(|(path, _)| path)
17566            .collect();
17567
17568        assert!(paths.contains(&"DB.md".to_string()));
17569        assert!(paths.contains(&"records/notes/owned.md".to_string()));
17570        assert!(paths.contains(&"log.md".to_string()));
17571        assert!(paths.contains(&"log/2026-07.md".to_string()));
17572        assert!(!paths.contains(&"index.md".to_string()));
17573        assert!(!paths.contains(&"records/notes/index.md".to_string()));
17574        assert!(!paths.contains(&"log/README.txt".to_string()));
17575    }
17576
17577    #[cfg(unix)]
17578    #[test]
17579    fn remote_push_uses_opened_root_after_path_replacement() {
17580        use std::os::unix::fs::symlink;
17581
17582        let sandbox = tempfile::tempdir().unwrap();
17583        let root = sandbox.path().join("store");
17584        std::fs::create_dir_all(root.join("records/notes")).unwrap();
17585        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17586        std::fs::write(
17587            root.join("records/notes/owned.md"),
17588            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
17589        )
17590        .unwrap();
17591        let store = Store::open_strict(&root).unwrap();
17592        let detached = sandbox.path().join("detached");
17593        std::fs::rename(&root, &detached).unwrap();
17594
17595        let replacement = sandbox.path().join("replacement");
17596        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
17597        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17598        std::fs::write(
17599            replacement.join("records/notes/secret.md"),
17600            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
17601        )
17602        .unwrap();
17603        symlink(&replacement, &root).unwrap();
17604
17605        let files = collect_push_files(&store).unwrap();
17606        let wire_text = files
17607            .iter()
17608            .map(|(path, content)| format!("{path}\n{content}"))
17609            .collect::<Vec<_>>()
17610            .join("\n");
17611        assert!(wire_text.contains("owned upload"));
17612        assert!(!wire_text.contains("replacement sentinel"));
17613        assert!(!wire_text.contains("records/notes/secret.md"));
17614
17615        let remote = signed_remote_fixture();
17616        let (hub, server) = scripted_json_hub(vec![
17617            (200, remote.card),
17618            (200, remote.feed),
17619            (200, json!({"ok": true}).to_string()),
17620        ]);
17621        let state = tempfile::tempdir().unwrap();
17622        let cfg = test_hub_config(hub, state.path().to_path_buf());
17623        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
17624        assert_eq!(pushed, json!({"ok": true}));
17625        server.join().unwrap();
17626    }
17627
17628    #[test]
17629    fn signed_feed_item_verifies_identity_hash_and_signature() {
17630        use ring::rand::SystemRandom;
17631        use ring::signature::{Ed25519KeyPair, KeyPair};
17632
17633        const PREFIX: &[u8] = &[
17634            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
17635        ];
17636        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
17637        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17638        let mut spki = PREFIX.to_vec();
17639        spki.extend_from_slice(pair.public_key().as_ref());
17640        let public_key = URL_SAFE_NO_PAD.encode(&spki);
17641        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
17642        let mut entry = FeedEntry {
17643            v: 1,
17644            seq: 1,
17645            ts: "2026-07-14T00:00:00.000Z".to_string(),
17646            brain: format!("ed25519:{fingerprint}"),
17647            public_key: public_key.clone(),
17648            kind: "push".to_string(),
17649            op: "snapshot".to_string(),
17650            pack_sha256: "a".repeat(64),
17651            files: vec![FeedFile {
17652                path: "DB.md".to_string(),
17653                sha256: "b".repeat(64),
17654                bytes: 3,
17655            }],
17656            removed: vec![],
17657            prev_entry_hash: None,
17658            sig: String::new(),
17659        };
17660        let unsigned = UnsignedFeedEntry {
17661            v: entry.v,
17662            seq: entry.seq,
17663            ts: &entry.ts,
17664            brain: &entry.brain,
17665            public_key: &entry.public_key,
17666            kind: &entry.kind,
17667            op: &entry.op,
17668            pack_sha256: &entry.pack_sha256,
17669            files: &entry.files,
17670            removed: &entry.removed,
17671            prev_entry_hash: &entry.prev_entry_hash,
17672        };
17673        entry.sig =
17674            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
17675        let mut exact = serde_json::to_vec(&entry).unwrap();
17676        exact.push(b'\n');
17677        let item = FeedItem {
17678            hash: format!("{:x}", Sha256::digest(&exact)),
17679            entry,
17680        };
17681        let identity = FeedIdentity {
17682            fingerprint,
17683            public_key_spki: public_key,
17684            previous: Vec::new(),
17685            rotations: Vec::new(),
17686        };
17687        assert!(verify_feed_item(&item, &identity).is_ok());
17688        let mut tampered = item;
17689        tampered.entry.pack_sha256 = "c".repeat(64);
17690        assert!(verify_feed_item(&tampered, &identity).is_err());
17691    }
17692
17693    #[test]
17694    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
17695        let rng = ring::rand::SystemRandom::new();
17696        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17697        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17698        let (spki, multikey) = public_identity_for(&pair);
17699        let identity = V2HeadIdentity {
17700            custody: "self".to_string(),
17701            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17702            public_key_spki: spki.clone(),
17703            previous: Vec::new(),
17704            rotations: Vec::new(),
17705        };
17706        let unsigned = json!({
17707            "actor_ref": "a".repeat(64),
17708            "asset_root": Value::Null,
17709            "brain": multikey,
17710            "changes_sha256": "b".repeat(64),
17711            "control_revision": "c".repeat(64),
17712            "materializer": "dbmd-projection-v1",
17713            "op": "changeset",
17714            "parent_asset_root": Value::Null,
17715            "parent_commit": Value::Null,
17716            "parent_root": Value::Null,
17717            "prev_entry_hash": Value::Null,
17718            "public_key": spki,
17719            "seq": 1,
17720            "signer_epoch": 1,
17721            "state_root": "d".repeat(64),
17722            "ts": "2026-08-19T12:00:00.000Z",
17723            "v": 2,
17724            "v1_bridge": {
17725                "feed_hash": "e".repeat(64),
17726                "head_seq": 7,
17727                "pack_sha256": "f".repeat(64),
17728            },
17729        });
17730        let sign_value = |value: Value| {
17731            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17732            let mut object = value.as_object().unwrap().clone();
17733            object.insert(
17734                "sig".to_string(),
17735                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17736            );
17737            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17738        };
17739        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
17740
17741        let mut extra = unsigned.clone();
17742        extra
17743            .as_object_mut()
17744            .unwrap()
17745            .insert("future".to_string(), Value::Bool(true));
17746        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
17747
17748        let mut missing = unsigned.clone();
17749        missing.as_object_mut().unwrap().remove("v1_bridge");
17750        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
17751
17752        let mut invalid_bridge = unsigned;
17753        invalid_bridge.as_object_mut().unwrap().insert(
17754            "v1_bridge".to_string(),
17755            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
17756        );
17757        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
17758    }
17759
17760    #[test]
17761    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
17762        let vector: Value = serde_json::from_str(include_str!(
17763            "../tests/vectors/linkmd-v2-commit-bridge.json"
17764        ))
17765        .unwrap();
17766        let identity_value = vector.get("identity").unwrap();
17767        let identity = V2HeadIdentity {
17768            custody: "self".to_string(),
17769            fingerprint: identity_value
17770                .get("fingerprint")
17771                .and_then(Value::as_str)
17772                .unwrap()
17773                .to_string(),
17774            public_key_spki: identity_value
17775                .get("public_key_spki")
17776                .and_then(Value::as_str)
17777                .unwrap()
17778                .to_string(),
17779            previous: Vec::new(),
17780            rotations: Vec::new(),
17781        };
17782        let private = URL_SAFE_NO_PAD
17783            .decode(
17784                identity_value
17785                    .get("private_key_pkcs8")
17786                    .and_then(Value::as_str)
17787                    .unwrap(),
17788            )
17789            .unwrap();
17790        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
17791            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
17792            .unwrap();
17793        let base = vector.get("body").unwrap().as_object().unwrap();
17794
17795        for item in vector.get("valid").unwrap().as_array().unwrap() {
17796            let mut body = base.clone();
17797            body.insert(
17798                "v1_bridge".to_string(),
17799                item.get("v1_bridge").unwrap().clone(),
17800            );
17801            body.insert(
17802                "sig".to_string(),
17803                item.get("signature_base64url").unwrap().clone(),
17804            );
17805            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17806            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
17807            assert_eq!(
17808                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
17809                item.get("commit_hash").and_then(Value::as_str).unwrap()
17810            );
17811            assert_eq!(
17812                format!("{:x}", Sha256::digest(&signed)),
17813                item.get("feed_hash").and_then(Value::as_str).unwrap()
17814            );
17815        }
17816
17817        for item in vector.get("invalid").unwrap().as_array().unwrap() {
17818            let mut body = base.clone();
17819            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
17820                for field in remove {
17821                    body.remove(field.as_str().unwrap());
17822                }
17823            }
17824            if let Some(set) = item.get("set").and_then(Value::as_object) {
17825                for (field, value) in set {
17826                    body.insert(field.clone(), value.clone());
17827                }
17828            }
17829            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
17830            body.insert(
17831                "sig".to_string(),
17832                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17833            );
17834            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17835            assert!(
17836                verified_v2_commit_object(&signed, &identity).is_err(),
17837                "accepted invalid shared vector {}",
17838                item.get("reason").and_then(Value::as_str).unwrap()
17839            );
17840        }
17841    }
17842
17843    #[test]
17844    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
17845        let vector: Value = serde_json::from_str(include_str!(
17846            "../tests/vectors/linkmd-v2-changeset-withheld.json"
17847        ))
17848        .unwrap();
17849        assert_eq!(
17850            vector.get("profile").and_then(Value::as_str),
17851            Some("link.md-v2-changeset-withheld")
17852        );
17853        let canonical =
17854            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
17855        let expected = STANDARD
17856            .decode(
17857                vector
17858                    .get("canonical_base64")
17859                    .and_then(Value::as_str)
17860                    .unwrap(),
17861            )
17862            .unwrap();
17863        assert_eq!(canonical, expected);
17864        assert_eq!(
17865            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
17866            vector.get("domain_hash").and_then(Value::as_str).unwrap()
17867        );
17868    }
17869
17870    #[test]
17871    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
17872        let remote = signed_remote_fixture();
17873        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
17874        let legacy_item = legacy.entries.first().unwrap();
17875        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
17876        let body = json!({
17877            "actor_ref": "a".repeat(64),
17878            "asset_root": Value::Null,
17879            "brain": remote.key.multikey,
17880            "changes_sha256": "b".repeat(64),
17881            "control_revision": "c".repeat(64),
17882            "materializer": "dbmd-projection-v1",
17883            "op": "changeset",
17884            "parent_asset_root": Value::Null,
17885            "parent_commit": Value::Null,
17886            "parent_root": Value::Null,
17887            "prev_entry_hash": Value::Null,
17888            "public_key": remote.key.public_key_spki,
17889            "seq": 1,
17890            "signer_epoch": 1,
17891            "state_root": "d".repeat(64),
17892            "ts": "2026-08-19T12:00:00.000Z",
17893            "v": 2,
17894            "v1_bridge": {
17895                "feed_hash": legacy_item.hash,
17896                "head_seq": legacy_item.entry.seq,
17897                "pack_sha256": legacy_item.entry.pack_sha256,
17898            },
17899        });
17900        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
17901        let mut signed = body.as_object().unwrap().clone();
17902        signed.insert(
17903            "sig".to_string(),
17904            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17905        );
17906        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
17907        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
17908        let feed_hash = content_sha256(&raw);
17909        let pointer = V2PointerBody {
17910            v: 2,
17911            brain: TEST_BRAIN_ID.to_string(),
17912            seq: 1,
17913            commit_hash: commit_hash.clone(),
17914            feed_hash: feed_hash.clone(),
17915            content_root: Some("d".repeat(64)),
17916            asset_root: None,
17917            materializer: "dbmd-projection-v1".to_string(),
17918            signer_epoch: 1,
17919            control_revision: "c".repeat(64),
17920            backup_preparation: "e".repeat(64),
17921            prior_pointer_hash: None,
17922            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
17923        };
17924        let v2_page = json!({
17925            "v": 2,
17926            "head_seq": 1,
17927            "head_commit_hash": commit_hash,
17928            "head_feed_hash": feed_hash,
17929            "entries": [{
17930                "seq": 1,
17931                "commit_hash": pointer.commit_hash,
17932                "feed_hash": pointer.feed_hash,
17933                "bytes_base64": STANDARD.encode(&raw),
17934            }],
17935            "next_after": 1,
17936            "complete": true,
17937        })
17938        .to_string();
17939        let identity = V2HeadIdentity {
17940            custody: "self".to_string(),
17941            fingerprint: remote.identity.fingerprint.clone(),
17942            public_key_spki: remote.identity.public_key_spki.clone(),
17943            previous: Vec::new(),
17944            rotations: Vec::new(),
17945        };
17946        let checkpoint = TrustState {
17947            v: 2,
17948            origin: "unused".to_string(),
17949            requested: TEST_BRAIN_ID.to_string(),
17950            brain: TEST_BRAIN_ID.to_string(),
17951            home: None,
17952            anchor: remote.key.multikey.clone(),
17953            current: remote.key.multikey,
17954            head_seq: legacy_item.entry.seq,
17955            feed_hash: Some(legacy_item.hash.clone()),
17956            rotations: Vec::new(),
17957            hub_signer: None,
17958            protocol_profile: None,
17959        };
17960        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
17961        let state = tempfile::tempdir().unwrap();
17962        let cfg = test_hub_config(hub, state.path().to_path_buf());
17963        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
17964        server.join().unwrap();
17965
17966        let mut wrong = checkpoint;
17967        wrong.feed_hash = Some("0".repeat(64));
17968        let (hub, server) = scripted_json_hub(vec![(
17969            200,
17970            json!({
17971                "v": 2,
17972                "head_seq": 1,
17973                "head_commit_hash": pointer.commit_hash,
17974                "head_feed_hash": pointer.feed_hash,
17975                "entries": [{
17976                    "seq": 1,
17977                    "commit_hash": pointer.commit_hash,
17978                    "feed_hash": pointer.feed_hash,
17979                    "bytes_base64": STANDARD.encode(&raw),
17980                }],
17981                "next_after": 1,
17982                "complete": true,
17983            })
17984            .to_string(),
17985        )]);
17986        let state = tempfile::tempdir().unwrap();
17987        let cfg = test_hub_config(hub, state.path().to_path_buf());
17988        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
17989        server.join().unwrap();
17990    }
17991
17992    #[test]
17993    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
17994        let rng = ring::rand::SystemRandom::new();
17995        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17996        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17997        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17998        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17999        let (old_spki, old_multikey) = public_identity_for(&old);
18000        let (new_spki, new_multikey) = public_identity_for(&new);
18001        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
18002            v: 1,
18003            op: "rotate",
18004            brain: &old_multikey,
18005            public_key: &old_spki,
18006            new_brain: &new_multikey,
18007            new_public_key: &new_spki,
18008            prior_head_seq: 1,
18009            prior_feed_hash: Some(&"9".repeat(64)),
18010            ts: "2026-08-19T12:01:00.000Z".to_string(),
18011        })
18012        .unwrap();
18013        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
18014        let rotation = format!(
18015            "{},\"sig\":\"{}\"}}",
18016            &rotation_unsigned[..rotation_unsigned.len() - 1],
18017            rotation_sig
18018        );
18019        let identity = V2HeadIdentity {
18020            custody: "self".to_string(),
18021            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
18022            public_key_spki: new_spki.clone(),
18023            previous: vec![V2PreviousIdentity {
18024                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
18025                public_key_spki: old_spki.clone(),
18026            }],
18027            rotations: vec![rotation],
18028        };
18029        let commit = |seq: u64,
18030                      epoch: u64,
18031                      multikey: &str,
18032                      spki: &str,
18033                      pair: &ring::signature::Ed25519KeyPair| {
18034            let value = json!({
18035                "actor_ref": "a".repeat(64),
18036                "asset_root": Value::Null,
18037                "brain": multikey,
18038                "changes_sha256": "b".repeat(64),
18039                "control_revision": "c".repeat(64),
18040                "materializer": "dbmd-projection-v1",
18041                "op": "changeset",
18042                "parent_asset_root": Value::Null,
18043                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
18044                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
18045                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
18046                "public_key": spki,
18047                "seq": seq,
18048                "signer_epoch": epoch,
18049                "state_root": "1".repeat(64),
18050                "ts": "2026-08-19T12:00:00.000Z",
18051                "v": 2,
18052                "v1_bridge": Value::Null,
18053            });
18054            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
18055            let mut object = value.as_object().unwrap().clone();
18056            object.insert(
18057                "sig".to_string(),
18058                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
18059            );
18060            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
18061        };
18062
18063        assert!(verified_v2_commit_object(
18064            &commit(1, 1, &old_multikey, &old_spki, &old),
18065            &identity,
18066        )
18067        .is_ok());
18068        assert!(verified_v2_commit_object(
18069            &commit(2, 2, &new_multikey, &new_spki, &new),
18070            &identity,
18071        )
18072        .is_ok());
18073        assert!(verified_v2_commit_object(
18074            &commit(2, 1, &old_multikey, &old_spki, &old),
18075            &identity,
18076        )
18077        .is_err());
18078        assert!(verified_v2_commit_object(
18079            &commit(1, 2, &new_multikey, &new_spki, &new),
18080            &identity,
18081        )
18082        .is_err());
18083    }
18084
18085    #[test]
18086    fn a_self_custody_entry_verifies_like_any_hub_entry() {
18087        let rng = ring::rand::SystemRandom::new();
18088        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18089        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18090        let (spki, multikey) = public_identity_for(&pair);
18091        let key = AgentSigningKey {
18092            pkcs8: pkcs8.as_ref().to_vec(),
18093            multikey: multikey.clone(),
18094            public_key_spki: spki.clone(),
18095        };
18096        let files = vec![WireFeedFile {
18097            path: "DB.md".to_string(),
18098            sha256: "a".repeat(64),
18099            bytes: 3,
18100        }];
18101        let raw = self_custody_entry(
18102            &key,
18103            1,
18104            "2026-07-23T12:00:00.000Z".to_string(),
18105            &"c".repeat(64),
18106            &files,
18107            None,
18108        )
18109        .unwrap();
18110        // The exact client serialization parses as a feed entry and passes the
18111        // SAME verifier every subscribe read runs — the self-custody path
18112        // produces first-class wire-profile-v1 entries.
18113        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
18114        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
18115        let item = FeedItem { hash, entry };
18116        let identity = FeedIdentity {
18117            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
18118            public_key_spki: spki,
18119            previous: Vec::new(),
18120            rotations: Vec::new(),
18121        };
18122        assert!(verify_feed_item(&item, &identity).is_ok());
18123    }
18124
18125    #[test]
18126    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
18127        let rng = ring::rand::SystemRandom::new();
18128        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18129        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
18130        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18131        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
18132        let (old_spki, old_multikey) = public_identity_for(&old);
18133        let (new_spki, new_multikey) = public_identity_for(&new);
18134        let unsigned = serde_json::to_string(&UnsignedRotation {
18135            v: 1,
18136            op: "rotate",
18137            brain: &old_multikey,
18138            public_key: &old_spki,
18139            new_brain: &new_multikey,
18140            new_public_key: &new_spki,
18141            prior_head_seq: 1,
18142            prior_feed_hash: Some(&"a".repeat(64)),
18143            ts: "2026-07-30T12:00:00.000Z".to_string(),
18144        })
18145        .unwrap();
18146        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
18147        let rotation = format!(
18148            "{},\"sig\":\"{}\"}}",
18149            &unsigned[..unsigned.len() - 1],
18150            signature
18151        );
18152        let identity = FeedIdentity {
18153            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
18154            public_key_spki: new_spki,
18155            previous: vec![PreviousIdentity {
18156                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
18157                public_key_spki: old_spki,
18158            }],
18159            rotations: vec![rotation],
18160        };
18161        let pin = TrustState {
18162            v: 2,
18163            origin: "https://hub.example".to_string(),
18164            requested: "brain".to_string(),
18165            brain: "brain".to_string(),
18166            home: None,
18167            anchor: old_multikey.clone(),
18168            current: old_multikey.clone(),
18169            head_seq: 1,
18170            feed_hash: Some("a".repeat(64)),
18171            rotations: Vec::new(),
18172            hub_signer: None,
18173            protocol_profile: None,
18174        };
18175        assert_eq!(
18176            verify_identity_chain(&identity, Some(&pin)).unwrap(),
18177            old_multikey
18178        );
18179        let mut accepted = pin.clone();
18180        accepted.current = new_multikey.clone();
18181        accepted.rotations = identity.rotations.clone();
18182        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
18183            v: 1,
18184            op: "rotate",
18185            brain: &old_multikey,
18186            public_key: &identity.previous[0].public_key_spki,
18187            new_brain: &new_multikey,
18188            new_public_key: &identity.public_key_spki,
18189            prior_head_seq: 1,
18190            prior_feed_hash: Some(&"a".repeat(64)),
18191            ts: "2026-07-30T12:00:01.000Z".to_string(),
18192        })
18193        .unwrap();
18194        let alternate_signature =
18195            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
18196        let mut rewritten = identity.clone();
18197        rewritten.rotations[0] = format!(
18198            "{},\"sig\":\"{}\"}}",
18199            &alternate_unsigned[..alternate_unsigned.len() - 1],
18200            alternate_signature
18201        );
18202        assert!(
18203            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
18204            "an alternate valid statement must not rewrite accepted history"
18205        );
18206
18207        let mut stale_entry = FeedEntry {
18208            v: 1,
18209            seq: 2,
18210            ts: "2026-07-30T12:01:00.000Z".to_string(),
18211            brain: pin.current.clone(),
18212            public_key: identity.previous[0].public_key_spki.clone(),
18213            kind: "push".to_string(),
18214            op: "snapshot".to_string(),
18215            pack_sha256: "b".repeat(64),
18216            files: Vec::new(),
18217            removed: Vec::new(),
18218            prev_entry_hash: pin.feed_hash.clone(),
18219            sig: String::new(),
18220        };
18221        let stale_unsigned = UnsignedFeedEntry {
18222            v: stale_entry.v,
18223            seq: stale_entry.seq,
18224            ts: &stale_entry.ts,
18225            brain: &stale_entry.brain,
18226            public_key: &stale_entry.public_key,
18227            kind: &stale_entry.kind,
18228            op: &stale_entry.op,
18229            pack_sha256: &stale_entry.pack_sha256,
18230            files: &stale_entry.files,
18231            removed: &stale_entry.removed,
18232            prev_entry_hash: &stale_entry.prev_entry_hash,
18233        };
18234        stale_entry.sig = URL_SAFE_NO_PAD.encode(
18235            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
18236                .as_ref(),
18237        );
18238        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
18239        stale_exact.push(b'\n');
18240        let stale_item = FeedItem {
18241            hash: content_sha256(&stale_exact),
18242            entry: stale_entry,
18243        };
18244        assert!(
18245            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
18246                .is_err(),
18247            "a key retired before the checkpoint must never append after it"
18248        );
18249        assert!(
18250            verify_feed_item(&stale_item, &identity).is_err(),
18251            "an old key must never append after its signed rotation boundary"
18252        );
18253
18254        let mut missing = identity.clone();
18255        missing.rotations.clear();
18256        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
18257
18258        let mut tampered = identity;
18259        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
18260        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
18261    }
18262
18263    #[cfg(unix)]
18264    #[test]
18265    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
18266        use std::os::unix::fs::symlink;
18267
18268        let dir = tempfile::tempdir().unwrap();
18269        let target = dir.path().join("valuable.txt");
18270        let planted = dir.path().join("agent.key");
18271        std::fs::write(&target, "do not overwrite").unwrap();
18272        symlink(&target, &planted).unwrap();
18273
18274        assert!(matches!(
18275            generate_agent_key(&planted),
18276            Err(LinkError::BadAgentKey { .. })
18277        ));
18278        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
18279    }
18280
18281    #[cfg(unix)]
18282    #[test]
18283    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
18284        use std::os::unix::fs::symlink;
18285
18286        let root = tempfile::tempdir().unwrap();
18287        let outside = tempfile::tempdir().unwrap();
18288        symlink(outside.path(), root.path().join("redirect")).unwrap();
18289
18290        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
18291        assert!(!outside.path().join("agent.key").exists());
18292    }
18293
18294    // ── Address parsing ─────────────────────────────────────────────────────
18295
18296    #[test]
18297    fn address_bare_brain_with_and_without_sigil() {
18298        for raw in ["@acme-ops", "acme-ops"] {
18299            let a = Address::parse(raw).expect(raw);
18300            assert_eq!(a.brain, "acme-ops");
18301            assert_eq!(a.target, None);
18302        }
18303    }
18304
18305    #[test]
18306    fn address_ulid_target_parses_as_id() {
18307        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
18308        assert_eq!(a.brain, "acme");
18309        assert_eq!(
18310            a.target,
18311            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
18312        );
18313    }
18314
18315    #[test]
18316    fn address_md_path_target_parses_as_path() {
18317        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
18318        assert_eq!(
18319            a.target,
18320            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
18321        );
18322    }
18323
18324    #[test]
18325    fn address_rejects_malformed_forms() {
18326        for raw in [
18327            "",
18328            "@",
18329            "@/x",
18330            "@acme/",
18331            "@acme/../etc/passwd",
18332            "@acme/records/.hidden.md",
18333            "@ACME",             // uppercase is not a hub ref shape
18334            "@acme/notes/x.txt", // target is neither ULID nor .md path
18335            "@a b",              // whitespace
18336        ] {
18337            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
18338        }
18339    }
18340
18341    // ── Path safety ─────────────────────────────────────────────────────────
18342
18343    #[test]
18344    fn safe_paths_accept_store_shapes_and_reject_escapes() {
18345        for ok in [
18346            "DB.md",
18347            "assets.jsonl",
18348            "records/clients/lumio.md",
18349            "sources/emails/2026/07/x.md",
18350        ] {
18351            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
18352        }
18353        for bad in [
18354            "",
18355            "/etc/passwd",
18356            "../up.md",
18357            "records/../../up.md",
18358            "records//x.md",
18359            ".dbmd/config",
18360            "records/.hidden/x.md",
18361            "records/a b.md",
18362            "records\\win.md",
18363        ] {
18364            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
18365        }
18366    }
18367
18368    #[cfg(unix)]
18369    #[test]
18370    fn opened_destination_capability_survives_an_ancestor_path_swap() {
18371        use std::os::unix::fs::symlink;
18372
18373        let work = tempfile::tempdir().unwrap();
18374        let outside = tempfile::tempdir().unwrap();
18375        let original = work.path().join("destination");
18376        let moved = work.path().join("destination-moved");
18377        let directory = open_or_create_dir_nofollow(&original).unwrap();
18378
18379        std::fs::rename(&original, &moved).unwrap();
18380        symlink(outside.path(), &original).unwrap();
18381        write_pull_entries_beneath_dir(
18382            &directory,
18383            &[("records/note.md".to_string(), b"held inode".to_vec())],
18384        )
18385        .unwrap();
18386
18387        assert_eq!(
18388            std::fs::read(moved.join("records/note.md")).unwrap(),
18389            b"held inode"
18390        );
18391        assert!(!outside.path().join("records/note.md").exists());
18392    }
18393
18394    // ── Config resolution (flag + file precedence; env is covered by the CLI
18395    //    integration tests, where a child process isolates it) ───────────────
18396
18397    #[test]
18398    fn hub_config_flag_beats_file_and_requires_some_source() {
18399        let dir = tempfile::tempdir().unwrap();
18400        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
18401        std::fs::write(
18402            dir.path().join(CONFIG_REL_PATH),
18403            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
18404        )
18405        .unwrap();
18406
18407        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
18408        assert_eq!(from_flag.hub, "https://flag.example.com");
18409
18410        let from_file = hub_config(None, dir.path()).unwrap();
18411        assert_eq!(from_file.hub, "https://file.example.com");
18412
18413        let none = hub_config(None, tempfile::tempdir().unwrap().path());
18414        assert!(matches!(none, Err(LinkError::NoHub)));
18415    }
18416
18417    #[test]
18418    fn https_guard_allows_loopback_only_for_plain_http() {
18419        assert!(assert_safe_hub("https://hub.example.com").is_ok());
18420        assert!(assert_safe_hub("http://localhost:3000").is_ok());
18421        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
18422        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
18423        assert!(matches!(
18424            assert_safe_hub("http://hub.example.com"),
18425            Err(LinkError::UnsafeHub { .. })
18426        ));
18427        assert!(matches!(
18428            assert_safe_hub("hub.example.com"),
18429            Err(LinkError::UnsafeHub { .. })
18430        ));
18431        assert!(matches!(
18432            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
18433            Err(LinkError::UnsafeHub { .. })
18434        ));
18435        assert!(matches!(
18436            assert_safe_hub("https://hub.example.com@attacker.example"),
18437            Err(LinkError::UnsafeHub { .. })
18438        ));
18439        assert!(matches!(
18440            assert_safe_hub("https://hub.example.com/base"),
18441            Err(LinkError::UnsafeHub { .. })
18442        ));
18443    }
18444
18445    #[test]
18446    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
18447        for blocked in [
18448            "127.0.0.1",
18449            "10.0.0.1",
18450            "100.64.0.1",
18451            "169.254.169.254",
18452            "172.16.0.1",
18453            "192.168.0.1",
18454            "192.88.99.1",
18455            "198.18.0.1",
18456            "203.0.113.1",
18457            "::1",
18458            "fe80::1",
18459            "fd00::1",
18460            "2001:db8::1",
18461            "2001:1::1",
18462            "2002:7f00:1::",
18463            "3fff::1",
18464        ] {
18465            assert!(
18466                !is_public_registry_ip(blocked.parse().unwrap()),
18467                "must block {blocked}"
18468            );
18469        }
18470        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
18471        assert!(is_public_registry_ip(
18472            "2606:4700:4700::1111".parse().unwrap()
18473        ));
18474        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
18475    }
18476
18477    #[test]
18478    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
18479        use ureq::Resolver as _;
18480
18481        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
18482        let resolver = PinnedRegistryResolver {
18483            netloc: "home.example:443".to_string(),
18484            addresses: vec![pinned],
18485        };
18486        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
18487        assert!(resolver.resolve("127.0.0.1:443").is_err());
18488        assert_eq!(
18489            resolver.resolve("home.example:443").unwrap(),
18490            vec![pinned],
18491            "subsequent connects reuse the validated answer instead of DNS"
18492        );
18493    }
18494
18495    #[test]
18496    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
18497        let cfg = HubConfig {
18498            hub: "https://hub.example".to_string(),
18499            key: None,
18500            agent_key: None,
18501            brain_key: None,
18502            state_dir: tempfile::tempdir().unwrap().keep(),
18503            store_selected: false,
18504        };
18505        assert!(
18506            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
18507            "a production hub must not turn its presigned URL into an SSRF primitive"
18508        );
18509
18510        let store_selected = HubConfig {
18511            hub: "https://127.0.0.1".to_string(),
18512            store_selected: true,
18513            ..cfg
18514        };
18515        assert!(
18516            hub_agent(&store_selected).is_err(),
18517            "bytes in a cloned store must not select a private-network hub"
18518        );
18519    }
18520
18521    #[test]
18522    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
18523        assert_eq!(
18524            one_past_bounded_limit(MAX_PACK_BYTES),
18525            Some(MAX_PACK_BYTES + 1),
18526            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
18527        );
18528        assert_eq!(
18529            presigned_download_read_limit(),
18530            MAX_PACK_BYTES + 1,
18531            "the presigned reader is capped by the client constant, not a hub response"
18532        );
18533        assert_eq!(
18534            one_past_bounded_limit(u64::MAX),
18535            None,
18536            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
18537        );
18538    }
18539
18540    #[test]
18541    fn https_guard_matches_the_scheme_case_insensitively() {
18542        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
18543        // hub is still HTTPS, never a misleading non-HTTPS refusal.
18544        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
18545        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
18546        // And an uppercase plain-HTTP hub is still refused outside loopback.
18547        assert!(matches!(
18548            assert_safe_hub("HTTP://hub.example.com"),
18549            Err(LinkError::UnsafeHub { .. })
18550        ));
18551    }
18552
18553    #[test]
18554    fn clean_key_refuses_paste_artifacts_without_echoing() {
18555        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
18556        for bad in ["vc account", "vc\naccount", "ключ", ""] {
18557            let err = clean_key(bad).unwrap_err();
18558            assert!(matches!(err, LinkError::BadKey));
18559            assert!(
18560                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
18561                "error must not echo the key"
18562            );
18563        }
18564    }
18565
18566    // ── Verb entry gates: refs must never reshape the request path ──────────
18567
18568    /// A config whose hub passes the loopback guard but is never listened on:
18569    /// every refusal below must come from the entry gate BEFORE a request
18570    /// exists — a dial on this dead port would surface `Transport` instead.
18571    fn dead_hub() -> HubConfig {
18572        HubConfig {
18573            hub: "http://127.0.0.1:9".to_string(),
18574            key: Some("k".to_string()),
18575            agent_key: None,
18576            brain_key: None,
18577            state_dir: PathBuf::from("."),
18578            store_selected: false,
18579        }
18580    }
18581
18582    #[test]
18583    fn request_retries_a_connection_failure_before_sending() {
18584        use std::io::{Read as _, Write as _};
18585        use std::net::TcpListener;
18586        use std::thread;
18587        use std::time::Duration;
18588
18589        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
18590        let address = probe.local_addr().unwrap();
18591        drop(probe);
18592        let server = thread::spawn(move || {
18593            thread::sleep(Duration::from_millis(40));
18594            let listener = TcpListener::bind(address).unwrap();
18595            let (mut stream, _) = listener.accept().unwrap();
18596            let mut request_bytes = [0_u8; 1024];
18597            let _ = stream.read(&mut request_bytes).unwrap();
18598            stream
18599                .write_all(
18600                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18601                )
18602                .unwrap();
18603        });
18604        let cfg = HubConfig {
18605            hub: format!("http://{address}"),
18606            key: None,
18607            agent_key: None,
18608            brain_key: None,
18609            state_dir: tempfile::tempdir().unwrap().keep(),
18610            store_selected: false,
18611        };
18612
18613        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
18614        assert_eq!(response.status, 200);
18615        assert_eq!(response.body, Some(json!({ "ok": true })));
18616        server.join().unwrap();
18617    }
18618
18619    #[test]
18620    fn a_commit_goes_back_for_a_receipt_it_lost() {
18621        use std::io::Write as _;
18622        use std::net::TcpListener;
18623        use std::thread;
18624
18625        // The mutation coordinate is derived from the observed base and the
18626        // exact operation set so a repeat is the SAME effect. A commit that
18627        // lands durably and then loses its connection must therefore be asked
18628        // after, not reported as a failure — which is what happened to a
18629        // 32,338-file store that had already committed.
18630        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18631        let address = listener.local_addr().unwrap();
18632        let server = thread::spawn(move || {
18633            // First attempt: promise a body, hang up mid-way.
18634            let (mut first, _) = listener.accept().unwrap();
18635            drain_test_http_request(&mut first);
18636            first
18637                .write_all(
18638                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
18639                )
18640                .unwrap();
18641            drop(first);
18642            // Second attempt: the receipt for the commit that already landed.
18643            let (mut second, _) = listener.accept().unwrap();
18644            drain_test_http_request(&mut second);
18645            second
18646                .write_all(
18647                    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\"}",
18648                )
18649                .unwrap();
18650        });
18651        let cfg = HubConfig {
18652            hub: format!("http://{address}"),
18653            key: Some("k".to_string()),
18654            agent_key: None,
18655            brain_key: None,
18656            state_dir: tempfile::tempdir().unwrap().keep(),
18657            store_selected: false,
18658        };
18659
18660        let response = request_patient(
18661            &cfg,
18662            "POST",
18663            "/api/hub/brains/b/v2/commits",
18664            Some(&json!({ "mutation_id": "dbmd-1" })),
18665            Auth::Required,
18666        )
18667        .expect("the receipt is collected on the second ask");
18668        assert_eq!(response.status, 200);
18669        assert_eq!(
18670            response
18671                .body
18672                .as_ref()
18673                .and_then(|value| value.get("outcome"))
18674                .and_then(Value::as_str),
18675            Some("converged"),
18676            "an already-applied mutation answers with its receipt"
18677        );
18678        server.join().unwrap();
18679    }
18680
18681    #[test]
18682    fn a_patient_commit_waits_for_its_typed_post_acceptance_receipt_lag() {
18683        use std::io::Write as _;
18684        use std::net::TcpListener;
18685        use std::thread;
18686
18687        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18688        let address = listener.local_addr().unwrap();
18689        let server = thread::spawn(move || {
18690            let lag = br#"{"error":"validation recovery state is not at the source head","details":{"code":"validation_index_catching_up"}}"#;
18691            let receipt = br#"{"v":2,"outcome":"converged"}"#;
18692            for (status, body) in [
18693                ("422 Unprocessable Entity", lag.as_slice()),
18694                ("200 OK", receipt.as_slice()),
18695            ] {
18696                let (mut stream, _) = listener.accept().unwrap();
18697                drain_test_http_request(&mut stream);
18698                write!(
18699                    stream,
18700                    "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
18701                    body.len()
18702                )
18703                .unwrap();
18704                stream.write_all(body).unwrap();
18705            }
18706        });
18707        let cfg = HubConfig {
18708            hub: format!("http://{address}"),
18709            key: Some("k".to_string()),
18710            agent_key: None,
18711            brain_key: None,
18712            state_dir: tempfile::tempdir().unwrap().keep(),
18713            store_selected: false,
18714        };
18715
18716        let response = request_patient(
18717            &cfg,
18718            "POST",
18719            "/api/hub/brains/b/v2/commits",
18720            Some(&json!({ "mutation_id": "dbmd-1" })),
18721            Auth::Required,
18722        )
18723        .expect("typed projection lag is retried until the exact receipt is available");
18724        assert_eq!(response.status, 200);
18725        assert_eq!(
18726            response
18727                .body
18728                .as_ref()
18729                .and_then(|value| value.get("outcome"))
18730                .and_then(Value::as_str),
18731            Some("converged")
18732        );
18733        server.join().unwrap();
18734    }
18735
18736    #[test]
18737    fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
18738        use std::io::{Read as _, Write as _};
18739        use std::net::TcpListener;
18740        use std::thread;
18741
18742        // A connection that fails while the body is arriving is the same class
18743        // of problem as one that fails before it arrives at all. Typed as a
18744        // bare io error it matched no retry path and named no peer, and one
18745        // stalled body ended a whole migration reporting only "timed out
18746        // reading response".
18747        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18748        let address = listener.local_addr().unwrap();
18749        let server = thread::spawn(move || {
18750            let (mut stream, _) = listener.accept().unwrap();
18751            let mut request_bytes = [0_u8; 1024];
18752            let _ = stream.read(&mut request_bytes).unwrap();
18753            // Promise a hundred bytes, send ten, hang up.
18754            stream
18755                .write_all(
18756                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18757                )
18758                .unwrap();
18759        });
18760        let cfg = HubConfig {
18761            hub: format!("http://{address}"),
18762            key: None,
18763            agent_key: None,
18764            brain_key: None,
18765            state_dir: tempfile::tempdir().unwrap().keep(),
18766            store_selected: false,
18767        };
18768
18769        let error = request(&cfg, "POST", "/truncated", None, Auth::None)
18770            .expect_err("a truncated body must not read as success");
18771        match error {
18772            LinkError::Transport { hub, .. } => {
18773                assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
18774            }
18775            other => panic!("expected a transport failure, got {other:?}"),
18776        }
18777        server.join().unwrap();
18778    }
18779
18780    #[test]
18781    fn a_safe_get_retries_when_its_body_dies_mid_stream() {
18782        use std::io::{Read as _, Write as _};
18783        use std::net::{TcpListener, TcpStream};
18784        use std::thread;
18785
18786        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18787        let address = listener.local_addr().unwrap();
18788        let server = thread::spawn(move || {
18789            let read_request = |stream: &mut TcpStream| {
18790                let mut request = Vec::new();
18791                let mut bytes = [0_u8; 1024];
18792                loop {
18793                    let read = stream.read(&mut bytes).unwrap();
18794                    if read == 0 {
18795                        break;
18796                    }
18797                    request.extend_from_slice(&bytes[..read]);
18798                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18799                    else {
18800                        continue;
18801                    };
18802                    let headers = String::from_utf8_lossy(&request[..header_end]);
18803                    let content_length = headers
18804                        .lines()
18805                        .find_map(|line| {
18806                            let (name, value) = line.split_once(':')?;
18807                            name.eq_ignore_ascii_case("content-length")
18808                                .then(|| value.trim().parse::<usize>().ok())
18809                                .flatten()
18810                        })
18811                        .unwrap_or(0);
18812                    if request.len() >= header_end + 4 + content_length {
18813                        break;
18814                    }
18815                }
18816            };
18817            let (mut first, _) = listener.accept().unwrap();
18818            read_request(&mut first);
18819            first
18820                .write_all(
18821                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18822                )
18823                .unwrap();
18824            drop(first);
18825
18826            let (mut second, _) = listener.accept().unwrap();
18827            read_request(&mut second);
18828            second
18829                .write_all(
18830                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18831                )
18832                .unwrap();
18833        });
18834        let cfg = HubConfig {
18835            hub: format!("http://{address}"),
18836            key: None,
18837            agent_key: None,
18838            brain_key: None,
18839            state_dir: tempfile::tempdir().unwrap().keep(),
18840            store_selected: false,
18841        };
18842
18843        let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
18844            .expect("a safe read retries the interrupted body");
18845        assert_eq!(response.status, 200);
18846        assert_eq!(response.body, Some(json!({ "ok": true })));
18847        server.join().unwrap();
18848    }
18849
18850    #[test]
18851    fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
18852        use std::io::{Read as _, Write as _};
18853        use std::net::{TcpListener, TcpStream};
18854        use std::thread;
18855
18856        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18857        let address = listener.local_addr().unwrap();
18858        let server = thread::spawn(move || {
18859            let read_request = |stream: &mut TcpStream| {
18860                let mut request = Vec::new();
18861                let mut bytes = [0_u8; 1024];
18862                loop {
18863                    let read = stream.read(&mut bytes).unwrap();
18864                    if read == 0 {
18865                        break;
18866                    }
18867                    request.extend_from_slice(&bytes[..read]);
18868                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18869                    else {
18870                        continue;
18871                    };
18872                    let headers = String::from_utf8_lossy(&request[..header_end]);
18873                    let content_length = headers
18874                        .lines()
18875                        .find_map(|line| {
18876                            let (name, value) = line.split_once(':')?;
18877                            name.eq_ignore_ascii_case("content-length")
18878                                .then(|| value.trim().parse::<usize>().ok())
18879                                .flatten()
18880                        })
18881                        .unwrap_or(0);
18882                    if request.len() >= header_end + 4 + content_length {
18883                        break;
18884                    }
18885                }
18886            };
18887            let (mut first, _) = listener.accept().unwrap();
18888            read_request(&mut first);
18889            first
18890                .write_all(
18891                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18892                )
18893                .unwrap();
18894            drop(first);
18895
18896            let (mut second, _) = listener.accept().unwrap();
18897            read_request(&mut second);
18898            second
18899                .write_all(
18900                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18901                )
18902                .unwrap();
18903        });
18904        let cfg = HubConfig {
18905            hub: format!("http://{address}"),
18906            key: None,
18907            agent_key: None,
18908            brain_key: None,
18909            state_dir: tempfile::tempdir().unwrap().keep(),
18910            store_selected: false,
18911        };
18912
18913        let response = request_raw_retryable_read(
18914            &cfg,
18915            "POST",
18916            "/v2/stream",
18917            Some(&json!({ "files": ["proof"] })),
18918            Auth::None,
18919            1_024,
18920        )
18921        .expect("an explicitly safe POST retries the interrupted body");
18922        assert_eq!(response.status, 200);
18923        assert_eq!(
18924            serde_json::from_slice::<Value>(&response.body).unwrap(),
18925            json!({ "ok": true })
18926        );
18927        server.join().unwrap();
18928    }
18929
18930    #[test]
18931    fn object_store_transport_errors_never_render_presigned_urls() {
18932        use std::net::TcpListener;
18933
18934        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18935        let address = listener.local_addr().unwrap();
18936        drop(listener);
18937        let signature = "do-not-render-this-presigned-signature";
18938        let raw =
18939            format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
18940        let error = ureq::get(&raw)
18941            .timeout(std::time::Duration::from_millis(250))
18942            .call()
18943            .expect_err("the closed local port must fail");
18944        let ureq::Error::Transport(transport) = error else {
18945            panic!("expected a transport failure");
18946        };
18947
18948        let rendered = object_store_transport_error(transport).to_string();
18949        assert!(rendered.contains("the object store"));
18950        assert!(rendered.contains("network error"));
18951        assert!(!rendered.contains(&raw));
18952        assert!(!rendered.contains(signature));
18953        assert!(!rendered.contains("X-Amz-"));
18954    }
18955
18956    #[test]
18957    fn endpoint_cap_refuses_a_body_before_json_parsing() {
18958        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
18959        let cfg = HubConfig {
18960            hub,
18961            key: None,
18962            agent_key: None,
18963            brain_key: None,
18964            state_dir: tempfile::tempdir().unwrap().keep(),
18965            store_selected: false,
18966        };
18967
18968        assert!(matches!(
18969            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
18970            Err(LinkError::ResponseTooLarge { .. })
18971        ));
18972        server.join().unwrap();
18973    }
18974
18975    #[test]
18976    fn overall_deadline_stops_a_dribbled_response_body() {
18977        use std::io::{Read as _, Write as _};
18978        use std::net::TcpListener;
18979        use std::time::{Duration, Instant};
18980
18981        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18982        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
18983        let server = std::thread::spawn(move || {
18984            let (mut stream, _) = listener.accept().unwrap();
18985            let mut request = [0_u8; 1024];
18986            let _ = stream.read(&mut request);
18987            stream
18988                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
18989                .unwrap();
18990            for byte in [b'x'; 32] {
18991                if stream.write_all(&[byte]).is_err() {
18992                    break;
18993                }
18994                std::thread::sleep(Duration::from_millis(40));
18995            }
18996        });
18997        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
18998        let started = Instant::now();
18999        let response = http.get(&url).call().unwrap();
19000        let mut body = Vec::new();
19001        let error = response
19002            .into_reader()
19003            .read_to_end(&mut body)
19004            .expect_err("per-read progress must not reset the overall deadline");
19005        assert!(
19006            started.elapsed() < Duration::from_millis(700),
19007            "dribbled body exceeded the wall-clock budget: {error}"
19008        );
19009        server.join().unwrap();
19010    }
19011
19012    #[test]
19013    fn overall_deadline_stops_a_stalled_upload() {
19014        use std::net::TcpListener;
19015        use std::time::{Duration, Instant};
19016
19017        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19018        let url = format!("http://{}/upload", listener.local_addr().unwrap());
19019        let server = std::thread::spawn(move || {
19020            let (_stream, _) = listener.accept().unwrap();
19021            // Never consume the request body. Once the kernel send buffer fills,
19022            // the client must leave on its absolute write deadline.
19023            std::thread::sleep(Duration::from_millis(600));
19024        });
19025        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
19026        let body = vec![0x5a; 32 * 1024 * 1024];
19027        let started = Instant::now();
19028        let error = http
19029            .put(&url)
19030            .send_bytes(&body)
19031            .expect_err("stalled request-body writes must time out");
19032        assert!(
19033            started.elapsed() < Duration::from_millis(700),
19034            "stalled upload exceeded the wall-clock budget: {error}"
19035        );
19036        server.join().unwrap();
19037    }
19038
19039    #[test]
19040    fn presigned_source_retries_share_one_upload_deadline() {
19041        use std::net::TcpListener;
19042        use std::time::{Duration, Instant};
19043
19044        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19045        let address = listener.local_addr().unwrap();
19046        let signature = "do-not-render-this-stalled-upload-signature";
19047        let url = format!(
19048            "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
19049        );
19050        let server = std::thread::spawn(move || {
19051            let (_stream, _) = listener.accept().unwrap();
19052            // Keep the socket open without consuming the request. The first
19053            // attempt must spend the complete shared budget; retries may not
19054            // each restart that budget.
19055            std::thread::sleep(Duration::from_millis(600));
19056        });
19057
19058        let directory = tempfile::tempdir().unwrap();
19059        std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
19060        std::fs::create_dir(directory.path().join("records")).unwrap();
19061        let relative = "records/stalled.bin";
19062        let bytes = vec![0x5a; 32 * 1024 * 1024];
19063        std::fs::write(directory.path().join(relative), &bytes).unwrap();
19064        let store = Store::open_strict(directory.path()).unwrap();
19065        let cfg = HubConfig {
19066            hub: format!("http://{address}"),
19067            key: None,
19068            agent_key: None,
19069            brain_key: None,
19070            state_dir: tempfile::tempdir().unwrap().keep(),
19071            store_selected: false,
19072        };
19073        let source = V2UploadSource {
19074            path: relative.to_string(),
19075            bytes: bytes.len() as u64,
19076        };
19077
19078        let started = Instant::now();
19079        let error = put_presigned_source_with_budget(
19080            &cfg,
19081            &url,
19082            &json!({ "content-length": source.bytes.to_string() }),
19083            &store,
19084            &source,
19085            None,
19086            Duration::from_millis(150),
19087        )
19088        .expect_err("a black-holed upload must leave at its shared deadline");
19089        assert!(
19090            started.elapsed() < Duration::from_millis(700),
19091            "presigned retries exceeded their shared budget: {error}"
19092        );
19093        let rendered = error.to_string();
19094        assert!(rendered.contains("the object store"));
19095        assert!(!rendered.contains(&url));
19096        assert!(!rendered.contains(signature));
19097        server.join().unwrap();
19098    }
19099
19100    #[test]
19101    fn verb_entry_gates_accept_the_hub_ref_shapes() {
19102        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
19103            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
19104            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
19105            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
19106        }
19107    }
19108
19109    #[test]
19110    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
19111        let cfg = dead_hub();
19112        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
19113            assert!(
19114                matches!(
19115                    sync_pull(&cfg, bad, None),
19116                    Err(LinkError::BadAddress { .. })
19117                ),
19118                "sync_pull must refuse {bad:?}"
19119            );
19120            assert!(
19121                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
19122                "sync_push must refuse {bad:?}"
19123            );
19124            assert!(
19125                matches!(
19126                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
19127                    Err(LinkError::BadAddress { .. })
19128                ),
19129                "grant_issue must refuse {bad:?}"
19130            );
19131            assert!(
19132                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
19133                "grant_list must refuse {bad:?}"
19134            );
19135            assert!(
19136                matches!(
19137                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
19138                    Err(LinkError::BadAddress { .. })
19139                ),
19140                "grant_revoke must refuse brain {bad:?}"
19141            );
19142            assert!(
19143                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
19144                "head must refuse {bad:?}"
19145            );
19146        }
19147    }
19148
19149    #[test]
19150    fn grant_revoke_refuses_url_reshaping_grant_ids() {
19151        let cfg = dead_hub();
19152        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
19153            assert!(
19154                matches!(
19155                    grant_revoke(&cfg, "acme", bad),
19156                    Err(LinkError::BadGrantId { .. })
19157                ),
19158                "grant_revoke must refuse grant id {bad:?}"
19159            );
19160        }
19161    }
19162
19163    #[test]
19164    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
19165        let cfg = dead_hub();
19166        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
19167            assert!(
19168                matches!(
19169                    propose(&cfg, bad, "intake", "hi"),
19170                    Err(LinkError::BadAddress { .. })
19171                ),
19172                "propose must refuse handle {bad:?}"
19173            );
19174        }
19175        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
19176        assert!(matches!(
19177            propose(&cfg, "acme-site", "intake", &oversize),
19178            Err(LinkError::ProposeTooLarge { .. })
19179        ));
19180        // A clean handle + in-cap body passes both gates: the failure is now
19181        // the (dead) wire, proving the gates refuse shape, not the verb.
19182        assert!(matches!(
19183            propose(&cfg, "acme-site", "intake", "hi"),
19184            Err(LinkError::Transport { .. })
19185        ));
19186    }
19187
19188    #[test]
19189    fn resolve_refuses_a_hand_built_unsafe_address() {
19190        let cfg = dead_hub();
19191        for brain in ["../up", "a/b", "a?x", "a#f"] {
19192            let addr = Address {
19193                brain: brain.to_string(),
19194                target: None,
19195            };
19196            assert!(
19197                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
19198                "resolve must refuse brain {brain:?}"
19199            );
19200        }
19201        for target in [
19202            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
19203            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
19204            AddressTarget::Path("../up.md".to_string()),
19205            AddressTarget::Path("records/x.md#frag".to_string()),
19206        ] {
19207            let addr = Address {
19208                brain: "acme".to_string(),
19209                target: Some(target.clone()),
19210            };
19211            assert!(
19212                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
19213                "resolve must refuse target {target:?}"
19214            );
19215        }
19216    }
19217
19218    #[test]
19219    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
19220        let mut local = std::collections::BTreeMap::new();
19221        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
19222        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
19223        let mut remote = std::collections::BTreeMap::new();
19224        remote.insert(
19225            "records/a.md".to_string(),
19226            V2BaselineFile {
19227                sha256: "c".repeat(64),
19228                bytes: 1,
19229                proof: None,
19230            },
19231        );
19232        remote.insert(
19233            "records/b.md".to_string(),
19234            V2BaselineFile {
19235                sha256: "b".repeat(64),
19236                bytes: 1,
19237                proof: None,
19238            },
19239        );
19240        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
19241    }
19242
19243    #[test]
19244    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
19245        let local = std::collections::BTreeMap::new();
19246        let mut remote = std::collections::BTreeMap::new();
19247        remote.insert(
19248            "private/local.md".to_string(),
19249            V2BaselineFile {
19250                sha256: "d".repeat(64),
19251                bytes: 1,
19252                proof: None,
19253            },
19254        );
19255        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
19256        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
19257    }
19258
19259    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
19260        V2VerifiedHead {
19261            requested: TEST_BRAIN_ID.to_string(),
19262            brain_id: TEST_BRAIN_ID.to_string(),
19263            view_kind: "scoped".to_string(),
19264            view_revision: revision.to_string(),
19265            control_revision: revision.to_string(),
19266            identity: V2HeadIdentity {
19267                custody: "hub".to_string(),
19268                fingerprint: "test".to_string(),
19269                public_key_spki: "test".to_string(),
19270                previous: Vec::new(),
19271                rotations: Vec::new(),
19272            },
19273            pointer: None,
19274            trust: TrustState {
19275                v: 2,
19276                origin: "https://hub.example".to_string(),
19277                requested: TEST_BRAIN_ID.to_string(),
19278                brain: TEST_BRAIN_ID.to_string(),
19279                home: None,
19280                anchor: "ed25519:test".to_string(),
19281                current: "ed25519:test".to_string(),
19282                head_seq: 0,
19283                feed_hash: None,
19284                rotations: Vec::new(),
19285                hub_signer: None,
19286                protocol_profile: Some("link-v2".to_string()),
19287            },
19288            alias: None,
19289        }
19290    }
19291
19292    #[test]
19293    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
19294        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
19295        assert!(accepted_as_v2(&trust));
19296
19297        trust.protocol_profile = None;
19298        trust.hub_signer = Some("ed25519:hub".to_string());
19299        assert!(accepted_as_v2(&trust));
19300
19301        trust.hub_signer = None;
19302        assert!(!accepted_as_v2(&trust));
19303    }
19304
19305    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
19306        V2SyncBaseline {
19307            v: 2,
19308            origin: "https://hub.example".to_string(),
19309            brain: TEST_BRAIN_ID.to_string(),
19310            checkout_id: Some("c".repeat(64)),
19311            head_seq: Some(0),
19312            commit_hash: None,
19313            content_root: None,
19314            asset_root: None,
19315            assets: std::collections::BTreeMap::new(),
19316            view_kind: Some("scoped".to_string()),
19317            view_revision: Some(revision.to_string()),
19318            control_revision: Some(revision.to_string()),
19319            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
19320            files: std::collections::BTreeMap::new(),
19321            scan_cache: std::collections::BTreeMap::new(),
19322            local_policy_digest: None,
19323            local_eligibility: std::collections::BTreeMap::new(),
19324            remote_copy_remains: std::collections::BTreeMap::new(),
19325        }
19326    }
19327
19328    #[test]
19329    fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
19330        let cfg = test_hub_config(
19331            "https://hub.example".to_string(),
19332            tempfile::tempdir().unwrap().keep(),
19333        );
19334        let mut baseline = scoped_test_baseline(&"a".repeat(64));
19335        baseline.assets.insert(
19336            "assets/archive.bin".to_string(),
19337            V2BaselineAsset {
19338                blob_sha256: "b".repeat(64),
19339                bytes: MAX_STORE_BYTES + 1,
19340                media_type: "application/octet-stream".to_string(),
19341                wrappers: vec!["records/archive.md".to_string()],
19342                required: true,
19343                disposition: "hosted".to_string(),
19344                leaf_hash: "c".repeat(64),
19345            },
19346        );
19347
19348        let accepted = serde_json::to_vec(&baseline).unwrap();
19349        assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
19350
19351        baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
19352        let refused = serde_json::to_vec(&baseline).unwrap();
19353        assert!(matches!(
19354            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
19355            Err(LinkError::InvalidFeed { .. })
19356        ));
19357    }
19358
19359    #[cfg(unix)]
19360    #[test]
19361    fn v2_stat_cache_waits_out_racy_files_and_binds_file_identity() {
19362        use std::os::unix::fs::MetadataExt as _;
19363
19364        let directory = tempfile::tempdir().unwrap();
19365        let first = directory.path().join("first.md");
19366        let second = directory.path().join("second.md");
19367        std::fs::write(&first, b"same").unwrap();
19368        std::fs::write(&second, b"same").unwrap();
19369        let first = std::fs::metadata(first).unwrap();
19370        let second = std::fs::metadata(second).unwrap();
19371        let observed_ns = |metadata: &std::fs::Metadata| {
19372            let mtime =
19373                i128::from(metadata.mtime()) * 1_000_000_000 + i128::from(metadata.mtime_nsec());
19374            let ctime =
19375                i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
19376            mtime.max(ctime)
19377        };
19378
19379        assert!(v2_scan_fingerprint_at(&first, observed_ns(&first) + 1_000_000_000).is_none());
19380        let first_fingerprint =
19381            v2_scan_fingerprint_at(&first, observed_ns(&first) + 3_000_000_000).unwrap();
19382        let second_fingerprint =
19383            v2_scan_fingerprint_at(&second, observed_ns(&second) + 3_000_000_000).unwrap();
19384        assert_ne!(first_fingerprint, second_fingerprint);
19385    }
19386
19387    #[test]
19388    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
19389        let directory = tempfile::tempdir().unwrap();
19390        std::fs::write(
19391            directory.path().join("DB.md"),
19392            scoped_projection_bytes(TEST_BRAIN_ID),
19393        )
19394        .unwrap();
19395        let store = Store::open_strict(directory.path()).unwrap();
19396        let head = scoped_test_head(&"a".repeat(64));
19397        let baseline = scoped_test_baseline(&"a".repeat(64));
19398        let mut view = v2_local_files(&store).unwrap();
19399        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
19400        assert!(!view.riding.contains_key("DB.md"));
19401        assert!(!view.eligibility.contains_key("DB.md"));
19402    }
19403
19404    #[test]
19405    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
19406        let directory = tempfile::tempdir().unwrap();
19407        std::fs::write(
19408            directory.path().join("DB.md"),
19409            scoped_projection_bytes(TEST_BRAIN_ID),
19410        )
19411        .unwrap();
19412        let store = Store::open_strict(directory.path()).unwrap();
19413        let head = scoped_test_head(&"a".repeat(64));
19414        let baseline = scoped_test_baseline(&"a".repeat(64));
19415
19416        let mut carried = v2_local_files(&store).unwrap();
19417        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
19418        let handed_off =
19419            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
19420        assert!(!handed_off.riding.contains_key("DB.md"));
19421
19422        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
19423        assert!(!freshly_scanned.riding.contains_key("DB.md"));
19424
19425        std::fs::write(
19426            directory.path().join("DB.md"),
19427            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
19428        )
19429        .unwrap();
19430        let tampered = Store::open_strict(directory.path()).unwrap();
19431        assert!(matches!(
19432            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
19433            Err(LinkError::ScopedProjectionModified)
19434        ));
19435    }
19436
19437    #[test]
19438    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
19439        let directory = tempfile::tempdir().unwrap();
19440        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19441        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
19442        std::fs::write(
19443            directory.path().join("DB.md"),
19444            b"---\nname: Kept home test\n---\n",
19445        )
19446        .unwrap();
19447        std::fs::write(
19448            directory.path().join("records/notes/a.md"),
19449            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
19450        )
19451        .unwrap();
19452        std::fs::write(
19453            directory.path().join("sources/private/secret.md"),
19454            b"---\ntype: note\n---\nlocal only\n",
19455        )
19456        .unwrap();
19457        std::fs::write(
19458            directory.path().join("sources/private/unlinked.md"),
19459            b"---\ntype: note\n---\nnot disclosed\n",
19460        )
19461        .unwrap();
19462        std::fs::write(
19463            directory.path().join(".sevralocal"),
19464            b"sources/private/**\n",
19465        )
19466        .unwrap();
19467
19468        let store = Store::open_strict(directory.path()).unwrap();
19469        let view = v2_local_files(&store).unwrap();
19470        assert!(!view.riding.contains_key("sources/private/secret.md"));
19471        assert_eq!(
19472            view.withheld_links,
19473            vec![V2WithheldLink {
19474                source: "records/notes/a.md".to_string(),
19475                target: "sources/private/secret.md".to_string(),
19476            }]
19477        );
19478    }
19479
19480    #[test]
19481    fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
19482        // An export omits kept-home content by construction, so a store
19483        // restored from one links to names it does not carry. Without this the
19484        // owner's own export could never be pushed back — the links would read
19485        // as broken and the commit would be refused.
19486        let directory = tempfile::tempdir().unwrap();
19487        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19488        std::fs::write(
19489            directory.path().join("DB.md"),
19490            b"---\nname: Restored export\n---\n",
19491        )
19492        .unwrap();
19493        std::fs::write(
19494            directory.path().join("records/notes/a.md"),
19495            b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
19496        )
19497        .unwrap();
19498        std::fs::write(
19499            directory.path().join(".sevralocal"),
19500            b"sources/private/**\n",
19501        )
19502        .unwrap();
19503
19504        let store = Store::open_strict(directory.path()).unwrap();
19505        let view = v2_local_files(&store).unwrap();
19506        assert_eq!(
19507            view.withheld_links,
19508            vec![V2WithheldLink {
19509                source: "records/notes/a.md".to_string(),
19510                target: "sources/private/absent.md".to_string(),
19511            }]
19512        );
19513        // A dangling link the owner never claimed is still dangling.
19514        std::fs::write(
19515            directory.path().join("records/notes/b.md"),
19516            b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
19517        )
19518        .unwrap();
19519        let store = Store::open_strict(directory.path()).unwrap();
19520        let view = v2_local_files(&store).unwrap();
19521        assert!(
19522            !view
19523                .withheld_links
19524                .iter()
19525                .any(|link| link.target == "records/notes/nowhere.md"),
19526            "an unclaimed dangling target must not be declared withheld"
19527        );
19528    }
19529
19530    #[test]
19531    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
19532        let directory = tempfile::tempdir().unwrap();
19533        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
19534        std::fs::write(
19535            directory.path().join("DB.md"),
19536            b"---\nname: Withdrawal test\n---\n",
19537        )
19538        .unwrap();
19539        let source = b"---\ntype: note\n---\nlocal evidence\n";
19540        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
19541        std::fs::write(
19542            directory.path().join(".sevralocal"),
19543            b"sources/private/**\n",
19544        )
19545        .unwrap();
19546        let store = Store::open_strict(directory.path()).unwrap();
19547        let view = v2_local_files(&store).unwrap();
19548        let mut remote = std::collections::BTreeMap::new();
19549        remote.insert(
19550            "sources/private/evidence.md".to_string(),
19551            V2BaselineFile {
19552                sha256: content_sha256(source),
19553                bytes: source.len() as u64,
19554                proof: None,
19555            },
19556        );
19557        assert_eq!(
19558            v2_content_withdrawal_operation(
19559                &store,
19560                &view,
19561                &remote,
19562                "sources/private/evidence.md",
19563                "approved retention change",
19564            )
19565            .unwrap(),
19566            json!({
19567                "op": "withdraw_from_hosting",
19568                "path": "sources/private/evidence.md",
19569                "expected": { "kind": "blob", "hash": content_sha256(source) },
19570                "reason": "approved retention change",
19571            })
19572        );
19573
19574        std::fs::write(
19575            directory.path().join("sources/private/evidence.md"),
19576            b"changed after review",
19577        )
19578        .unwrap();
19579        assert!(matches!(
19580            v2_content_withdrawal_operation(
19581                &store,
19582                &view,
19583                &remote,
19584                "sources/private/evidence.md",
19585                "approved retention change",
19586            ),
19587            Err(LinkError::InvalidPack { .. })
19588        ));
19589    }
19590
19591    #[test]
19592    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
19593        let directory = tempfile::tempdir().unwrap();
19594        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
19595        std::fs::write(
19596            directory.path().join("DB.md"),
19597            b"---\nname: Asset withdrawal test\n---\n",
19598        )
19599        .unwrap();
19600        let bytes = b"private binary";
19601        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
19602        std::fs::write(
19603            directory.path().join(".sevralocal"),
19604            b"sources/files/private.pdf\n",
19605        )
19606        .unwrap();
19607        let store = Store::open_strict(directory.path()).unwrap();
19608        let view = v2_local_files(&store).unwrap();
19609        let local = crate::AssetRecord {
19610            path: "sources/files/private.pdf".to_string(),
19611            sha256: content_sha256(bytes),
19612            bytes: bytes.len() as u64,
19613            media_type: "application/pdf".to_string(),
19614            wrappers: vec![
19615                "sources/files/private.md".to_string(),
19616                "sources/redacted/private.md".to_string(),
19617            ],
19618            required: false,
19619        };
19620        let current = V2BaselineAsset {
19621            blob_sha256: local.sha256.clone(),
19622            bytes: local.bytes,
19623            media_type: local.media_type.clone(),
19624            wrappers: vec!["sources/files/private.md".to_string()],
19625            required: true,
19626            disposition: "hosted".to_string(),
19627            leaf_hash: "d".repeat(64),
19628        };
19629        assert_eq!(
19630            v2_asset_withdrawal_operation(
19631                &store,
19632                &view,
19633                &local.path,
19634                &local,
19635                &current,
19636                "approved retention change",
19637            )
19638            .unwrap(),
19639            json!({
19640                "op": "asset_withdraw",
19641                "path": local.path,
19642                "expected": { "kind": "asset", "hash": "d".repeat(64) },
19643                "asset": {
19644                    "blob_sha256": local.sha256.clone(),
19645                    "bytes": local.bytes,
19646                    "media_type": local.media_type.clone(),
19647                    "wrappers": local.wrappers.clone(),
19648                    "required": false,
19649                    "disposition": "withheld",
19650                },
19651                "reason": "approved retention change",
19652            })
19653        );
19654
19655        let mut mismatched = current.clone();
19656        mismatched.blob_sha256 = "f".repeat(64);
19657        assert!(matches!(
19658            v2_asset_withdrawal_operation(
19659                &store,
19660                &view,
19661                &local.path,
19662                &local,
19663                &mismatched,
19664                "approved retention change",
19665            ),
19666            Err(LinkError::InvalidPack { .. })
19667        ));
19668    }
19669
19670    #[test]
19671    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
19672        let first = v2_checkout_id(None).unwrap();
19673        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
19674        assert_ne!(first, v2_checkout_id(None).unwrap());
19675        assert!(is_sha256(&first));
19676    }
19677
19678    #[test]
19679    fn moved_checkout_relocates_its_path_bound_baseline_without_rehash_ambiguity() {
19680        let sandbox = tempfile::tempdir().unwrap();
19681        let stage_root = sandbox.path().join("stage");
19682        let live_root = sandbox.path().join("live");
19683        let from = stage_root.join("db");
19684        let to = live_root.join("db");
19685        std::fs::create_dir_all(from.join("records/items")).unwrap();
19686        std::fs::write(
19687            from.join("DB.md"),
19688            b"---\ntype: db-md\nscope: company\nowner: test\n---\n",
19689        )
19690        .unwrap();
19691        std::fs::write(
19692            from.join("records/items/example.md"),
19693            b"---\ntype: item\n---\n\n# Example\n",
19694        )
19695        .unwrap();
19696        let cfg = test_hub_config(
19697            "https://hub.example".to_string(),
19698            sandbox.path().join("state"),
19699        );
19700        let store = Store::open_strict(&from).unwrap();
19701        let local = v2_local_files(&store).unwrap();
19702        let files = local
19703            .riding
19704            .iter()
19705            .map(|(path, (sha256, bytes))| {
19706                (
19707                    path.clone(),
19708                    V2BaselineFile {
19709                        sha256: sha256.clone(),
19710                        bytes: *bytes,
19711                        proof: None,
19712                    },
19713                )
19714            })
19715            .collect();
19716        let baseline = V2SyncBaseline {
19717            v: 2,
19718            origin: "https://hub.example".to_string(),
19719            brain: TEST_BRAIN_ID.to_string(),
19720            checkout_id: Some("c".repeat(64)),
19721            head_seq: Some(7),
19722            commit_hash: Some("a".repeat(64)),
19723            content_root: Some("b".repeat(64)),
19724            asset_root: None,
19725            assets: std::collections::BTreeMap::new(),
19726            view_kind: Some("full".to_string()),
19727            view_revision: Some("d".repeat(64)),
19728            control_revision: Some("e".repeat(64)),
19729            projection_sha256: None,
19730            files,
19731            scan_cache: local.scan_cache.clone(),
19732            local_policy_digest: Some(local.policy.digest.clone()),
19733            local_eligibility: local.eligibility.clone(),
19734            remote_copy_remains: std::collections::BTreeMap::new(),
19735        };
19736        save_v2_baseline(&cfg, TEST_BRAIN_ID, &from, &baseline).unwrap();
19737        std::fs::rename(&stage_root, &live_root).unwrap();
19738
19739        let first = relocate_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from, &to).unwrap();
19740        assert_eq!(first.get("moved").and_then(Value::as_bool), Some(true));
19741        assert!(!has_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from).unwrap());
19742        assert!(has_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &to).unwrap());
19743
19744        let retry = relocate_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from, &to).unwrap();
19745        assert_eq!(retry.get("moved").and_then(Value::as_bool), Some(false));
19746    }
19747
19748    #[test]
19749    fn scoped_projection_edit_and_scope_transition_fail_closed() {
19750        let directory = tempfile::tempdir().unwrap();
19751        std::fs::write(
19752            directory.path().join("DB.md"),
19753            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
19754        )
19755        .unwrap();
19756        let store = Store::open_strict(directory.path()).unwrap();
19757        let head = scoped_test_head(&"a".repeat(64));
19758        let baseline = scoped_test_baseline(&"a".repeat(64));
19759        let mut view = v2_local_files(&store).unwrap();
19760        assert!(matches!(
19761            remove_scoped_projection(&head, Some(&baseline), &mut view),
19762            Err(LinkError::ScopedProjectionModified)
19763        ));
19764
19765        let changed = scoped_test_head(&"b".repeat(64));
19766        assert!(matches!(
19767            ensure_v2_view_compatible(&changed, Some(&baseline)),
19768            Err(LinkError::ScopedViewChanged)
19769        ));
19770
19771        let mut same_view_new_control = head.clone();
19772        same_view_new_control.control_revision = "c".repeat(64);
19773        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
19774        assert!(!same_v2_head(&head, &same_view_new_control));
19775    }
19776
19777    #[test]
19778    fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
19779        let mut head = scoped_test_head(&"a".repeat(64));
19780        head.control_revision = "b".repeat(64);
19781        head.pointer = Some(V2PointerBody {
19782            v: 2,
19783            brain: TEST_BRAIN_ID.to_string(),
19784            seq: 7,
19785            commit_hash: "c".repeat(64),
19786            feed_hash: "d".repeat(64),
19787            content_root: Some("e".repeat(64)),
19788            asset_root: Some("f".repeat(64)),
19789            materializer: "dbmd-projection-v1".to_string(),
19790            signer_epoch: 1,
19791            control_revision: head.control_revision.clone(),
19792            backup_preparation: "0".repeat(64),
19793            prior_pointer_hash: Some("1".repeat(64)),
19794            signed_at: "2026-08-24T00:00:00.000Z".to_string(),
19795        });
19796        let mut baseline = scoped_test_baseline(&head.view_revision);
19797        baseline.head_seq = Some(7);
19798        baseline.commit_hash = Some("c".repeat(64));
19799        baseline.content_root = Some("e".repeat(64));
19800        baseline.asset_root = Some("f".repeat(64));
19801        baseline.control_revision = Some(head.control_revision.clone());
19802        assert!(v2_baseline_matches_head(&head, &baseline));
19803
19804        let mut changed = baseline.clone();
19805        changed.head_seq = Some(8);
19806        assert!(!v2_baseline_matches_head(&head, &changed));
19807        let mut changed = baseline.clone();
19808        changed.commit_hash = Some("2".repeat(64));
19809        assert!(!v2_baseline_matches_head(&head, &changed));
19810        let mut changed = baseline.clone();
19811        changed.content_root = Some("3".repeat(64));
19812        assert!(!v2_baseline_matches_head(&head, &changed));
19813        let mut changed = baseline.clone();
19814        changed.asset_root = Some("4".repeat(64));
19815        assert!(!v2_baseline_matches_head(&head, &changed));
19816        let mut changed = baseline.clone();
19817        changed.view_revision = Some("5".repeat(64));
19818        assert!(!v2_baseline_matches_head(&head, &changed));
19819        let mut changed = baseline.clone();
19820        changed.control_revision = Some("6".repeat(64));
19821        assert!(!v2_baseline_matches_head(&head, &changed));
19822
19823        let mut changed_head = head.clone();
19824        changed_head.view_kind = "full".to_string();
19825        assert!(!v2_baseline_matches_head(&changed_head, &baseline));
19826    }
19827
19828    #[test]
19829    fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
19830        let sandbox = tempfile::tempdir().unwrap();
19831        let cfg = test_hub_config(
19832            "https://hub.example".to_string(),
19833            sandbox.path().to_path_buf(),
19834        );
19835        let head = scoped_test_head(&"a".repeat(64));
19836        let baseline = scoped_test_baseline(&head.view_revision);
19837        let mut encoded = serde_json::to_value(&baseline).unwrap();
19838        encoded.as_object_mut().unwrap().remove("control_revision");
19839        let parsed =
19840            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
19841        assert!(parsed.control_revision.is_none());
19842        assert!(!v2_baseline_matches_head(&head, &parsed));
19843    }
19844
19845    #[test]
19846    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
19847        let scoped = scoped_test_head(&"a".repeat(64));
19848        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
19849        assert!(matches!(
19850            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
19851            Err(LinkError::ScopedProjectionModified)
19852        ));
19853
19854        let mut full = scoped.clone();
19855        full.view_kind = "full".to_string();
19856        let mut full_baseline = scoped_baseline.clone();
19857        full_baseline.view_kind = Some("full".to_string());
19858        full_baseline.projection_sha256 = None;
19859        assert!(matches!(
19860            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
19861            Err(LinkError::InvalidPack { .. })
19862        ));
19863
19864        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
19865        assert!(
19866            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
19867        );
19868    }
19869
19870    #[test]
19871    fn scoped_view_metadata_is_explicitly_non_authoritative() {
19872        let head = scoped_test_head(&"a".repeat(64));
19873        let value: Value =
19874            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
19875        assert_eq!(value["kind"], "link.md-scoped-view");
19876        assert_eq!(value["authoritative"], false);
19877        assert_eq!(value["visible_files"], 7);
19878        assert_eq!(value["brain"], TEST_BRAIN_ID);
19879    }
19880
19881    #[test]
19882    fn local_scoped_marker_requires_the_exact_generated_projection() {
19883        let directory = tempfile::tempdir().unwrap();
19884        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
19885        std::fs::write(
19886            directory.path().join("DB.md"),
19887            scoped_projection_bytes(TEST_BRAIN_ID),
19888        )
19889        .unwrap();
19890        let head = scoped_test_head(&"a".repeat(64));
19891        std::fs::write(
19892            directory.path().join(".dbmd/view.json"),
19893            scoped_view_metadata(&head, 0).unwrap(),
19894        )
19895        .unwrap();
19896        let store = Store::open_strict(directory.path()).unwrap();
19897        assert!(has_verified_local_scoped_view(&store));
19898
19899        std::fs::write(
19900            directory.path().join("DB.md"),
19901            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
19902        )
19903        .unwrap();
19904        let altered = Store::open_strict(directory.path()).unwrap();
19905        assert!(!has_verified_local_scoped_view(&altered));
19906    }
19907
19908    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
19909        use ring::signature::KeyPair as _;
19910
19911        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
19912        let rng = ring::rand::SystemRandom::new();
19913        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
19914        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
19915        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
19916        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
19917        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
19918        let blob = b"new";
19919        let blob_hash = content_sha256(blob);
19920        let changes = json!({
19921            "mutation_id": "sync:proposal-fixture",
19922            "operations": [{
19923                "blob": blob_hash,
19924                "bytes": blob.len(),
19925                "expected": null,
19926                "op": "put",
19927                "path": "records/new.md",
19928            }],
19929            "reason": "fixture",
19930            "v": 2,
19931        });
19932        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
19933        let changes_base64 = STANDARD.encode(&changes_bytes);
19934        let descriptor = json!({
19935            "base": null,
19936            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
19937            "changes_base64": changes_base64,
19938            "rebase": "strict",
19939            "v": 2,
19940        });
19941        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
19942        let payload_hash = "b".repeat(64);
19943        let submitted_at = "2026-08-19T12:00:00.000Z";
19944        let claim = json!({
19945            "actor_root": {
19946                "actor_class": "foreign_key",
19947                "credential": "ed25519:fixture",
19948                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
19949                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
19950                "principal": "key:fixture",
19951                "role": null,
19952            },
19953            "brain": TEST_BRAIN_ID,
19954            "clear_sha256": clear_hash,
19955            "control_revision": "c".repeat(64),
19956            "mutation_id": "sync:proposal-fixture",
19957            "payload_sha256": payload_hash,
19958            "proposal_id": proposal_id,
19959            "submitted_at": submitted_at,
19960            "v": 2,
19961        });
19962        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
19963        let envelope = json!({
19964            "claim": claim,
19965            "fingerprint": fingerprint,
19966            "public_key": public_key,
19967            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
19968        });
19969        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
19970        let submission_hash =
19971            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
19972        let mut head = scoped_test_head(&"c".repeat(64));
19973        head.view_kind = "full".to_string();
19974        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
19975        let value = json!({
19976            "proposal": {
19977                "base": null,
19978                "blobs": [{
19979                    "bytes": blob.len(),
19980                    "endpoint": format!(
19981                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
19982                    ),
19983                    "sha256": blob_hash,
19984                }],
19985                "changes_base64": changes_base64,
19986                "clear_sha256": clear_hash,
19987                "expires_at": "2026-08-26T12:00:00.000Z",
19988                "id": proposal_id,
19989                "payload_sha256": payload_hash,
19990                "proposer": { "class": "foreign_key" },
19991                "rebase": "strict",
19992                "state": "pending",
19993                "submission_claim_base64": STANDARD.encode(envelope_bytes),
19994                "submission_claim_sha256": submission_hash,
19995                "submitted_at": submitted_at,
19996            },
19997            "v": 2,
19998        });
19999        (head, proposal_id, value)
20000    }
20001
20002    #[test]
20003    fn v2_proposal_verifier_accepts_exact_signed_payload() {
20004        let (head, proposal_id, value) = signed_proposal_fixture();
20005        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
20006        assert_eq!(verified.blobs.len(), 1);
20007        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
20008    }
20009
20010    #[test]
20011    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
20012        let (head, proposal_id, value) = signed_proposal_fixture();
20013
20014        let mut changed = value.clone();
20015        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
20016        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
20017
20018        let mut redirected = value.clone();
20019        redirected["proposal"]["blobs"][0]["endpoint"] =
20020            Value::String("https://attacker.example/blob".to_string());
20021        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
20022
20023        let mut forged = value;
20024        let encoded = forged["proposal"]["submission_claim_base64"]
20025            .as_str()
20026            .unwrap();
20027        let mut envelope: Value =
20028            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
20029        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
20030        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
20031        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
20032        forged["proposal"]["submission_claim_sha256"] = Value::String(
20033            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
20034        );
20035        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
20036    }
20037
20038    #[cfg(unix)]
20039    #[test]
20040    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
20041        let sandbox = tempfile::tempdir().unwrap();
20042        let destination = sandbox.path().join("brain");
20043        let entries = vec![
20044            (
20045                "DB.md".to_string(),
20046                scoped_projection_bytes(TEST_BRAIN_ID),
20047            ),
20048            (
20049                "records/contacts/a.md".to_string(),
20050                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
20051                    .to_vec(),
20052            ),
20053        ];
20054        install_pulled_delta(&destination, &entries, &[], true).unwrap();
20055        assert!(destination.join("index.md").is_file());
20056        assert!(destination.join("records/index.md").is_file());
20057        assert!(destination.join("records/contacts/index.md").is_file());
20058        assert!(destination.join("records/contacts/index.jsonl").is_file());
20059    }
20060
20061    #[cfg(unix)]
20062    #[test]
20063    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
20064        let sandbox = tempfile::tempdir().unwrap();
20065        let destination = sandbox.path().join("brain");
20066        let cache = sandbox.path().join("cache");
20067        std::fs::create_dir(&cache).unwrap();
20068        let db = scoped_projection_bytes(TEST_BRAIN_ID);
20069        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
20070        let db_source = cache.join("db");
20071        let shared_source = cache.join("shared");
20072        crate::fsx::write_atomic(&db_source, &db).unwrap();
20073        crate::fsx::write_atomic(&shared_source, shared).unwrap();
20074        let mut entries = vec![V2StagedFile {
20075            path: "DB.md".to_string(),
20076            source: db_source,
20077            sha256: content_sha256(&db),
20078            bytes: db.len() as u64,
20079        }];
20080        for index in 0..512 {
20081            entries.push(V2StagedFile {
20082                path: format!("records/items/{index:05}.md"),
20083                source: shared_source.clone(),
20084                sha256: content_sha256(shared),
20085                bytes: shared.len() as u64,
20086            });
20087        }
20088        install_pulled_delta_sources(
20089            &destination,
20090            &entries,
20091            &[],
20092            false,
20093            None,
20094            &scoped_test_head(&"c".repeat(64)),
20095        )
20096        .unwrap();
20097        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
20098        for index in 0..512 {
20099            assert_eq!(
20100                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
20101                shared
20102            );
20103        }
20104        assert!(
20105            std::fs::read_dir(sandbox.path())
20106                .unwrap()
20107                .all(|entry| !entry
20108                    .unwrap()
20109                    .file_name()
20110                    .to_string_lossy()
20111                    .contains("pull-stage")),
20112            "the private stage must be atomically installed or removed"
20113        );
20114    }
20115
20116    #[cfg(unix)]
20117    #[test]
20118    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
20119        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
20120
20121        let sandbox = tempfile::tempdir().unwrap();
20122        let root = sandbox.path().join("brain");
20123        std::fs::create_dir_all(root.join("records/items")).unwrap();
20124        let db = scoped_projection_bytes(TEST_BRAIN_ID);
20125        let old = b"---\ntype: note\n---\n\nold\n";
20126        let new = b"---\ntype: note\n---\n\nnew\n";
20127        let removed = b"---\ntype: note\n---\n\nremove me\n";
20128        std::fs::write(root.join("DB.md"), &db).unwrap();
20129        std::fs::write(root.join("records/items/change.md"), old).unwrap();
20130        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
20131        for index in 0..512 {
20132            std::fs::write(
20133                root.join(format!("records/items/untouched-{index:04}.md")),
20134                old,
20135            )
20136            .unwrap();
20137        }
20138        let untouched = root.join("records/items/untouched-0256.md");
20139        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
20140        let source = sandbox.path().join("changed-source");
20141        crate::fsx::write_atomic(&source, new).unwrap();
20142        let same_source = sandbox.path().join("unchanged-source");
20143        crate::fsx::write_atomic(&same_source, old).unwrap();
20144        let same_entry = V2StagedFile {
20145            path: "records/items/change.md".to_string(),
20146            source: same_source,
20147            sha256: content_sha256(old),
20148            bytes: old.len() as u64,
20149        };
20150        let entry = V2StagedFile {
20151            path: "records/items/change.md".to_string(),
20152            source,
20153            sha256: content_sha256(new),
20154            bytes: new.len() as u64,
20155        };
20156        let head = scoped_test_head(&"c".repeat(64));
20157
20158        // A no-op established pull neither clones the checkout nor arms a
20159        // journal. Inode identity is a direct regression guard against the
20160        // former brain-wide private-copy implementation.
20161        install_established_v2_delta(
20162            Store::open_strict(&root).unwrap(),
20163            &[same_entry],
20164            &["records/items/already-absent.md".to_string()],
20165            true,
20166            None,
20167            &head,
20168        )
20169        .unwrap();
20170        assert_eq!(
20171            std::fs::metadata(&untouched).unwrap().ino(),
20172            untouched_inode
20173        );
20174        assert!(!root.join(V2_PULL_JOURNAL).exists());
20175
20176        install_established_v2_delta(
20177            Store::open_strict(&root).unwrap(),
20178            &[entry],
20179            &["records/items/delete.md".to_string()],
20180            false,
20181            None,
20182            &head,
20183        )
20184        .unwrap();
20185        assert_eq!(
20186            std::fs::read(root.join("records/items/change.md")).unwrap(),
20187            new
20188        );
20189        assert!(!root.join("records/items/delete.md").exists());
20190        assert_eq!(
20191            std::fs::metadata(&untouched).unwrap().ino(),
20192            untouched_inode
20193        );
20194        assert!(root.join(V2_PULL_JOURNAL).is_file());
20195        assert_eq!(
20196            std::fs::metadata(root.join(V2_PULL_JOURNAL))
20197                .unwrap()
20198                .permissions()
20199                .mode()
20200                & 0o777,
20201            0o600
20202        );
20203        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
20204            .unwrap()
20205            .unwrap();
20206        assert_eq!(
20207            std::fs::metadata(root.join(&journal.backup_dir))
20208                .unwrap()
20209                .permissions()
20210                .mode()
20211                & 0o777,
20212            0o700
20213        );
20214        for entry in &journal.entries {
20215            if let Some(backup) = &entry.backup {
20216                assert_eq!(
20217                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
20218                        .unwrap()
20219                        .permissions()
20220                        .mode()
20221                        & 0o777,
20222                    0o600
20223                );
20224            }
20225        }
20226
20227        let cfg = test_hub_config(
20228            "https://example.test".to_string(),
20229            sandbox.path().join("state"),
20230        );
20231        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20232        assert_eq!(
20233            std::fs::read(root.join("records/items/change.md")).unwrap(),
20234            old
20235        );
20236        assert_eq!(
20237            std::fs::read(root.join("records/items/delete.md")).unwrap(),
20238            removed
20239        );
20240        assert_eq!(
20241            std::fs::metadata(&untouched).unwrap().ino(),
20242            untouched_inode
20243        );
20244        assert!(!root.join(V2_PULL_JOURNAL).exists());
20245    }
20246
20247    #[test]
20248    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
20249        let body = b"bounded bytes";
20250        let path = "records/example.md".to_string();
20251        let file = V2BaselineFile {
20252            sha256: content_sha256(body),
20253            bytes: body.len() as u64,
20254            proof: None,
20255        };
20256        let header = serde_json::to_vec(&json!({
20257            "bytes": body.len(),
20258            "path": path,
20259            "sha256": file.sha256,
20260            "v": 2,
20261        }))
20262        .unwrap();
20263        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
20264        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
20265        stream.extend_from_slice(&header);
20266        stream.extend_from_slice(body);
20267        stream.extend_from_slice(&0_u32.to_be_bytes());
20268        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
20269        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
20270
20271        let mut tampered = stream.clone();
20272        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
20273        tampered[body_offset] ^= 1;
20274        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
20275
20276        let mut trailing = stream;
20277        trailing.push(0);
20278        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
20279    }
20280
20281    #[test]
20282    fn first_checkout_resolution_does_not_recreate_the_same_conflict() {
20283        let path = "records/value.md".to_string();
20284        let mut local = std::collections::BTreeMap::new();
20285        local.insert(path.clone(), (content_sha256(b"local"), 5));
20286        let mut remote = std::collections::BTreeMap::new();
20287        remote.insert(
20288            path.clone(),
20289            V2BaselineFile {
20290                sha256: content_sha256(b"remote"),
20291                bytes: 6,
20292                proof: None,
20293            },
20294        );
20295
20296        assert_eq!(
20297            v2_initial_content_conflicts(&local, &remote, false),
20298            vec![path]
20299        );
20300        assert!(v2_initial_content_conflicts(&local, &remote, true).is_empty());
20301
20302        let mut resolution = std::collections::BTreeMap::new();
20303        resolution.insert(
20304            "records/value.md".to_string(),
20305            V2ResolutionOverride {
20306                expected_remote: Some(content_sha256(b"remote")),
20307                selected_local: Some(content_sha256(b"local")),
20308            },
20309        );
20310        assert!(v2_resolution_allows_path(
20311            Some(&resolution),
20312            "records/value.md",
20313            true
20314        ));
20315        assert!(v2_resolution_allows_path(
20316            Some(&resolution),
20317            "records/new-target.md",
20318            false
20319        ));
20320        assert!(!v2_resolution_allows_path(
20321            Some(&resolution),
20322            "records/unreviewed-remote.md",
20323            true
20324        ));
20325    }
20326
20327    #[test]
20328    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
20329        let sandbox = tempfile::TempDir::new().unwrap();
20330        let root = sandbox.path().join("brain");
20331        std::fs::create_dir_all(&root).unwrap();
20332        std::fs::write(
20333            root.join("DB.md"),
20334            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20335        )
20336        .unwrap();
20337        let store = Store::open_strict(&root).unwrap();
20338        let incomplete = crate::ulid::mint();
20339        store
20340            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
20341            .unwrap();
20342        let expired = crate::ulid::mint();
20343        store
20344            .create_dir_all(&v2_conflict_relative(&expired, "files"))
20345            .unwrap();
20346        let plan = V2ConflictPlan {
20347            v: 2,
20348            class: "content_resolution_required".to_string(),
20349            bundle: expired.clone(),
20350            brain: TEST_BRAIN_ID.to_string(),
20351            origin: "https://example.test".to_string(),
20352            created_unix: 0,
20353            expires_unix: 0,
20354            base_seq: None,
20355            base_commit: None,
20356            remote_seq: 0,
20357            remote_commit: None,
20358            remote_content_root: None,
20359            view_kind: "full".to_string(),
20360            view_revision: "a".repeat(64),
20361            files: vec![V2ConflictFile {
20362                path: "records/value.md".to_string(),
20363                base: V2ConflictCoordinate {
20364                    sha256: None,
20365                    bytes: None,
20366                    file: None,
20367                },
20368                local: V2ConflictCoordinate {
20369                    sha256: None,
20370                    bytes: None,
20371                    file: None,
20372                },
20373                remote: V2ConflictCoordinate {
20374                    sha256: None,
20375                    bytes: None,
20376                    file: None,
20377                },
20378            }],
20379        };
20380        let mut bytes = serde_json::to_vec(&plan).unwrap();
20381        bytes.push(b'\n');
20382        store
20383            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
20384            .unwrap();
20385
20386        let listed = sync_conflicts(&root, false, false).unwrap();
20387        assert_eq!(listed["bundles"], 2);
20388        assert_eq!(listed["pruned"], 0);
20389        let pruned = sync_conflicts(&root, true, false).unwrap();
20390        assert_eq!(pruned["bundles"], 0);
20391        assert_eq!(pruned["pruned"], 2);
20392        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
20393        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
20394    }
20395
20396    #[test]
20397    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
20398        let sandbox = tempfile::TempDir::new().unwrap();
20399        let root = sandbox.path().join("brain");
20400        std::fs::create_dir_all(&root).unwrap();
20401        std::fs::write(
20402            root.join("DB.md"),
20403            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20404        )
20405        .unwrap();
20406        let store = Store::open_strict(&root).unwrap();
20407        let bundle = crate::ulid::mint();
20408        store
20409            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
20410            .unwrap();
20411        store
20412            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
20413            .unwrap();
20414
20415        assert!(sync_conflicts(&root, true, false).is_err());
20416        assert!(sync_conflicts(&root, false, true).is_err());
20417        let pruned = sync_conflicts(&root, true, true).unwrap();
20418        assert_eq!(pruned["pruned"], 1);
20419        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
20420    }
20421
20422    #[test]
20423    fn ready_pull_journal_rolls_back_exact_preimages() {
20424        let sandbox = tempfile::TempDir::new().unwrap();
20425        let root = sandbox.path().join("brain");
20426        std::fs::create_dir_all(root.join("records")).unwrap();
20427        std::fs::write(
20428            root.join("DB.md"),
20429            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20430        )
20431        .unwrap();
20432        let path = "records/value.md";
20433        let old = b"---\ntype: note\n---\n\nold\n";
20434        let new = b"---\ntype: note\n---\n\nnew\n";
20435        std::fs::write(root.join(path), old).unwrap();
20436        let store = Store::open_strict(&root).unwrap();
20437        let bundle = crate::ulid::mint();
20438        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20439        store
20440            .create_private_dir_all(Path::new(&backup_dir))
20441            .unwrap();
20442        store
20443            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
20444            .unwrap();
20445        let journal = V2PullJournal {
20446            v: 1,
20447            phase: V2PullPhase::Ready,
20448            brain: TEST_BRAIN_ID.to_string(),
20449            previous: V2PullCoordinate {
20450                head_seq: None,
20451                commit_hash: None,
20452                view_kind: None,
20453                view_revision: None,
20454            },
20455            next: V2PullCoordinate {
20456                head_seq: Some(2),
20457                commit_hash: Some("c".repeat(64)),
20458                view_kind: Some("full".to_string()),
20459                view_revision: Some("d".repeat(64)),
20460            },
20461            backup_dir: backup_dir.clone(),
20462            entries: vec![V2PullJournalEntry {
20463                path: path.to_string(),
20464                old: Some(V2PullFileCoordinate {
20465                    sha256: content_sha256(old),
20466                    bytes: old.len() as u64,
20467                }),
20468                new: Some(V2PullFileCoordinate {
20469                    sha256: content_sha256(new),
20470                    bytes: new.len() as u64,
20471                }),
20472                backup: Some("00000000".to_string()),
20473            }],
20474        };
20475        validate_v2_pull_journal(&journal).unwrap();
20476        store
20477            .write_private_atomic_new(
20478                Path::new(V2_PULL_JOURNAL),
20479                &v2_pull_journal_bytes(&journal).unwrap(),
20480            )
20481            .unwrap();
20482        store.write_atomic(Path::new(path), new).unwrap();
20483
20484        let cfg = test_hub_config(
20485            "https://example.test".to_string(),
20486            sandbox.path().join("state"),
20487        );
20488        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20489        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
20490        assert!(!root.join(V2_PULL_JOURNAL).exists());
20491        assert!(!root.join(backup_dir).exists());
20492    }
20493
20494    #[test]
20495    fn preparing_pull_journal_discards_only_private_staging() {
20496        let sandbox = tempfile::TempDir::new().unwrap();
20497        let root = sandbox.path().join("brain");
20498        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
20499        std::fs::write(
20500            root.join("DB.md"),
20501            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20502        )
20503        .unwrap();
20504        let store = Store::open_strict(&root).unwrap();
20505        let bundle = crate::ulid::mint();
20506        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20507        store
20508            .create_private_dir_all(Path::new(&backup_dir))
20509            .unwrap();
20510        let journal = V2PullJournal {
20511            v: 1,
20512            phase: V2PullPhase::Preparing,
20513            brain: TEST_BRAIN_ID.to_string(),
20514            previous: V2PullCoordinate {
20515                head_seq: None,
20516                commit_hash: None,
20517                view_kind: None,
20518                view_revision: None,
20519            },
20520            next: V2PullCoordinate {
20521                head_seq: Some(1),
20522                commit_hash: Some("a".repeat(64)),
20523                view_kind: Some("full".to_string()),
20524                view_revision: Some("b".repeat(64)),
20525            },
20526            backup_dir: backup_dir.clone(),
20527            entries: vec![V2PullJournalEntry {
20528                path: "records/new.md".to_string(),
20529                old: None,
20530                new: Some(V2PullFileCoordinate {
20531                    sha256: "c".repeat(64),
20532                    bytes: 1,
20533                }),
20534                backup: None,
20535            }],
20536        };
20537        store
20538            .write_private_atomic_new(
20539                Path::new(V2_PULL_JOURNAL),
20540                &v2_pull_journal_bytes(&journal).unwrap(),
20541            )
20542            .unwrap();
20543        let cfg = test_hub_config(
20544            "https://example.test".to_string(),
20545            sandbox.path().join("state"),
20546        );
20547
20548        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20549
20550        assert!(root.join("DB.md").is_file());
20551        assert!(!root.join(V2_PULL_JOURNAL).exists());
20552        assert!(!root.join(backup_dir).exists());
20553    }
20554
20555    #[test]
20556    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
20557        let sandbox = tempfile::TempDir::new().unwrap();
20558        let root = sandbox.path().join("brain");
20559        std::fs::create_dir_all(root.join("records")).unwrap();
20560        std::fs::write(
20561            root.join("DB.md"),
20562            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20563        )
20564        .unwrap();
20565        let new = b"---\ntype: note\n---\n\nnew\n";
20566        std::fs::write(root.join("records/value.md"), new).unwrap();
20567        let store = Store::open_strict(&root).unwrap();
20568        let bundle = crate::ulid::mint();
20569        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20570        store
20571            .create_private_dir_all(Path::new(&backup_dir))
20572            .unwrap();
20573        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
20574        store.create_private_dir_all(Path::new(&orphan)).unwrap();
20575        let next = V2PullCoordinate {
20576            head_seq: Some(2),
20577            commit_hash: Some("c".repeat(64)),
20578            view_kind: Some("full".to_string()),
20579            view_revision: Some("d".repeat(64)),
20580        };
20581        let journal = V2PullJournal {
20582            v: 1,
20583            phase: V2PullPhase::Ready,
20584            brain: TEST_BRAIN_ID.to_string(),
20585            previous: V2PullCoordinate {
20586                head_seq: Some(1),
20587                commit_hash: Some("a".repeat(64)),
20588                view_kind: Some("full".to_string()),
20589                view_revision: Some("b".repeat(64)),
20590            },
20591            next: next.clone(),
20592            backup_dir: backup_dir.clone(),
20593            entries: vec![V2PullJournalEntry {
20594                path: "records/value.md".to_string(),
20595                old: Some(V2PullFileCoordinate {
20596                    sha256: "e".repeat(64),
20597                    bytes: new.len() as u64,
20598                }),
20599                new: Some(V2PullFileCoordinate {
20600                    sha256: content_sha256(new),
20601                    bytes: new.len() as u64,
20602                }),
20603                backup: Some("00000000".to_string()),
20604            }],
20605        };
20606        store
20607            .write_private_atomic_new(
20608                Path::new(V2_PULL_JOURNAL),
20609                &v2_pull_journal_bytes(&journal).unwrap(),
20610            )
20611            .unwrap();
20612        let cfg = test_hub_config(
20613            "https://example.test".to_string(),
20614            sandbox.path().join("state"),
20615        );
20616        save_v2_baseline(
20617            &cfg,
20618            TEST_BRAIN_ID,
20619            &root,
20620            &V2SyncBaseline {
20621                v: 2,
20622                origin: "https://example.test".to_string(),
20623                brain: TEST_BRAIN_ID.to_string(),
20624                checkout_id: Some("c".repeat(64)),
20625                head_seq: next.head_seq,
20626                commit_hash: next.commit_hash.clone(),
20627                content_root: Some("f".repeat(64)),
20628                asset_root: None,
20629                assets: Default::default(),
20630                view_kind: next.view_kind.clone(),
20631                view_revision: next.view_revision.clone(),
20632                control_revision: Some("d".repeat(64)),
20633                projection_sha256: None,
20634                files: Default::default(),
20635                scan_cache: Default::default(),
20636                local_policy_digest: None,
20637                local_eligibility: Default::default(),
20638                remote_copy_remains: Default::default(),
20639            },
20640        )
20641        .unwrap();
20642
20643        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20644
20645        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
20646        assert!(!root.join(V2_PULL_JOURNAL).exists());
20647        assert!(!root.join(backup_dir).exists());
20648        assert!(!root.join(orphan).exists());
20649    }
20650
20651    #[test]
20652    fn only_typed_validation_projection_lag_retries_v2_head() {
20653        let typed = HubResponse {
20654            status: 422,
20655            body: Some(json!({ "code": "validation_index_catching_up" })),
20656        };
20657        let nested = HubResponse {
20658            status: 422,
20659            body: Some(json!({
20660                "details": { "code": "validation_index_catching_up" }
20661            })),
20662        };
20663        let unrelated = HubResponse {
20664            status: 422,
20665            body: Some(json!({ "code": "source_immutable" })),
20666        };
20667        let wrong_status = HubResponse {
20668            status: 403,
20669            body: typed.body.clone(),
20670        };
20671        assert!(v2_validation_catching_up(&typed));
20672        assert!(v2_validation_catching_up(&nested));
20673        assert!(!v2_validation_catching_up(&unrelated));
20674        assert!(!v2_validation_catching_up(&wrong_status));
20675    }
20676
20677    #[test]
20678    fn asset_resolution_is_limited_to_explicitly_resolved_wrappers() {
20679        let wrapper = "records/operational/package.md".to_string();
20680        let mut resolution = std::collections::BTreeMap::new();
20681        resolution.insert(
20682            wrapper.clone(),
20683            V2ResolutionOverride {
20684                expected_remote: Some("a".repeat(64)),
20685                selected_local: Some("b".repeat(64)),
20686            },
20687        );
20688        let local = crate::AssetRecord {
20689            path: "sources/package/object.blob".to_string(),
20690            sha256: "c".repeat(64),
20691            bytes: 1,
20692            media_type: "application/octet-stream".to_string(),
20693            wrappers: vec![wrapper.clone()],
20694            required: true,
20695        };
20696        assert!(v2_resolution_allows_asset(
20697            Some(&resolution),
20698            None,
20699            None,
20700            Some(&local),
20701        ));
20702        let unrelated = crate::AssetRecord {
20703            wrappers: vec!["records/unrelated.md".to_string()],
20704            ..local
20705        };
20706        assert!(!v2_resolution_allows_asset(
20707            Some(&resolution),
20708            None,
20709            None,
20710            Some(&unrelated),
20711        ));
20712        assert!(!v2_resolution_allows_asset(
20713            None,
20714            None,
20715            None,
20716            Some(&unrelated),
20717        ));
20718    }
20719}