Skip to main content

dbmd_core/
linkmd.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The **link.md client** — the five interconnect verbs `dbmd` speaks against
4//! a hub: `resolve`, `sync`, `grant`, `propose`, `subscribe`.
5//!
6//! One binary, two specs (the git precedent: one binary carries both the
7//! object format and the wire protocol). The db.md FORMAT is untouched by this
8//! module: a store never needs link.md to be valid db.md, record files stay
9//! plain markdown, and SPEC.md reserves only the `@brain/id` address *shape*.
10//! Everything with a wire or a trust boundary — addressing across stores,
11//! pulling/pushing a hosted copy, capability grants, the propose door, feed
12//! polling and signed-entry verification — lives here, as a *client
13//! capability*, never a format requirement.
14//!
15//! # What this client speaks
16//!
17//! The v0 HTTP binding a hub serves under its base URL:
18//!
19//! | verb | binding |
20//! | --- | --- |
21//! | `resolve` | `GET /api/hub/brains/<brain>` (the brain card); records are read from the exact signed snapshot pack named by that card's verified feed head |
22//! | `sync` (pull) | `GET /api/hub/brains/<brain>/export?format=pack&atSeq=<n>&feedHash=<hash>` — the exact verified snapshot |
23//! | `sync` (push) | `POST /api/hub/brains/<brain>/push` for small snapshots; presign/upload/commit for large snapshots |
24//! | `grant` | `GET` / `POST /api/hub/brains/<brain>/grants`, `DELETE /api/hub/brains/<brain>/grants/<id>` |
25//! | `propose` | `POST /api/hub/sites/<handle>/inbox` — evidence in, without trust (unauthenticated by design) |
26//! | `subscribe` | `GET /api/hub/brains/<brain>` + `/feed` for a locally verified signed head |
27//!
28//! # Configuration — no default hub, credential never in the store
29//!
30//! There is **no built-in hub endpoint**: the toolkit is neutral and a hub is
31//! whatever the user points it at. Resolution order for the hub URL:
32//!
33//! 1. the `--hub <URL>` flag,
34//! 2. the `DBMD_HUB_URL` environment variable,
35//! 3. the `hub = <URL>` line in the store-local `.dbmd/config` file
36//!    (toolkit state, not store content — the walkers already skip hidden
37//!    directories, so `.dbmd/` never syncs, indexes, or validates).
38//!
39//! The credential is the `DBMD_HUB_KEY` environment variable, full stop. It is
40//! deliberately **not** read from `.dbmd/config`: a secret inside the store
41//! tree is one commit or one push away from leaking. A store-selected hub may
42//! receive an ambient bearer or agent key only when
43//! `DBMD_HUB_CREDENTIAL_ORIGIN` binds it to that exact origin. Identity pins
44//! and monotonic feed checkpoints live in the user's global state directory,
45//! never in store-controlled `.dbmd/`.
46//!
47//! Non-HTTPS hubs are refused (the bearer key must never travel in cleartext)
48//! with a loopback exemption for local development.
49//!
50//! # v0 honesty
51//!
52//! This client binds to what a hub enforces **today**: grantees are hub
53//! principals (an email), grant scopes are store-path prefixes, pushes are
54//! whole-store snapshots, and `subscribe` reports feed-head movement. The hub
55//! signs each committed snapshot in a hash-chained feed with a per-brain
56//! Ed25519 identity. This client verifies the rotation chain, signer epochs,
57//! monotonic feed checkpoint, snapshot token, and content-addressed pack before
58//! untrusted bytes touch their destination.
59
60use std::collections::{BTreeMap, BTreeSet};
61use std::io::{Cursor, Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{SystemTime, UNIX_EPOCH};
64
65use base64::{
66    engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
67    Engine as _,
68};
69use ring::signature::{UnparsedPublicKey, ED25519};
70use serde::{Deserialize, Serialize};
71use serde_json::{json, Value};
72use sha2::{Digest, Sha256};
73
74use crate::store::Store;
75
76/// Environment variable naming the hub base URL (e.g. `https://hub.example.com`).
77pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
78
79/// Environment variable carrying the hub bearer credential. The bearer
80/// credential source — see the module docs for why it is never store-based.
81pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
82
83/// Explicit origin binding required before an ambient bearer or agent key may
84/// be sent to a hub selected by untrusted store-local configuration. The value
85/// is an origin (`https://host[:port]`), not an arbitrary URL path.
86pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
87
88/// Override for dbmd's user-owned trust/checkpoint root. Must be absolute.
89/// Intended for managed installations and hermetic tests; untrusted stores
90/// cannot select it.
91pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
92
93/// Explicit test/development escape hatch for registry homes on loopback or
94/// private networks. Production federation rejects every non-public resolved
95/// address and pins the validated DNS answer into the HTTP agent.
96pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
97
98/// Explicit development escape hatch for object-store URLs on loopback/private
99/// networks. Production hubs must return public HTTPS URLs. A local loopback hub
100/// is allowed to use local object URLs without this switch.
101pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
102
103/// Environment variable naming the PATH of a self-custodied BRAIN key file
104/// (link.md §2.4). When set, `sync --push` signs each feed entry locally and
105/// ships it through the pack flow — the hub verifies and stores the exact
106/// client bytes and can never sign for the brain. Same file format as agent
107/// keys (`dbmd key generate`).
108pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
109
110/// Environment variable naming the PATH of an agent signing key file
111/// (link.md §8 `LinkMD-Sig` proof of possession). When set, authenticated
112/// requests are signed per-request with the agent's Ed25519 key instead of
113/// carrying a bearer: the signature binds method + path + body + a ±60s
114/// window, so nothing reusable ever crosses the wire or lands in a log or an
115/// agent transcript. The file holds the base64url PKCS#8 key minted by
116/// `dbmd key generate`; the path is not a secret, the file is (mode 0600).
117pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
118
119/// The store-local config file, relative to the store root. Holds non-secret
120/// toolkit state (`hub = <URL>`); hidden, so every store walk skips it.
121pub const CONFIG_REL_PATH: &str = ".dbmd/config";
122
123/// Control-plane JSON is metadata, never the store itself. Exact snapshot
124/// bytes travel through the separately bounded pack lane.
125const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
126/// A feed page can legitimately carry a large snapshot manifest, but remains
127/// far below a full pack and is parsed with count-bounded sequence visitors.
128const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
129/// Foreign registry cards are identity metadata only.
130const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
131
132/// Direct JSON pushes stay below the serverless request-body cap. Larger
133/// snapshots switch to the bounded object-store pack lane.
134const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
135/// Ceiling for a staged change manifest. Mirrors the hub's bound so an
136/// oversized push is refused before it stages anything.
137const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
138
139/// Canonical raw ZIP32 uses a u16 entry count and stores each UTF-8 name twice.
140const MAX_PUSH_FILES: usize = u16::MAX as usize;
141const MAX_STORE_PATH_BYTES: usize = 1_024;
142const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
143/// Assets travel outside the Markdown store/pack lane and have their own
144/// protocol ceiling. Keep this distinct from `MAX_STORE_BYTES`: applying the
145/// pack bound to a signed asset leaf makes an otherwise valid checkout unable
146/// to load its own saved sync baseline after the first commit.
147const MAX_ASSET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
148/// Exact worst case for the canonical STORED profile:
149/// payload + (local 30 + central 46 + name twice) per entry + EOCD 22.
150const MAX_PACK_BYTES: u64 =
151    MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
152/// An upload reservation body carries one entry per changed blob, so a real
153/// store's first push does not fit in a single request: the hub bounds the body
154/// and the hosting platform rejects an oversized payload before the route sees
155/// it. Reservations are therefore batched. The budget is deliberately well
156/// under the hub's own ceiling, and both bounds are enforced because a single
157/// hash can carry many coordinates and a path may be long.
158/// The count matches the reservation window a hub grants per request; batching
159/// above it only fails deeper in, after the body has already been accepted.
160const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
161const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
162
163/// A legitimate brain should rotate rarely. Bound adversarial identity
164/// histories before allocating and repeatedly verifying an unbounded chain.
165const MAX_IDENTITY_ROTATIONS: usize = 1_024;
166
167/// Split blob declarations into request-sized batches, by serialized size and
168/// by count. Order is preserved so a caller can concatenate the responses.
169fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
170    let mut batches: Vec<Vec<Value>> = Vec::new();
171    let mut current: Vec<Value> = Vec::new();
172    let mut current_bytes = 0usize;
173    for declaration in declarations {
174        let declared_bytes = serde_json::to_string(&declaration)
175            .map(|text| text.len())
176            .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
177            + 1;
178        if !current.is_empty()
179            && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
180                || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
181        {
182            batches.push(std::mem::take(&mut current));
183            current_bytes = 0;
184        }
185        current_bytes += declared_bytes;
186        current.push(declaration);
187    }
188    if !current.is_empty() {
189        batches.push(current);
190    }
191    batches
192}
193/// A client never needs to replay an unbounded feed in one invocation. Large
194/// histories are mirrored incrementally; a first mirror beyond this cap needs a
195/// checkpoint/export rather than allocating attacker-controlled metadata.
196const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
197const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
198const FEED_PAGE_LIMIT: usize = 100;
199
200/// The hub's inbox cap on one `propose` submission body, mirrored client-side
201/// so an oversized body fails before the upload, not after (the same
202/// fail-before-upload contract as the push caps). Public so the CLI can
203/// pre-check a `--body-file` from file metadata without reading it.
204pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
205
206/// Bounded connect so a dead hub fails fast; a generous read window so a
207/// large export on a slow link still completes.
208const CONNECT_TIMEOUT_SECS: u64 = 10;
209const READ_TIMEOUT_SECS: u64 = 120;
210/// Hard wall-clock budget for one HTTP attempt, spanning socket writes,
211/// response headers, and the complete response body. DNS performed for pinned
212/// agents has its own interruptible deadline below.
213const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
214/// A commit of a whole store is brain-sized work the hub is allowed to spend
215/// its full route budget on, so the client has to outwait the server rather
216/// than the other way round. At the default 120 seconds a first push of a real
217/// store abandoned a commit the hub was still completing and reported a
218/// transport failure for work that had not failed. This sits above the hub's
219/// own genesis-commit ceiling so the client always learns the outcome.
220const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
221/// A commit is idempotent by mutation coordinate, so a lost response is worth
222/// asking after — and the work it lost the receipt for may still be finishing,
223/// which is why the waits are long enough to let a brain-sized commit land.
224const COMMIT_ATTEMPTS: usize = 4;
225const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
226const CONNECT_ATTEMPTS: usize = 3;
227const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
228/// Safe reads are idempotent, including authenticated GETs and the explicitly
229/// marked read-only POSTs whose proof lists do not fit in a URL. Retry the
230/// whole read when a connection dies after headers or part-way through its
231/// body; connect-only retries cannot recover that common large-export failure.
232const SAFE_READ_ATTEMPTS: usize = 4;
233const SAFE_READ_RETRY_BACKOFF_MS: [u64; SAFE_READ_ATTEMPTS - 1] = [200, 1_000, 3_000];
234
235/// Staging a real store means tens of thousands of immutable object PUTs in a
236/// row. A single refusal under sustained load must not fail the migration, so
237/// the upload lane retries further and waits longer than a connect blip does.
238const UPLOAD_ATTEMPTS: usize = 6;
239const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
240/// Retries share one wall-clock budget. Without this outer deadline, one
241/// black-holed object can consume the per-attempt timeout six times, outlive
242/// its presigned capability, and make a large migration appear hung.
243const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
244
245fn upload_retry_backoff_ms(attempt: usize) -> u64 {
246    UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
247}
248
249fn upload_deadline_error() -> LinkError {
250    LinkError::Transport {
251        hub: "the object store".to_string(),
252        message: "network error (upload deadline exceeded)".to_string(),
253    }
254}
255
256fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
257    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
258    if remaining.is_zero() {
259        return Err(upload_deadline_error());
260    }
261    Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
262}
263
264fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
265    if attempt + 1 >= UPLOAD_ATTEMPTS {
266        return false;
267    }
268    let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
269    if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
270        return false;
271    }
272    std::thread::sleep(pause);
273    true
274}
275
276/// A migration opens dozens of staging windows back to back, so it is ordinary
277/// for a shared limiter or a briefly busy hub to answer one of them with 429 or
278/// 5xx. Failing the push there throws away every byte already staged, and the
279/// waits have to reach past a whole limiter window to be worth anything.
280const RESERVATION_ATTEMPTS: usize = 7;
281const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
282    [500, 2_000, 5_000, 15_000, 30_000, 60_000];
283
284/// Answers a busy or briefly unhealthy hub gives, as opposed to a refusal that
285/// would repeat identically. A 4xx other than these states something about the
286/// request itself and is never retried.
287fn is_retryable_hub_status(status: u16) -> bool {
288    matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
289}
290
291/// Statuses worth replaying an immutable, content-addressed upload for: edge
292/// and object-store refusals that are load-shaped rather than a statement about
293/// these bytes. A checksum or precondition failure is never retried.
294fn is_retryable_upload_status(status: u16) -> bool {
295    matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
296}
297/// Cold checkouts may need every visible blob, but short-lived direct object
298/// capabilities must not create an unbounded connection storm. Keep the
299/// verified downloads inside this fixed worker count.
300const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
301/// File durability barriers are independent inside the private pull stage.
302/// Keep enough in flight to hide storage flush latency without approaching
303/// ordinary process descriptor limits.
304#[cfg(unix)]
305const V2_PULL_INSTALL_WORKERS: usize = 16;
306/// One authenticated link.md v2 bulk frame remains small enough for bounded
307/// retries and serverless streaming while amortizing tens of thousands of
308/// immutable blobs over a few hundred requests instead of one request each.
309const V2_BULK_STREAM_FILES: usize = 256;
310const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
311const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
312const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
313/// A read capability lives for five minutes. Ask only for the files the
314/// bounded worker pool can start immediately; collecting hundreds of signed
315/// asset URLs before consuming the first one deterministically expires the
316/// oldest capabilities on a company-sized brain.
317const V2_DOWNLOAD_CAPABILITY_FILES: usize = V2_BLOB_DOWNLOAD_WORKERS;
318const V2_DOWNLOAD_CAPABILITY_BYTES: u64 = 512 * 1024 * 1024;
319const V2_DOWNLOAD_CAPABILITY_ATTEMPTS: usize = 4;
320const V2_DOWNLOAD_CAPABILITY_BACKOFF_MS: [u64; V2_DOWNLOAD_CAPABILITY_ATTEMPTS - 1] =
321    [200, 1_000, 3_000];
322
323/// Everything that can go wrong on the wire or at its edges. Each variant maps
324/// onto one stable CLI error code; messages are single-line and never echo the
325/// credential.
326#[derive(Debug, thiserror::Error)]
327pub enum LinkError {
328    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
329    #[error(
330        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
331    )]
332    NoHub,
333
334    /// The verb needs a credential and none was present.
335    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
336    NoCredential,
337
338    /// The credential contains whitespace / non-ASCII (a paste artifact). The
339    /// key is deliberately not echoed.
340    #[error(
341        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
342    )]
343    BadKey,
344
345    /// A store selected the destination while an ambient credential was
346    /// present, but the operator did not bind that credential to the same
347    /// origin. This is a hard refusal, not an anonymous fallback: silently
348    /// dropping a credential can turn an intended private operation into a
349    /// confusing public one.
350    #[error(
351        "refusing to send an ambient credential to the hub selected by {CONFIG_REL_PATH} — set {HUB_CREDENTIAL_ORIGIN_ENV} to that exact origin, or choose the hub explicitly with --hub/{HUB_URL_ENV}"
352    )]
353    UnboundCredential,
354
355    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
356    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
357    /// echoed.
358    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
359    BadAgentKey {
360        /// What failed, without any key material.
361        message: String,
362    },
363
364    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
365    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
366    UnsafeHub {
367        /// The offending hub URL.
368        hub: String,
369    },
370
371    /// TCP/TLS-level failure: the hub never answered.
372    #[error("hub unreachable at {hub}: {message}")]
373    Transport {
374        /// The hub base URL.
375        hub: String,
376        /// The transport-layer error text.
377        message: String,
378    },
379
380    /// The hub answered with an HTTP error status.
381    #[error("{what} failed (HTTP {status}): {message}")]
382    Http {
383        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
384        what: &'static str,
385        /// The HTTP status code.
386        status: u16,
387        /// The hub's own `error` string when it sent one, else a placeholder.
388        message: String,
389        /// The hub's machine `code` field when it sent one.
390        code: Option<String>,
391        /// Permission-filtered structured refusal details, when supplied.
392        details: Option<Value>,
393    },
394
395    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
396    /// URL — refused here rather than deserializing into nothing downstream.
397    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
398    NotJson {
399        /// What the client was doing.
400        what: &'static str,
401        /// The (2xx) status that carried the non-JSON body.
402        status: u16,
403    },
404
405    /// The hub response exceeded the selected endpoint's byte cap.
406    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
407    ResponseTooLarge {
408        /// The endpoint-specific cap applied before JSON parsing.
409        limit_bytes: u64,
410    },
411
412    /// A malformed `@brain/id` address.
413    #[error("invalid address `{given}`: {reason}")]
414    BadAddress {
415        /// The raw address as typed.
416        given: String,
417        /// Why it did not parse.
418        reason: String,
419    },
420
421    /// A grant id whose shape cannot travel as a URL path segment.
422    #[error(
423        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
424    )]
425    BadGrantId {
426        /// The raw id as typed.
427        given: String,
428    },
429
430    /// An exported file path that would escape or pollute the destination
431    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
432    /// hub is not trusted with local path layout.
433    #[error("refusing unsafe path from the hub: `{path}`")]
434    UnsafePath {
435        /// The offending path as received.
436        path: String,
437    },
438
439    /// The store exceeds the hub's bounded whole-snapshot caps.
440    #[error(
441        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
442        MAX_STORE_BYTES / (1024 * 1024),
443        MAX_PACK_BYTES / (1024 * 1024)
444    )]
445    PushTooLarge {
446        /// Which cap was hit, human-readable.
447        detail: String,
448    },
449
450    /// The propose body exceeds the hub's inbox cap.
451    #[error(
452        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
453        MAX_PROPOSE_BYTES / 1024
454    )]
455    ProposeTooLarge {
456        /// The offending body size in bytes.
457        bytes: u64,
458    },
459
460    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
461    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
462    NotUtf8 {
463        /// The store-relative path of the offending file.
464        path: String,
465    },
466
467    /// A downloaded pack failed validation before any local write.
468    #[error("invalid store pack: {message}")]
469    InvalidPack {
470        /// Hash, ZIP, path, count, or expansion failure.
471        message: String,
472    },
473
474    /// A signed feed entry, hash chain, or advertised feed head did not verify.
475    #[error("invalid signed feed: {message}")]
476    InvalidFeed {
477        /// The failed integrity condition, without untrusted secret material.
478        message: String,
479    },
480
481    /// A mutable alias moved to another canonical brain. Ordinary sync fails
482    /// closed; only the exact old/new ids can authorize replacing that one
483    /// alias binding while preserving both canonical trust histories.
484    #[error(
485        "brain alias `{alias}` was pinned to `{from}` but now resolves to `{to}` — review both ids, then run `dbmd sync {alias} rebind --from {from} --to {to}`"
486    )]
487    AliasRebindRequired {
488        alias: String,
489        from: String,
490        to: String,
491    },
492
493    /// Local and remote both changed one or more coordinates since the last
494    /// verified sync baseline. No side was overwritten.
495    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
496    Conflict {
497        /// Bounded, portable paths safe to show to the agent/operator.
498        paths: Vec<String>,
499    },
500
501    /// A readable v2 conflict was preserved as a private, exact-head bundle.
502    /// The bundle is local control state; none of its bytes ride with the
503    /// brain, and resolving it always creates a fresh explicit mutation.
504    #[error(
505        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
506    )]
507    ConflictBundle {
508        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
509        bundle: String,
510        /// Bounded, readable conflict paths.
511        paths: Vec<String>,
512    },
513
514    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
515    /// explicit adoption boundary, never an accidental side effect of editing
516    /// or removing a local policy file.
517    #[error(
518        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
519    )]
520    LocalPolicyTransition {
521        /// Bounded local-only paths. They are never sent in telemetry.
522        paths: Vec<String>,
523    },
524
525    /// The exact mutation crosses a permissioned bulk-impact boundary. The
526    /// hub has not committed it; `preview` is the bounded, permission-filtered
527    /// receipt an agent must inspect before explicitly confirming the same
528    /// request.
529    #[error(
530        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
531    )]
532    BulkPreviewRequired {
533        /// Stable structured preview returned by the hub.
534        preview: Value,
535    },
536
537    /// A scoped checkout's generated store marker was edited or removed.
538    /// It is local projection metadata and is never accepted as brain data.
539    #[error(
540        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
541    )]
542    ScopedProjectionModified,
543
544    /// The effective permission slice changed after this checkout was pinned.
545    /// Reusing the directory could conceal removals or accidentally adopt files
546    /// revealed by a wider grant, so a new checkout is required.
547    #[error(
548        "the checkout's permission scope changed — clone into a new directory to accept the new view"
549    )]
550    ScopedViewChanged,
551
552    /// A v2 identity/head was previously accepted for this ref, but the hub
553    /// now hides it. Never reinterpret that as a v1 downgrade.
554    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
555    BrainUnavailable,
556
557    /// The verified remote head advanced while a pull or post-commit barrier
558    /// was in flight. The old baseline is deliberately retained.
559    #[error(
560        "the remote brain advanced during sync — retry to converge from the new verified head"
561    )]
562    RemoteAdvancedDuringSync,
563
564    /// This build cannot provide the no-follow, directory-handle-relative
565    /// filesystem semantics required for trust/key/snapshot state.
566    #[error(
567        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
568    )]
569    UnsupportedPlatform {
570        /// The operation that requires hardened local filesystem primitives.
571        operation: &'static str,
572    },
573
574    /// Local filesystem failure while materializing a pull or reading a push.
575    #[error(transparent)]
576    Io(#[from] std::io::Error),
577
578    /// A store-level failure (walking the local store for a push).
579    #[error(transparent)]
580    Store(#[from] crate::StoreError),
581}
582
583/// Result alias for link.md client operations.
584pub type LinkResult<T> = std::result::Result<T, LinkError>;
585
586/// Exact approval token returned by a permissioned v2 bulk preview.
587#[derive(Debug, Clone, PartialEq, Eq)]
588pub struct V2BulkConfirmation {
589    /// Opaque preview receipt id.
590    pub id: String,
591    /// Digest binding the receipt to principal, head, control revision, and
592    /// the exact strict mutation.
593    pub digest: String,
594}
595
596impl V2BulkConfirmation {
597    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
598    /// outside the protocol's exact lowercase shapes before network I/O.
599    pub fn parse(value: &str) -> LinkResult<Self> {
600        let (id, digest) = value
601            .split_once(':')
602            .ok_or_else(|| LinkError::InvalidPack {
603                message: "bulk confirmation must be <id>:<digest>".to_string(),
604            })?;
605        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
606            return Err(LinkError::InvalidPack {
607                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
608                    .to_string(),
609            });
610        }
611        Ok(Self {
612            id: id.to_string(),
613            digest: digest.to_string(),
614        })
615    }
616}
617
618/// Security-sensitive link.md state and destination writes require a hardened
619/// descriptor/handle-relative filesystem implementation. Unix uses
620/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
621/// refusal, exclusive share modes, and write-through replacement.
622fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
623    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
624    {
625        let _ = operation;
626        Ok(())
627    }
628    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
629    {
630        Err(LinkError::UnsupportedPlatform { operation })
631    }
632}
633
634// ─────────────────────────────────────────────────────────────────────────────
635// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
636// ─────────────────────────────────────────────────────────────────────────────
637
638/// What the part after `@brain/` names.
639#[derive(Debug, Clone, PartialEq, Eq)]
640pub enum AddressTarget {
641    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
642    Id(String),
643    /// A store-relative `.md` path — a client-side convenience the hub's
644    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
645    /// shape; unambiguous because a ULID is never a path.
646    Path(String),
647}
648
649/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
650/// and the per-verb entry gates so the two surfaces never drift.
651const BAD_BRAIN_REASON: &str =
652    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
653
654/// Why an address target failed its shape check — shared by [`Address::parse`]
655/// and the [`resolve`] entry gate.
656const BAD_TARGET_REASON: &str =
657    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
658
659/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
660/// brain's ULID id (works for any caller, including cross-party on a public
661/// brain) or a slug (which a hub resolves only against the caller's own
662/// brains; slugs are unique per owner, not globally).
663#[derive(Debug, Clone, PartialEq, Eq)]
664pub struct Address {
665    /// The brain reference (leading `@` stripped).
666    pub brain: String,
667    /// The record target, when the address names one.
668    pub target: Option<AddressTarget>,
669}
670
671impl Address {
672    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
673    /// sigil is optional (an agent piping ids around should not have to quote
674    /// it back on). Whitespace and empty segments are malformed.
675    pub fn parse(raw: &str) -> LinkResult<Address> {
676        let bad = |reason: &str| LinkError::BadAddress {
677            given: raw.to_string(),
678            reason: reason.to_string(),
679        };
680
681        let trimmed = raw.trim();
682        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
683        if body.is_empty() {
684            return Err(bad("empty address"));
685        }
686
687        let (brain, rest) = match body.split_once('/') {
688            Some((b, r)) => (b, Some(r)),
689            None => (body, None),
690        };
691
692        if brain.is_empty() {
693            return Err(bad("missing brain reference before `/`"));
694        }
695        if !is_safe_ref(brain) {
696            return Err(bad(BAD_BRAIN_REASON));
697        }
698
699        let target = match rest {
700            None => None,
701            Some("") => return Err(bad("trailing `/` with no record id or path")),
702            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
703            Some(r) => {
704                if !safe_store_rel_path(r) || !r.ends_with(".md") {
705                    return Err(bad(BAD_TARGET_REASON));
706                }
707                Some(AddressTarget::Path(r.to_string()))
708            }
709        };
710
711        Ok(Address {
712            brain: brain.to_string(),
713            target,
714        })
715    }
716}
717
718/// A brain reference safe to embed in a URL path segment: the shapes a hub
719/// accepts (ULID id or slug), which are also exactly URL-path-clean.
720fn is_safe_ref(s: &str) -> bool {
721    !s.is_empty()
722        && s.len() <= 64
723        && s.bytes()
724            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
725}
726
727/// A published-site handle (the `propose` target). Same lexical shape as a
728/// slug.
729pub fn is_valid_handle(s: &str) -> bool {
730    is_safe_ref(s)
731}
732
733/// True when `p` is a store-relative path this client will read from or write
734/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
735/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
736/// every path an export hands us (the hub is not trusted with local layout)
737/// and to every path a push sends (mirroring the hub's own gate).
738pub fn safe_store_rel_path(p: &str) -> bool {
739    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
740        return false;
741    }
742    if !p
743        .bytes()
744        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
745    {
746        return false;
747    }
748    p.split('/')
749        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
750}
751
752/// Entry gate for every verb that embeds a caller-supplied brain reference in
753/// a URL path segment. `resolve` reaches the same check through
754/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
755/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
756/// URL-reshaping byte is refused before a request exists (the `url` crate
757/// normalizes dot segments, so an unvalidated ref would redirect the
758/// authenticated request to a different hub path).
759fn require_safe_ref(brain: &str) -> LinkResult<()> {
760    if is_safe_ref(brain) {
761        Ok(())
762    } else {
763        Err(LinkError::BadAddress {
764            given: brain.to_string(),
765            reason: BAD_BRAIN_REASON.to_string(),
766        })
767    }
768}
769
770/// Entry gate for the published-site handle `propose` embeds in its URL path.
771fn require_valid_handle(handle: &str) -> LinkResult<()> {
772    if is_valid_handle(handle) {
773        Ok(())
774    } else {
775        Err(LinkError::BadAddress {
776            given: handle.to_string(),
777            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
778        })
779    }
780}
781
782/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
783/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
784/// shape as a brain ref rather than pinning one mint scheme.
785fn require_safe_grant_id(id: &str) -> LinkResult<()> {
786    if is_safe_ref(id) {
787        Ok(())
788    } else {
789        Err(LinkError::BadGrantId {
790            given: id.to_string(),
791        })
792    }
793}
794
795// ─────────────────────────────────────────────────────────────────────────────
796// Configuration — flag > env > .dbmd/config; credential from env only
797// ─────────────────────────────────────────────────────────────────────────────
798
799/// The resolved client configuration for one invocation.
800#[derive(Debug, Clone)]
801pub struct HubConfig {
802    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
803    pub hub: String,
804    /// The bearer credential, when the environment carries one.
805    pub key: Option<String>,
806    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
807    /// over the bearer for authenticated requests (link.md §8).
808    pub agent_key: Option<AgentSigningKey>,
809    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
810    /// names one — `sync --push` then signs feed entries locally (§2.4).
811    pub brain_key: Option<AgentSigningKey>,
812    /// User-owned global toolkit state root. Identity pins and monotonic feed
813    /// checkpoints live below `<state_dir>/trust/`, never under a store.
814    pub state_dir: PathBuf,
815    /// True only when the origin came from untrusted store-local configuration.
816    /// Such origins are resolved and public-IP-pinned for every request.
817    store_selected: bool,
818}
819
820/// A loaded agent signing key: the PKCS#8 secret plus its derived public
821/// multikey. Debug never prints key material.
822#[derive(Clone)]
823pub struct AgentSigningKey {
824    pkcs8: Vec<u8>,
825    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
826    pub multikey: String,
827    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
828    pub public_key_spki: String,
829}
830
831impl std::fmt::Debug for AgentSigningKey {
832    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
833        f.debug_struct("AgentSigningKey")
834            .field("multikey", &self.multikey)
835            .field("pkcs8", &"<redacted>")
836            .finish()
837    }
838}
839
840impl HubConfig {
841    /// The credential, or the canonical "not configured" error. Verbs that
842    /// authenticate call this; `propose` never does.
843    pub fn require_key(&self) -> LinkResult<&str> {
844        self.key.as_deref().ok_or(LinkError::NoCredential)
845    }
846}
847
848/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
849/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
850/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
851/// header token (never echoed on failure).
852pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
853    let explicit_hub = flag_hub
854        .map(str::to_string)
855        .or_else(|| env_nonempty(HUB_URL_ENV));
856    let selected_by_store = explicit_hub.is_none();
857    let hub = explicit_hub
858        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
859        .ok_or(LinkError::NoHub)?;
860    let hub = hub.trim().trim_end_matches('/').to_string();
861    assert_safe_hub(&hub)?;
862    if selected_by_store {
863        let parsed =
864            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
865        // A cloned store is not an operator opt-in to local-network access.
866        // Local/private development hubs must be selected explicitly by flag or
867        // environment, never by bytes inside the store.
868        if !parsed.scheme().eq_ignore_ascii_case("https")
869            || (parsed.path() != "/" && !parsed.path().is_empty())
870        {
871            return Err(LinkError::UnsafeHub { hub });
872        }
873    }
874
875    let key = match env_nonempty(HUB_KEY_ENV) {
876        Some(raw) => Some(clean_key(&raw)?),
877        None => None,
878    };
879
880    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
881        Some(path) => Some(load_agent_key(Path::new(&path))?),
882        None => None,
883    };
884
885    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
886        Some(path) => Some(load_agent_key(Path::new(&path))?),
887        None => None,
888    };
889
890    // A cloned store controls `.dbmd/config`. It must never be able to point
891    // the process at an attacker origin and harvest the user's ambient account
892    // bearer, signed agent identity, or self-custodied brain signatures/content.
893    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
894    // in the invocation environment; a store-selected target additionally
895    // needs an exact origin binding.
896    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
897        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
898            .and_then(|value| normalized_origin(&value).ok());
899        let selected_origin = normalized_origin(&hub)?;
900        if bound.as_deref() != Some(selected_origin.as_str()) {
901            return Err(LinkError::UnboundCredential);
902        }
903    }
904
905    Ok(HubConfig {
906        hub,
907        key,
908        agent_key,
909        brain_key,
910        state_dir: toolkit_state_dir()?,
911        store_selected: selected_by_store,
912    })
913}
914
915fn toolkit_state_dir() -> LinkResult<PathBuf> {
916    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
917        let path = PathBuf::from(path);
918        if !path.is_absolute() {
919            return Err(LinkError::UnsafePath {
920                path: path.display().to_string(),
921            });
922        }
923        return Ok(path);
924    }
925    #[cfg(windows)]
926    if let Some(base) = env_nonempty("LOCALAPPDATA") {
927        let base = PathBuf::from(base);
928        if base.is_absolute() {
929            return Ok(base.join("dbmd").join("state"));
930        }
931    }
932    #[cfg(windows)]
933    {
934        Err(LinkError::Io(std::io::Error::new(
935            std::io::ErrorKind::NotFound,
936            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
937        )))
938    }
939    #[cfg(not(windows))]
940    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
941        let base = PathBuf::from(base);
942        if base.is_absolute() {
943            return Ok(base.join("dbmd"));
944        }
945    }
946    #[cfg(not(windows))]
947    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
948        LinkError::Io(std::io::Error::new(
949            std::io::ErrorKind::NotFound,
950            format!("cannot locate user state; set {STATE_DIR_ENV}"),
951        ))
952    })?);
953    #[cfg(not(windows))]
954    if !home.is_absolute() {
955        return Err(LinkError::UnsafePath {
956            path: home.display().to_string(),
957        });
958    }
959    #[cfg(target_os = "macos")]
960    {
961        Ok(home
962            .join("Library")
963            .join("Application Support")
964            .join("dbmd")
965            .join("state"))
966    }
967    #[cfg(all(not(target_os = "macos"), not(windows)))]
968    {
969        Ok(home.join(".local").join("state").join("dbmd"))
970    }
971}
972
973fn normalized_origin(value: &str) -> LinkResult<String> {
974    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
975        hub: value.to_string(),
976    })?;
977    if !(parsed.scheme().eq_ignore_ascii_case("https")
978        || parsed.scheme().eq_ignore_ascii_case("http"))
979        || !parsed.username().is_empty()
980        || parsed.password().is_some()
981        || (parsed.path() != "/" && !parsed.path().is_empty())
982        || parsed.query().is_some()
983        || parsed.fragment().is_some()
984    {
985        return Err(LinkError::UnsafeHub {
986            hub: value.to_string(),
987        });
988    }
989    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
990        hub: value.to_string(),
991    })?;
992    let host = if host.contains(':') {
993        format!("[{host}]")
994    } else {
995        host.to_ascii_lowercase()
996    };
997    let port = parsed
998        .port_or_known_default()
999        .ok_or_else(|| LinkError::UnsafeHub {
1000            hub: value.to_string(),
1001        })?;
1002    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
1003        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
1004    Ok(format!(
1005        "{}://{}{}",
1006        parsed.scheme().to_ascii_lowercase(),
1007        host,
1008        if default {
1009            String::new()
1010        } else {
1011            format!(":{port}")
1012        }
1013    ))
1014}
1015
1016// ─────────────────────────────────────────────────────────────────────────────
1017// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
1018// ─────────────────────────────────────────────────────────────────────────────
1019
1020/// The DER prefix that wraps a raw Ed25519 public key into a
1021/// SubjectPublicKeyInfo (RFC 8410).
1022const ED25519_SPKI_PREFIX: [u8; 12] = [
1023    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1024];
1025
1026fn bad_agent_key(message: &str) -> LinkError {
1027    LinkError::BadAgentKey {
1028        message: message.to_string(),
1029    }
1030}
1031
1032fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1033    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
1034    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
1035    // deriving the public half itself.
1036    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1037        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1038        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1039}
1040
1041/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
1042fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1043    use ring::signature::KeyPair as _;
1044    let mut spki = Vec::with_capacity(44);
1045    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1046    spki.extend_from_slice(pair.public_key().as_ref());
1047    (
1048        URL_SAFE_NO_PAD.encode(&spki),
1049        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1050    )
1051}
1052
1053/// Load and validate a signing-key file (agent or brain — same format):
1054/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
1055/// old key explicitly.
1056pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1057    load_agent_key(path)
1058}
1059
1060/// Load and validate the agent key file: one base64url line of PKCS#8.
1061fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1062    #[cfg(unix)]
1063    let file = {
1064        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1065        use std::os::unix::ffi::OsStrExt as _;
1066        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1067            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1068        let leaf = path
1069            .file_name()
1070            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1071        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1072        let fd = unsafe {
1073            libc::openat(
1074                parent.as_raw_fd(),
1075                leaf.as_ptr(),
1076                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1077            )
1078        };
1079        if fd < 0 {
1080            return Err(bad_agent_key(
1081                "the key path must be an existing regular file without symlink ancestors",
1082            ));
1083        }
1084        unsafe { std::fs::File::from_raw_fd(fd) }
1085    };
1086    #[cfg(not(unix))]
1087    let file = std::fs::File::open(path)
1088        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1089    let metadata = file
1090        .metadata()
1091        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1092    if !metadata.is_file() {
1093        return Err(bad_agent_key("the key path must be a regular file"));
1094    }
1095    #[cfg(unix)]
1096    {
1097        use std::os::unix::fs::PermissionsExt as _;
1098        if metadata.permissions().mode() & 0o077 != 0 {
1099            return Err(bad_agent_key(
1100                "the key file is accessible to group/other; set mode 0600",
1101            ));
1102        }
1103    }
1104    let mut text = String::new();
1105    file.take(1024 * 1024 + 1)
1106        .read_to_string(&mut text)
1107        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1108    if text.len() > 1024 * 1024 {
1109        return Err(bad_agent_key("the key file exceeds the size limit"));
1110    }
1111    let pkcs8 = URL_SAFE_NO_PAD
1112        .decode(text.trim())
1113        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1114    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1115    Ok(AgentSigningKey {
1116        pkcs8,
1117        multikey,
1118        public_key_spki,
1119    })
1120}
1121
1122/// Durably create a new secret file without an exists→write race and without
1123/// ever exposing a default-mode (commonly 0644) key between write and chmod.
1124/// `create_new` also refuses a planted symlink. The file and its parent
1125/// directory are synced before the caller can publish the corresponding
1126/// public identity remotely.
1127fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1128    #[cfg(unix)]
1129    let (mut file, parent, leaf) = {
1130        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1131        use std::os::unix::ffi::OsStrExt as _;
1132        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1133        let leaf_name = path
1134            .file_name()
1135            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1136        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1137        let fd = unsafe {
1138            libc::openat(
1139                parent.as_raw_fd(),
1140                leaf.as_ptr(),
1141                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1142                0o600,
1143            )
1144        };
1145        if fd < 0 {
1146            let error = std::io::Error::last_os_error();
1147            if error.kind() == std::io::ErrorKind::AlreadyExists {
1148                return Err(bad_agent_key(
1149                    "the output file already exists — refusing to overwrite a key",
1150                ));
1151            }
1152            return Err(error.into());
1153        }
1154        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1155    };
1156    #[cfg(not(unix))]
1157    let mut file = std::fs::OpenOptions::new()
1158        .write(true)
1159        .create_new(true)
1160        .open(path)
1161        .map_err(|error| {
1162            if error.kind() == std::io::ErrorKind::AlreadyExists {
1163                bad_agent_key("the output file already exists — refusing to overwrite a key")
1164            } else {
1165                LinkError::Io(error)
1166            }
1167        })?;
1168    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1169        drop(file);
1170        #[cfg(unix)]
1171        let _ =
1172            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1173        #[cfg(not(unix))]
1174        let _ = std::fs::remove_file(path);
1175        return Err(LinkError::Io(error));
1176    }
1177    drop(file);
1178    #[cfg(unix)]
1179    parent.sync_all()?;
1180    Ok(())
1181}
1182
1183/// What `dbmd key generate` returns: the public identity to register plus
1184/// where the secret landed.
1185#[derive(Debug, Serialize)]
1186pub struct GeneratedAgentKey {
1187    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1188    pub multikey: String,
1189    /// base64url SPKI DER — what a hub's register endpoint takes.
1190    #[serde(rename = "publicKeySpki")]
1191    pub public_key_spki: String,
1192    /// Where the PKCS#8 secret was written (mode 0600).
1193    #[serde(rename = "keyFile")]
1194    pub key_file: String,
1195}
1196
1197/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1198/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1199/// identity is returned. The private key never enters a store and never
1200/// travels — requests carry per-request signatures instead (link.md §8).
1201pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1202    require_hardened_filesystem("key generation")?;
1203    let rng = ring::rand::SystemRandom::new();
1204    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1205        .map_err(|_| bad_agent_key("key generation failed"))?;
1206    let pair = agent_keypair(pkcs8.as_ref())?;
1207    let (spki_b64u, multikey) = public_identity_for(&pair);
1208
1209    write_secret_new(
1210        out,
1211        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1212    )?;
1213
1214    Ok(GeneratedAgentKey {
1215        multikey,
1216        public_key_spki: spki_b64u,
1217        key_file: out.display().to_string(),
1218    })
1219}
1220
1221/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1222/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1223/// (sha256hex(body) | "-")`.
1224///
1225/// The origin is derived from the already-validated hub URL, never from
1226/// attacker-controlled request metadata. Binding it closes the v1 replay
1227/// class where a proof captured by one hub could be replayed to another hub
1228/// serving the same path inside the timestamp window.
1229fn linkmd_sig_header(
1230    key: &AgentSigningKey,
1231    origin: &str,
1232    method: &str,
1233    path: &str,
1234    body: Option<&str>,
1235) -> LinkResult<String> {
1236    let ts = std::time::SystemTime::now()
1237        .duration_since(std::time::UNIX_EPOCH)
1238        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1239        .as_secs();
1240    let body_hash = match body {
1241        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1242        None => "-".to_string(),
1243    };
1244    let canonical = format!(
1245        "v2\n{}\n{}\n{}\n{}\n{}",
1246        origin,
1247        method.to_uppercase(),
1248        path,
1249        ts,
1250        body_hash
1251    );
1252    let pair = agent_keypair(&key.pkcs8)?;
1253    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1254    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1255    Ok(format!(
1256        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1257    ))
1258}
1259
1260// ─────────────────────────────────────────────────────────────────────────────
1261// Self-custody feed entries — the client signs what the hub only verifies
1262// ─────────────────────────────────────────────────────────────────────────────
1263
1264/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1265/// exactly this order).
1266#[derive(Serialize)]
1267struct WireFeedFile {
1268    path: String,
1269    sha256: String,
1270    bytes: u64,
1271}
1272
1273/// The unsigned entry in the normative §5.1 field order — serde serializes
1274/// struct fields in declaration order, which IS the wire contract.
1275#[derive(Serialize)]
1276struct UnsignedWireEntry<'a> {
1277    v: u8,
1278    seq: u64,
1279    ts: String,
1280    brain: &'a str,
1281    public_key: &'a str,
1282    kind: &'a str,
1283    op: &'a str,
1284    pack_sha256: &'a str,
1285    files: &'a [WireFeedFile],
1286    removed: &'a [String],
1287    prev_entry_hash: Option<&'a str>,
1288}
1289
1290/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1291/// brain key: serialize the unsigned entry compactly in the normative order,
1292/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1293/// returned string is the exact serialization the hub stores verbatim (plus
1294/// one trailing newline) and every independent reader re-derives.
1295fn self_custody_entry(
1296    key: &AgentSigningKey,
1297    seq: u64,
1298    ts: String,
1299    pack_sha256: &str,
1300    files: &[WireFeedFile],
1301    prev_entry_hash: Option<&str>,
1302) -> LinkResult<String> {
1303    let removed: [String; 0] = [];
1304    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1305        v: 1,
1306        seq,
1307        ts,
1308        brain: &key.multikey,
1309        public_key: &key.public_key_spki,
1310        kind: "push",
1311        op: "snapshot",
1312        pack_sha256,
1313        files,
1314        removed: &removed,
1315        prev_entry_hash,
1316    })
1317    .expect("serialize feed entry");
1318    let pair = agent_keypair(&key.pkcs8)?;
1319    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1320    Ok(format!(
1321        "{},\"sig\":\"{}\"}}",
1322        &unsigned[..unsigned.len() - 1],
1323        sig
1324    ))
1325}
1326
1327/// An env var, treated as absent when unset or empty (an empty
1328/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1329fn env_nonempty(name: &str) -> Option<String> {
1330    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1331}
1332
1333/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1334/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1335/// ignored (forward-compatible). A missing or unreadable file is simply "not
1336/// configured here".
1337fn config_file_hub(path: &Path) -> Option<String> {
1338    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1339    #[cfg(unix)]
1340    let file = {
1341        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1342        use std::os::unix::ffi::OsStrExt as _;
1343        let parent =
1344            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1345        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1346        let fd = unsafe {
1347            libc::openat(
1348                parent.as_raw_fd(),
1349                leaf.as_ptr(),
1350                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1351            )
1352        };
1353        if fd < 0 {
1354            return None;
1355        }
1356        unsafe { std::fs::File::from_raw_fd(fd) }
1357    };
1358    #[cfg(not(unix))]
1359    let file = std::fs::File::open(path).ok()?;
1360    let metadata = file.metadata().ok()?;
1361    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1362        return None;
1363    }
1364    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1365    file.take(MAX_CONFIG_BYTES + 1)
1366        .read_to_end(&mut bytes)
1367        .ok()?;
1368    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1369        return None;
1370    }
1371    let text = String::from_utf8(bytes).ok()?;
1372    for line in text.lines() {
1373        let line = line.trim();
1374        if line.is_empty() || line.starts_with('#') {
1375            continue;
1376        }
1377        if let Some((k, v)) = line.split_once('=') {
1378            if k.trim() == "hub" {
1379                let v = v.trim();
1380                if !v.is_empty() {
1381                    return Some(v.to_string());
1382                }
1383            }
1384        }
1385    }
1386    None
1387}
1388
1389/// The bearer key must never travel in cleartext; only loopback hosts may
1390/// skip TLS (local development against a hub on localhost).
1391fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1392    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1393        hub: hub.to_string(),
1394    })?;
1395    if !(parsed.scheme().eq_ignore_ascii_case("https")
1396        || parsed.scheme().eq_ignore_ascii_case("http"))
1397        || !parsed.username().is_empty()
1398        || parsed.password().is_some()
1399        || (parsed.path() != "/" && !parsed.path().is_empty())
1400        || parsed.query().is_some()
1401        || parsed.fragment().is_some()
1402    {
1403        return Err(LinkError::UnsafeHub {
1404            hub: hub.to_string(),
1405        });
1406    }
1407    let loopback = match parsed.host() {
1408        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1409        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1410        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1411        None => false,
1412    };
1413    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1414        Ok(())
1415    } else {
1416        Err(LinkError::UnsafeHub {
1417            hub: hub.to_string(),
1418        })
1419    }
1420}
1421
1422/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1423/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1424/// value tends to echo the whole header line, credential included, so the
1425/// gate sits here instead.
1426fn clean_key(raw: &str) -> LinkResult<String> {
1427    let k = raw.trim();
1428    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1429        return Err(LinkError::BadKey);
1430    }
1431    Ok(k.to_string())
1432}
1433
1434// ─────────────────────────────────────────────────────────────────────────────
1435// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1436// ─────────────────────────────────────────────────────────────────────────────
1437
1438/// One hub response: the status plus the parsed JSON body when there was one.
1439#[derive(Debug)]
1440pub struct HubResponse {
1441    /// The HTTP status code.
1442    pub status: u16,
1443    /// The parsed JSON body, `None` when the body was empty or not JSON.
1444    pub body: Option<Value>,
1445}
1446
1447struct RawHubResponse {
1448    status: u16,
1449    body: Vec<u8>,
1450}
1451
1452/// Whether a request carries the bearer credential.
1453#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1454enum Auth {
1455    /// Send `authorization: Bearer <key>`; error without a key.
1456    Required,
1457    /// Send no credential — the propose door is unauthenticated by design.
1458    None,
1459    /// Send the configured credential when one exists, otherwise nothing —
1460    /// brain-addressed propose works anonymously on public brains, and an
1461    /// authenticated caller earns a bigger actor-class budget.
1462    Optional,
1463}
1464
1465fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1466    ureq::AgentBuilder::new()
1467        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1468        // Never follow a redirect while a bearer, a store pack, or a signed
1469        // response is in flight. Callers see the 3xx as a non-success instead
1470        // of letting an origin steer sensitive material elsewhere.
1471        .redirects(0)
1472        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1473        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1474        .timeout_write(overall)
1475        .timeout(overall)
1476}
1477
1478fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1479    hub_agent_with_timeout(
1480        cfg,
1481        std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1482    )
1483}
1484
1485fn hub_agent_with_timeout(
1486    cfg: &HubConfig,
1487    overall: std::time::Duration,
1488) -> LinkResult<ureq::Agent> {
1489    if !cfg.store_selected {
1490        return Ok(agent_builder_with_timeout(overall).build());
1491    }
1492    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1493        hub: cfg.hub.clone(),
1494    })?;
1495    pinned_public_agent_pooled(
1496        &parsed,
1497        false,
1498        "store-selected hub",
1499        AgentShape {
1500            overall,
1501            ..AgentShape::default()
1502        },
1503    )
1504}
1505
1506/// Perform one hub request. `path` is the binding path (starts with `/`);
1507/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1508/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1509/// the [`HubResponse`] for [`ensure_ok`] to shape.
1510fn request_raw(
1511    cfg: &HubConfig,
1512    method: &str,
1513    path: &str,
1514    body: Option<&Value>,
1515    auth: Auth,
1516    max_response_bytes: u64,
1517) -> LinkResult<RawHubResponse> {
1518    let http = hub_agent(cfg)?;
1519    request_raw_with_agent(
1520        cfg,
1521        &http,
1522        method,
1523        path,
1524        body,
1525        RawRequestOptions {
1526            auth,
1527            max_response_bytes,
1528            request_id: None,
1529            retry_transport: false,
1530        },
1531    )
1532}
1533
1534/// A body-bearing request that reads immutable state without changing hub
1535/// authority. Some proof lists are too large for a URL, so link.md transports
1536/// them with POST even though repeating the request is side-effect free.
1537fn request_raw_retryable_read(
1538    cfg: &HubConfig,
1539    method: &str,
1540    path: &str,
1541    body: Option<&Value>,
1542    auth: Auth,
1543    max_response_bytes: u64,
1544) -> LinkResult<RawHubResponse> {
1545    let http = hub_agent(cfg)?;
1546    request_raw_with_agent(
1547        cfg,
1548        &http,
1549        method,
1550        path,
1551        body,
1552        RawRequestOptions {
1553            auth,
1554            max_response_bytes,
1555            request_id: None,
1556            retry_transport: true,
1557        },
1558    )
1559}
1560
1561struct RawRequestOptions<'a> {
1562    auth: Auth,
1563    max_response_bytes: u64,
1564    request_id: Option<&'a str>,
1565    retry_transport: bool,
1566}
1567
1568fn request_raw_with_agent(
1569    cfg: &HubConfig,
1570    http: &ureq::Agent,
1571    method: &str,
1572    path: &str,
1573    body: Option<&Value>,
1574    options: RawRequestOptions<'_>,
1575) -> LinkResult<RawHubResponse> {
1576    let url = format!("{}{}", cfg.hub, path);
1577    let encoded_body = body.map(Value::to_string);
1578    let origin = normalized_origin(&cfg.hub)?;
1579    let safe_read = (method == "GET" && encoded_body.is_none()) || options.retry_transport;
1580    let mut read_attempt = 0;
1581    loop {
1582        // An agent signing key outranks the bearer: possession proofs put
1583        // nothing reusable on the wire, so when both are configured the
1584        // stronger one wins. Regenerate its timestamp for a whole-request GET
1585        // retry; a long stalled read must not age the replacement signature
1586        // outside the hub's acceptance window. This applies only to GETs and
1587        // the explicitly marked read-only POST bindings above.
1588        let credential = match options.auth {
1589            Auth::Required => Some(match &cfg.agent_key {
1590                Some(key) => {
1591                    linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?
1592                }
1593                None => format!("Bearer {}", cfg.require_key()?),
1594            }),
1595            Auth::Optional => match &cfg.agent_key {
1596                Some(key) => Some(linkmd_sig_header(
1597                    key,
1598                    &origin,
1599                    method,
1600                    path,
1601                    encoded_body.as_deref(),
1602                )?),
1603                None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1604            },
1605            Auth::None => None,
1606        };
1607        let result = with_connect_retries(|| {
1608            let mut req = http.request(method, &url);
1609            if let Some(value) = &credential {
1610                req = req.set("authorization", value);
1611            }
1612            if let Some(value) = options.request_id {
1613                req = req.set("x-request-id", value);
1614            }
1615            match &encoded_body {
1616                Some(value) => req
1617                    .set("content-type", "application/json")
1618                    .send_string(value)
1619                    .map_err(Box::new),
1620                None => req.call().map_err(Box::new),
1621            }
1622        });
1623        let resp = match result {
1624            Ok(resp) => resp,
1625            Err(error) => match *error {
1626                ureq::Error::Status(_, resp) => resp,
1627                ureq::Error::Transport(error) => {
1628                    if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS {
1629                        std::thread::sleep(std::time::Duration::from_millis(
1630                            SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1631                        ));
1632                        read_attempt += 1;
1633                        continue;
1634                    }
1635                    return Err(LinkError::Transport {
1636                        hub: cfg.hub.clone(),
1637                        message: error.to_string(),
1638                    });
1639                }
1640            },
1641        };
1642
1643        let status = resp.status();
1644        let buf = match read_response_body(resp, options.max_response_bytes + 1, &cfg.hub) {
1645            Ok(buf) => buf,
1646            Err(LinkError::Transport { .. })
1647                if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS =>
1648            {
1649                std::thread::sleep(std::time::Duration::from_millis(
1650                    SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1651                ));
1652                read_attempt += 1;
1653                continue;
1654            }
1655            Err(error) => return Err(error),
1656        };
1657        if buf.len() as u64 > options.max_response_bytes {
1658            return Err(LinkError::ResponseTooLarge {
1659                limit_bytes: options.max_response_bytes,
1660            });
1661        }
1662        return Ok(RawHubResponse { status, body: buf });
1663    }
1664}
1665
1666fn request_capped(
1667    cfg: &HubConfig,
1668    method: &str,
1669    path: &str,
1670    body: Option<&Value>,
1671    auth: Auth,
1672    max_response_bytes: u64,
1673) -> LinkResult<HubResponse> {
1674    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1675    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1676    Ok(HubResponse {
1677        status: raw.status,
1678        body: parsed,
1679    })
1680}
1681
1682/// One request the hub is allowed to take brain-sized time over. Used for the
1683/// commit lane, where abandoning the attempt early does not cancel the work —
1684/// it only loses the receipt for it.
1685///
1686/// Which is why a lost connection is asked again rather than reported. The
1687/// mutation coordinate is derived from the observed base and the exact
1688/// operation set precisely so a repeat is the SAME effect: the hub answers a
1689/// mutation it already applied with that commit's receipt instead of a second
1690/// commit. Measured on a 32,338-file store: the commit landed durably and the
1691/// push reported a transport failure, because nobody went back for the
1692/// receipt.
1693fn request_patient(
1694    cfg: &HubConfig,
1695    method: &str,
1696    path: &str,
1697    body: Option<&Value>,
1698    auth: Auth,
1699) -> LinkResult<HubResponse> {
1700    let http = hub_agent_with_timeout(
1701        cfg,
1702        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1703    )?;
1704    let mut attempt = 0;
1705    loop {
1706        let sent = request_raw_with_agent(
1707            cfg,
1708            &http,
1709            method,
1710            path,
1711            body,
1712            RawRequestOptions {
1713                auth,
1714                max_response_bytes: MAX_RESPONSE_BYTES,
1715                request_id: None,
1716                retry_transport: false,
1717            },
1718        );
1719        match sent {
1720            Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1721                std::thread::sleep(std::time::Duration::from_millis(
1722                    COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1723                ));
1724                attempt += 1;
1725            }
1726            Err(error) => return Err(error),
1727            Ok(raw) => {
1728                return Ok(HubResponse {
1729                    status: raw.status,
1730                    body: serde_json::from_slice(&raw.body).ok(),
1731                })
1732            }
1733        }
1734    }
1735}
1736
1737fn request(
1738    cfg: &HubConfig,
1739    method: &str,
1740    path: &str,
1741    body: Option<&Value>,
1742    auth: Auth,
1743) -> LinkResult<HubResponse> {
1744    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1745}
1746
1747/// Repeat one challenge-response mutation with the exact request identity the
1748/// hub bound into its actor claim. A new request id would create a new actor
1749/// hash and therefore a different canonical commit candidate, making the
1750/// otherwise correct self-custody signature unusable.
1751fn request_with_request_id(
1752    cfg: &HubConfig,
1753    method: &str,
1754    path: &str,
1755    body: Option<&Value>,
1756    auth: Auth,
1757    request_id: &str,
1758) -> LinkResult<HubResponse> {
1759    if request_id.is_empty()
1760        || request_id.len() > 128
1761        || !request_id
1762            .bytes()
1763            .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1764    {
1765        return Err(invalid_feed("hub returned an unsafe request id"));
1766    }
1767    // Both callers are commit responses, so this shares the commit lane's
1768    // patience: the hub is finishing the same brain-sized work either way.
1769    let http = hub_agent_with_timeout(
1770        cfg,
1771        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1772    )?;
1773    let raw = request_raw_with_agent(
1774        cfg,
1775        &http,
1776        method,
1777        path,
1778        body,
1779        RawRequestOptions {
1780            auth,
1781            max_response_bytes: MAX_RESPONSE_BYTES,
1782            request_id: Some(request_id),
1783            retry_transport: false,
1784        },
1785    )?;
1786    Ok(HubResponse {
1787        status: raw.status,
1788        body: serde_json::from_slice(&raw.body).ok(),
1789    })
1790}
1791
1792fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1793    if (200..300).contains(&r.status) {
1794        return Ok(r.body);
1795    }
1796    ensure_ok(
1797        HubResponse {
1798            status: r.status,
1799            body: serde_json::from_slice(&r.body).ok(),
1800        },
1801        what,
1802    )
1803    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1804}
1805
1806/// These failures happen before any HTTP request reaches the hub, so retrying
1807/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1808/// bytes may have crossed the wire, the caller must rely on the verb's own
1809/// idempotency contract instead of guessing.
1810fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1811    matches!(
1812        kind,
1813        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1814    )
1815}
1816
1817fn with_connect_retries(
1818    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1819) -> Result<ureq::Response, Box<ureq::Error>> {
1820    let mut attempt = 0;
1821    loop {
1822        match send() {
1823            Err(error)
1824                if matches!(
1825                    error.as_ref(),
1826                    ureq::Error::Transport(transport)
1827                        if is_pre_request_transport(transport.kind())
1828                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1829            {
1830                std::thread::sleep(std::time::Duration::from_millis(
1831                    CONNECT_RETRY_BACKOFF_MS[attempt],
1832                ));
1833                attempt += 1;
1834            }
1835            result => return result,
1836        }
1837    }
1838}
1839
1840fn hub_is_loopback(hub: &str) -> bool {
1841    url::Url::parse(hub).ok().is_some_and(|parsed| {
1842        parsed.host().is_some_and(|host| match host {
1843            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1844            url::Host::Ipv4(ip) => ip.is_loopback(),
1845            url::Host::Ipv6(ip) => ip.is_loopback(),
1846        })
1847    })
1848}
1849
1850/// Every safety property a presigned object-store URL must carry, checked
1851/// before anything is sent to it. Split out from agent construction so a batch
1852/// can share one connection pool without ever skipping a per-URL check.
1853fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1854    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1855        message: "the hub returned an invalid object-store URL".to_string(),
1856    })?;
1857    let allow_private = hub_is_loopback(&cfg.hub)
1858        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1859    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1860        || !parsed.username().is_empty()
1861        || parsed.password().is_some()
1862        || parsed.fragment().is_some()
1863    {
1864        return Err(LinkError::InvalidPack {
1865            message: "the hub returned an unsafe object-store URL".to_string(),
1866        });
1867    }
1868    Ok((parsed, allow_private))
1869}
1870
1871fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1872    let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1873    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1874        LinkError::InvalidPack {
1875            message: "the hub returned an object-store URL with an unsafe network target"
1876                .to_string(),
1877        }
1878    })
1879}
1880
1881/// One pinned agent for a whole staging batch.
1882///
1883/// Every object in a batch is presigned against the same object-store
1884/// authority, so one agent can serve them all — and must: building one per
1885/// object costs a DNS resolution and a fresh TLS handshake per object, which is
1886/// the difference between a real store staging in minutes and in an hour. The
1887/// shared authority is verified rather than assumed, and a batch that ever
1888/// spans two of them falls back to a per-object agent.
1889fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1890    let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1891    let authority = (
1892        first.host_str()?.to_string(),
1893        first.port_or_known_default()?,
1894    );
1895    for raw in &urls[1..] {
1896        let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1897        if (parsed.host_str()?, parsed.port_or_known_default()?)
1898            != (authority.0.as_str(), authority.1)
1899        {
1900            return None;
1901        }
1902    }
1903    pinned_public_agent_pooled(
1904        &first,
1905        allow_private,
1906        "object-store URL",
1907        AgentShape {
1908            idle_per_host: V2_UPLOAD_CONCURRENCY,
1909            ..AgentShape::default()
1910        },
1911    )
1912    .ok()
1913}
1914
1915/// Turn an object-store transport failure into a useful but credential-safe
1916/// diagnostic. `ureq::Transport`'s Display implementation includes the full
1917/// request URL; for a presigned URL that means the short-lived signature and
1918/// every signed query coordinate. The error kind is enough to distinguish a
1919/// network failure without copying bearer material into terminals or logs.
1920fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1921    LinkError::Transport {
1922        hub: "the object store".to_string(),
1923        message: format!("network error ({:?})", error.kind()),
1924    }
1925}
1926
1927fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1928    let http = presigned_agent(cfg, raw)?;
1929    let deadline = std::time::Instant::now()
1930        .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1931        .ok_or_else(upload_deadline_error)?;
1932    let mut attempt = 0;
1933    let result = loop {
1934        // The reservation states content-length itself. This client sends the
1935        // body from memory, so it never needs to add one, and adding one under
1936        // a different capitalization would leave both on the wire.
1937        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1938        if let Some(map) = headers.as_object() {
1939            for (name, value) in map {
1940                if let Some(value) = value.as_str() {
1941                    req = req.set(name, value);
1942                }
1943            }
1944        }
1945        match req.send_bytes(bytes) {
1946            // An upload is idempotent here: fixed content address, exact
1947            // length and checksum, write-once precondition. A dropped
1948            // connection or a single overloaded refusal is worth replaying
1949            // rather than failing a whole migration on. 412 already means
1950            // "someone else stored these exact bytes".
1951            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1952                attempt += 1;
1953            }
1954            Err(ureq::Error::Status(status, _))
1955                if status != 412
1956                    && is_retryable_upload_status(status)
1957                    && wait_for_upload_retry(deadline, attempt) =>
1958            {
1959                attempt += 1;
1960            }
1961            result => break result,
1962        }
1963    };
1964    match result {
1965        Ok(resp) if (200..300).contains(&resp.status()) => {
1966            drain_presigned_response(resp);
1967            Ok(())
1968        }
1969        Ok(resp) => Err(presigned_upload_refusal(resp)),
1970        Err(error) => match error {
1971            // Immutable uploads use If-None-Match. A concurrent writer may
1972            // win the same content address; the commit/confirm endpoint reads
1973            // and hashes that object before accepting it, so 412 safely means
1974            // "continue to verification", never "trust the upload".
1975            ureq::Error::Status(412, _) => Ok(()),
1976            ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
1977            ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
1978        },
1979    }
1980}
1981
1982/// Read a response body, typed as the transport failure it is.
1983///
1984/// A connection that fails while the body is still arriving is the same class
1985/// of problem as one that fails before it arrives at all — but a bare `?` on
1986/// the read turned it into an anonymous io error that no retry path recognised
1987/// and no message attributed to a peer. On a migration making tens of
1988/// thousands of requests, one stalled body ended the whole push and reported
1989/// only "timed out reading response".
1990fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
1991    let mut buf = Vec::new();
1992    response
1993        .into_reader()
1994        .take(limit)
1995        .read_to_end(&mut buf)
1996        .map_err(|error| LinkError::Transport {
1997            hub: peer.to_string(),
1998            message: error.to_string(),
1999        })?;
2000    Ok(buf)
2001}
2002
2003/// Draining a response is what returns its connection to the pool: the client
2004/// cannot recycle a socket whose body may still have bytes pending, so a body
2005/// that is dropped instead of read costs the next object a fresh handshake.
2006/// An object-store PUT answers with almost nothing, so this is nearly free.
2007fn drain_presigned_response(response: ureq::Response) {
2008    let mut reader = response.into_reader().take(64 * 1024);
2009    let _ = std::io::copy(&mut reader, &mut std::io::sink());
2010}
2011
2012/// Carry the object store's own reason for a refusal. An unexplained status is
2013/// unactionable; the body states checksum mismatch, precondition, or expiry.
2014fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
2015    let status = response.status();
2016    let detail = response
2017        .into_string()
2018        .ok()
2019        .map(|body| body.chars().take(400).collect::<String>())
2020        .filter(|body| !body.trim().is_empty());
2021    LinkError::Http {
2022        what: "pack upload",
2023        status,
2024        message: match detail {
2025            Some(body) => format!(
2026                "object store rejected the upload: {}",
2027                body.replace('\n', " ")
2028            ),
2029            None => "object store rejected the upload".to_string(),
2030        },
2031        code: None,
2032        details: None,
2033    }
2034}
2035
2036fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
2037    max_bytes.checked_add(1)
2038}
2039
2040fn presigned_download_read_limit() -> u64 {
2041    one_past_bounded_limit(MAX_PACK_BYTES)
2042        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
2043}
2044
2045fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
2046    let http = presigned_agent(cfg, raw)?;
2047    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
2048        Ok(resp) => resp,
2049        Err(error) => match *error {
2050            ureq::Error::Status(_, resp) => {
2051                return Err(LinkError::Http {
2052                    what: "pack download",
2053                    status: resp.status(),
2054                    message: "object store rejected the download".to_string(),
2055                    code: None,
2056                    details: None,
2057                });
2058            }
2059            ureq::Error::Transport(err) => {
2060                return Err(LinkError::Transport {
2061                    hub: "the object store".to_string(),
2062                    message: err.to_string(),
2063                });
2064            }
2065        },
2066    };
2067    if !(200..300).contains(&resp.status()) {
2068        return Err(LinkError::Http {
2069            what: "pack download",
2070            status: resp.status(),
2071            message: "object store rejected the download".to_string(),
2072            code: None,
2073            details: None,
2074        });
2075    }
2076    let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2077    if bytes.len() as u64 > MAX_PACK_BYTES {
2078        return Err(LinkError::InvalidPack {
2079            message: "download exceeds the compressed-size limit".to_string(),
2080        });
2081    }
2082    Ok(bytes)
2083}
2084
2085/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
2086/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
2087/// answer.
2088fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2089    if !(200..300).contains(&r.status) {
2090        let message = r
2091            .body
2092            .as_ref()
2093            .and_then(|b| b.get("error"))
2094            .and_then(Value::as_str)
2095            .unwrap_or("unknown error")
2096            .to_string();
2097        let code = r
2098            .body
2099            .as_ref()
2100            .and_then(|b| b.get("code"))
2101            .and_then(Value::as_str)
2102            .map(str::to_string);
2103        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2104        return Err(LinkError::Http {
2105            what,
2106            status: r.status,
2107            message,
2108            code,
2109            details,
2110        });
2111    }
2112    r.body.ok_or(LinkError::NotJson {
2113        what,
2114        status: r.status,
2115    })
2116}
2117
2118// ─────────────────────────────────────────────────────────────────────────────
2119// resolve — handle → brain card; @brain/id → the record
2120// ─────────────────────────────────────────────────────────────────────────────
2121
2122/// Resolve an address. A bare `@brain` returns the brain card (metadata +
2123/// index stats — the v0 form of the card; keys arrive with the protocol's
2124/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
2125/// record, frontmatter + body.
2126fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2127    match ip {
2128        std::net::IpAddr::V4(ip) => {
2129            let [a, b, c, _] = ip.octets();
2130            !(a == 0
2131                || a == 10
2132                || a == 127
2133                || (a == 100 && (64..=127).contains(&b))
2134                || (a == 169 && b == 254)
2135                || (a == 172 && (16..=31).contains(&b))
2136                || (a == 192 && b == 0 && c == 0)
2137                || (a == 192 && b == 0 && c == 2)
2138                || (a == 192 && b == 88 && c == 99)
2139                || (a == 192 && b == 168)
2140                || (a == 198 && (b == 18 || b == 19))
2141                || (a == 198 && b == 51 && c == 100)
2142                || (a == 203 && b == 0 && c == 113)
2143                || a >= 224)
2144        }
2145        std::net::IpAddr::V6(ip) => {
2146            let segments = ip.segments();
2147            // Conservatively accept only global unicast 2000::/3, excluding
2148            // special-purpose/transition blocks. In particular, 6to4 embeds
2149            // an IPv4 destination and must not tunnel a validated public
2150            // connect to 127/8 or RFC1918.
2151            (segments[0] & 0xe000) == 0x2000
2152                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2153                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2154                && segments[0] != 0x2002
2155                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2156        }
2157    }
2158}
2159
2160#[derive(Clone)]
2161struct PinnedRegistryResolver {
2162    netloc: String,
2163    addresses: Vec<std::net::SocketAddr>,
2164}
2165
2166impl ureq::Resolver for PinnedRegistryResolver {
2167    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2168        if requested == self.netloc {
2169            Ok(self.addresses.clone())
2170        } else {
2171            Err(std::io::Error::new(
2172                std::io::ErrorKind::PermissionDenied,
2173                "registry request attempted to resolve an unvalidated authority",
2174            ))
2175        }
2176    }
2177}
2178
2179fn pinned_public_agent(
2180    url: &url::Url,
2181    allow_private: bool,
2182    label: &str,
2183) -> LinkResult<ureq::Agent> {
2184    pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2185}
2186
2187/// How one agent is built: `idle_per_host` sizes the connection pool (the
2188/// default of one is right for a lone request but throttles a concurrent batch
2189/// back to a single warm connection), and `overall` is the wall-clock budget
2190/// for one attempt.
2191struct AgentShape {
2192    idle_per_host: usize,
2193    overall: std::time::Duration,
2194}
2195
2196impl Default for AgentShape {
2197    fn default() -> Self {
2198        Self {
2199            idle_per_host: 1,
2200            overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2201        }
2202    }
2203}
2204
2205fn pinned_public_agent_pooled(
2206    url: &url::Url,
2207    allow_private: bool,
2208    label: &str,
2209    shape: AgentShape,
2210) -> LinkResult<ureq::Agent> {
2211    let host = url
2212        .host_str()
2213        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2214    let port = url
2215        .port_or_known_default()
2216        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2217    let addresses = resolve_addresses_with_deadline(
2218        host,
2219        port,
2220        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2221    )
2222    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2223    if addresses.is_empty() {
2224        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2225    }
2226    if !allow_private
2227        && addresses
2228            .iter()
2229            .any(|address| !is_public_registry_ip(address.ip()))
2230    {
2231        return Err(invalid_feed(format!(
2232            "{label} resolves to a non-public address"
2233        )));
2234    }
2235    let netloc = if host.contains(':') {
2236        format!("[{host}]:{port}")
2237    } else {
2238        format!("{host}:{port}")
2239    };
2240    Ok(agent_builder_with_timeout(shape.overall)
2241        .max_idle_connections_per_host(shape.idle_per_host.max(1))
2242        .resolver(PinnedRegistryResolver { netloc, addresses })
2243        .build())
2244}
2245
2246/// Resolve one authority without allowing libc DNS to hold the caller forever.
2247/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
2248/// worker and only its result channel is awaited. A late resolver result has no
2249/// side effects and is discarded after the deadline.
2250fn resolve_addresses_with_deadline(
2251    host: &str,
2252    port: u16,
2253    timeout: std::time::Duration,
2254) -> std::io::Result<Vec<std::net::SocketAddr>> {
2255    use std::net::ToSocketAddrs as _;
2256
2257    let host = host.to_string();
2258    let (send, receive) = std::sync::mpsc::sync_channel(1);
2259    std::thread::Builder::new()
2260        .name("dbmd-dns".to_string())
2261        .spawn(move || {
2262            let result = (host.as_str(), port)
2263                .to_socket_addrs()
2264                .map(|addresses| addresses.collect());
2265            let _ = send.send(result);
2266        })
2267        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2268    match receive.recv_timeout(timeout) {
2269        Ok(result) => result,
2270        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2271            std::io::ErrorKind::TimedOut,
2272            "resolution exceeded its deadline",
2273        )),
2274        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2275            "resolver stopped without returning a result",
2276        )),
2277    }
2278}
2279
2280fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2281    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2282    pinned_public_agent(url, allow_private, "registry home")
2283}
2284
2285/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
2286/// from a FOREIGN home during registry resolution. DNS is resolved once,
2287/// every answer is required to be public, and that exact answer set is pinned
2288/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
2289fn get_json_absolute(url: &str) -> LinkResult<Value> {
2290    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2291    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2292    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2293        || !parsed.username().is_empty()
2294        || parsed.password().is_some()
2295        || parsed.query().is_some()
2296        || parsed.fragment().is_some()
2297    {
2298        return Err(invalid_feed("unsafe registry home URL"));
2299    }
2300    let http = registry_agent(&parsed)?;
2301    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2302        Ok(resp) => resp,
2303        Err(error) => match *error {
2304            ureq::Error::Status(status, resp) => {
2305                let _ = resp;
2306                return Err(LinkError::Http {
2307                    what: "registry home fetch",
2308                    status,
2309                    message: "the home node rejected the card request".to_string(),
2310                    code: None,
2311                    details: None,
2312                });
2313            }
2314            ureq::Error::Transport(err) => {
2315                return Err(LinkError::Transport {
2316                    hub: url.to_string(),
2317                    message: err.to_string(),
2318                });
2319            }
2320        },
2321    };
2322    if !(200..300).contains(&resp.status()) {
2323        return Err(LinkError::Http {
2324            what: "registry home fetch",
2325            status: resp.status(),
2326            message: "the home node returned a redirect or error".to_string(),
2327            code: None,
2328            details: None,
2329        });
2330    }
2331    let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2332    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2333        return Err(LinkError::ResponseTooLarge {
2334            limit_bytes: MAX_REGISTRY_CARD_BYTES,
2335        });
2336    }
2337    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2338        message: "the home node returned invalid JSON".to_string(),
2339    })
2340}
2341
2342/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
2343/// E5): look the handle up in the hub's registry, fetch the brain card from
2344/// the returned HOME node, and PIN — the card's identity fingerprint must
2345/// equal the registry's, or resolution fails. Returns the card enriched with
2346/// the resolved `home`, or `Ok(None)` when the registry has no such handle
2347/// (so the caller can fall back to a direct lookup).
2348pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2349    require_safe_ref(handle)?;
2350    // Hold the validated trust-directory capability before any network I/O.
2351    // Every lock/load/save below remains relative to this exact inode even if
2352    // an attacker swaps an ancestor while the registry or home is answering.
2353    let trust_directory = open_trust_dir(cfg)?;
2354    let reg = request_capped(
2355        cfg,
2356        "GET",
2357        &format!("/api/hub/registry/{handle}"),
2358        None,
2359        Auth::None,
2360        MAX_REGISTRY_CARD_BYTES,
2361    )?;
2362    if reg.status == 404 {
2363        return Ok(None);
2364    }
2365    let body = ensure_ok(reg, "registry resolve")?;
2366    let home = body
2367        .get("home")
2368        .and_then(Value::as_str)
2369        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2370    let brain = body
2371        .get("brain")
2372        .and_then(Value::as_str)
2373        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2374    if !crate::ulid::is_ulid(brain) {
2375        return Err(invalid_feed(
2376            "registry entry brain is not a canonical lowercase ULID",
2377        ));
2378    }
2379    let want_fp = body
2380        .get("identity")
2381        .and_then(|i| i.get("fingerprint"))
2382        .and_then(Value::as_str)
2383        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2384
2385    let home = home.trim_end_matches('/');
2386    let origin = normalized_origin(home)?;
2387    if origin != home {
2388        return Err(invalid_feed(
2389            "registry home must be an origin without a path, query, or fragment",
2390        ));
2391    }
2392    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2393    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2394    if let Some(binding) = &alias_binding {
2395        if binding
2396            .home
2397            .as_deref()
2398            .is_some_and(|pinned_home| pinned_home != home)
2399        {
2400            return Err(invalid_feed(
2401                "registry relocated a pinned handle to a different home",
2402            ));
2403        }
2404    }
2405    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2406    if card.get("id").and_then(Value::as_str) != Some(brain) {
2407        return Err(invalid_feed(
2408            "the home node served a card for a different brain",
2409        ));
2410    }
2411    let identity: FeedIdentity = serde_json::from_value(
2412        card.get("identity")
2413            .cloned()
2414            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2415    )
2416    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2417    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2418    let got_fp = card
2419        .get("identity")
2420        .and_then(|i| i.get("fingerprint"))
2421        .and_then(Value::as_str)
2422        .unwrap_or_default();
2423    if got_fp != want_fp {
2424        return Err(invalid_feed(
2425            "the home node served an identity that does not match the registry — refusing",
2426        ));
2427    }
2428    let current = format!("ed25519:{}", identity.fingerprint);
2429    let advertised_seq = card
2430        .get("headSeq")
2431        .and_then(Value::as_u64)
2432        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2433    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2434    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2435        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2436    {
2437        return Err(invalid_feed(
2438            "the home node served an invalid feed head boundary",
2439        ));
2440    }
2441    // This is required even on first contact. Otherwise a syntactically valid
2442    // identity may claim that a rotation happened after a head the home has
2443    // never reached, then become the permanent TOFU anchor.
2444    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2445    let registry_alias = AliasBinding {
2446        v: 1,
2447        origin: normalized_origin(&cfg.hub)?,
2448        requested: handle.to_string(),
2449        brain: brain.to_string(),
2450        home: Some(home.to_string()),
2451    };
2452    save_canonical_pin_and_alias(
2453        cfg,
2454        &trust_directory,
2455        handle,
2456        brain,
2457        TrustState {
2458            v: 2,
2459            origin: normalized_origin(&cfg.hub)?,
2460            requested: brain.to_string(),
2461            brain: brain.to_string(),
2462            home: None,
2463            anchor,
2464            current,
2465            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2466            feed_hash: pinned
2467                .as_ref()
2468                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2469            rotations: identity.rotations.clone(),
2470            hub_signer: None,
2471            protocol_profile: None,
2472        },
2473        Some(&registry_alias),
2474    )?;
2475    let mut out = card;
2476    if let Value::Object(map) = &mut out {
2477        map.insert("home".to_string(), Value::String(home.to_string()));
2478        map.insert(
2479            "resolvedVia".to_string(),
2480            Value::String("registry".to_string()),
2481        );
2482    }
2483    Ok(Some(out))
2484}
2485
2486pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2487    // `Address::parse` refuses these shapes already, but `Address` has public
2488    // fields — re-assert at the wire so a hand-built address can never
2489    // reshape the request path.
2490    require_safe_ref(&addr.brain)?;
2491    if let Some(target) = &addr.target {
2492        let (given, ok) = match target {
2493            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2494            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2495        };
2496        if !ok {
2497            return Err(LinkError::BadAddress {
2498                given: given.clone(),
2499                reason: BAD_TARGET_REASON.to_string(),
2500            });
2501        }
2502    }
2503
2504    // A record is never accepted from the hub's mutable query/index response.
2505    // Under v2, an id lookup may suggest a path, but the client accepts only
2506    // bytes whose exact path proof reaches the verified content root and whose
2507    // parsed id still matches. Under v1, the signed snapshot pack remains the
2508    // binding. Both profiles therefore reject a forged query response.
2509    if let Some(target) = &addr.target {
2510        if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2511            let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2512                what: "resolve",
2513                status: 404,
2514                message: "record not found".to_string(),
2515                code: Some("NOT_FOUND".to_string()),
2516                details: None,
2517            })?;
2518            let (path, file) = match target {
2519                AddressTarget::Path(path) => {
2520                    let file =
2521                        v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2522                            LinkError::Http {
2523                                what: "resolve",
2524                                status: 404,
2525                                message: "record not found".to_string(),
2526                                code: Some("NOT_FOUND".to_string()),
2527                                details: None,
2528                            }
2529                        })?;
2530                    (path.clone(), file)
2531                }
2532                AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2533            };
2534            let mut downloaded =
2535                download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2536            let (_, bytes) = downloaded
2537                .pop()
2538                .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2539            let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2540            accept_v2_head(cfg, &head)?;
2541            return Ok(resolved);
2542        }
2543        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2544        if !remote.head.verified {
2545            return Err(invalid_feed(
2546                "a path-scoped feed cannot prove a record against the full signed snapshot",
2547            ));
2548        }
2549        if remote.head.seq == 0 {
2550            return Err(LinkError::Http {
2551                what: "resolve",
2552                status: 404,
2553                message: "record not found".to_string(),
2554                code: Some("NOT_FOUND".to_string()),
2555                details: None,
2556            });
2557        }
2558        let brain = remote.head.brain.clone();
2559        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2560        return resolve_from_verified_pack(&brain, target, pack);
2561    }
2562
2563    let path = format!("/api/hub/brains/{}", addr.brain);
2564    // Direct first: the caller's own slug and hub-hosted public handles resolve
2565    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2566    // (404) falls through to the federation registry — how a handle reaches a
2567    // brain on ANOTHER node (link.md §7.1, E5).
2568    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2569    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2570        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2571            return Ok(card);
2572        }
2573    }
2574    let mut resolved = ensure_ok(direct, "resolve")?;
2575    if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2576        let v2 = v2_verified_head(cfg, &addr.brain)?
2577            .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2578        if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2579            return Err(invalid_feed(
2580                "resolve card is not bound to the verified v2 brain",
2581            ));
2582        }
2583        let card_identity: FeedIdentity = serde_json::from_value(
2584            resolved
2585                .get("identity")
2586                .cloned()
2587                .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2588        )
2589        .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2590        if card_identity != v2_identity(&v2.identity) {
2591            return Err(invalid_feed(
2592                "resolve card identity differs from the verified v2 identity",
2593            ));
2594        }
2595        accept_v2_head(cfg, &v2)?;
2596        if let Value::Object(card) = &mut resolved {
2597            card.insert(
2598                "headSeq".to_string(),
2599                json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2600            );
2601            card.insert(
2602                "feedHash".to_string(),
2603                v2.pointer
2604                    .as_ref()
2605                    .map(|pointer| Value::String(pointer.feed_hash.clone()))
2606                    .unwrap_or(Value::Null),
2607            );
2608            card.insert(
2609                "storageProfile".to_string(),
2610                Value::String("v2".to_string()),
2611            );
2612            if let Some(pointer) = &v2.pointer {
2613                card.insert(
2614                    "updatedAt".to_string(),
2615                    Value::String(pointer.signed_at.clone()),
2616                );
2617            }
2618        }
2619        return Ok(resolved);
2620    }
2621    // A successful direct response is accepted only after the same centralized
2622    // identity/rotation/feed checkpoint verification used by sync and
2623    // subscribe. No verb gets a weaker ad-hoc pinning path.
2624    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2625    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2626        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2627        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2628    {
2629        return Err(invalid_feed(
2630            "resolve card is not bound to the exact verified feed checkpoint",
2631        ));
2632    }
2633    let card_identity: FeedIdentity = serde_json::from_value(
2634        resolved
2635            .get("identity")
2636            .cloned()
2637            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2638    )
2639    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2640    if remote.identity.as_ref() != Some(&card_identity) {
2641        return Err(invalid_feed(
2642            "resolve card identity differs from the verified feed identity",
2643        ));
2644    }
2645    Ok(resolved)
2646}
2647
2648/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2649/// mutable query/index result is intentionally not consulted: a signed pack
2650/// digest is the only cryptographic binding between a feed checkpoint and
2651/// record bytes in wire profile v1.
2652fn resolve_from_verified_pack(
2653    brain: &str,
2654    target: &AddressTarget,
2655    pack: Vec<u8>,
2656) -> LinkResult<Value> {
2657    let entries = parse_store_pack(pack)?;
2658    let mut matched: Option<(String, Vec<u8>)> = None;
2659
2660    for (path, bytes) in entries {
2661        let is_candidate = match target {
2662            AddressTarget::Path(want) => &path == want,
2663            AddressTarget::Id(_) => {
2664                path.ends_with(".md")
2665                    && (path.starts_with("records/") || path.starts_with("sources/"))
2666            }
2667        };
2668        if !is_candidate {
2669            continue;
2670        }
2671        let text = std::str::from_utf8(&bytes)
2672            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2673        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2674            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2675        if let AddressTarget::Id(want) = target {
2676            let frontmatter =
2677                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2678                    .map_err(|_| {
2679                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2680                    })?;
2681            if frontmatter.id.as_deref() != Some(want) {
2682                continue;
2683            }
2684        }
2685        if matched.is_some() {
2686            return Err(invalid_feed(
2687                "signed snapshot contains more than one record for the requested target",
2688            ));
2689        }
2690        matched = Some((path, bytes));
2691    }
2692
2693    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2694        what: "resolve",
2695        status: 404,
2696        message: "record not found".to_string(),
2697        code: Some("NOT_FOUND".to_string()),
2698        details: None,
2699    })?;
2700    resolve_from_verified_record_bytes(brain, target, path, bytes)
2701}
2702
2703fn resolve_from_verified_record_bytes(
2704    brain: &str,
2705    target: &AddressTarget,
2706    path: String,
2707    bytes: Vec<u8>,
2708) -> LinkResult<Value> {
2709    match target {
2710        AddressTarget::Path(expected) if expected != &path => {
2711            return Err(invalid_feed(
2712                "verified record path differs from the requested path",
2713            ));
2714        }
2715        AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2716            return Err(invalid_feed(
2717                "verified id resolved outside records or sources",
2718            ));
2719        }
2720        _ => {}
2721    }
2722    let text = std::str::from_utf8(&bytes)
2723        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2724    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2725        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2726    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2727        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2728    let Value::Object(fields) = frontmatter else {
2729        return Err(invalid_feed(format!(
2730            "signed snapshot record `{path}` frontmatter is not a mapping"
2731        )));
2732    };
2733    if let AddressTarget::Id(expected) = target {
2734        if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2735            return Err(invalid_feed(
2736                "verified record id differs from the requested id",
2737            ));
2738        }
2739    }
2740    let mut document = serde_json::Map::new();
2741    document.insert("path".to_string(), Value::String(path));
2742    for (key, value) in fields {
2743        document.insert(key, value);
2744    }
2745    document.insert("body".to_string(), Value::String(parsed.body));
2746    document.insert(
2747        "contentSha".to_string(),
2748        Value::String(content_sha256(&bytes)),
2749    );
2750    Ok(json!({
2751        "brain": brain,
2752        "document": Value::Object(document),
2753    }))
2754}
2755
2756// ─────────────────────────────────────────────────────────────────────────────
2757// sync — pull the granted slice as files; push the local store as a snapshot
2758// ─────────────────────────────────────────────────────────────────────────────
2759
2760/// What a pull materialized.
2761#[derive(Debug, Clone, serde::Serialize)]
2762pub struct PullReport {
2763    /// The brain id the hub reported.
2764    pub brain: String,
2765    /// The brain's slug.
2766    pub slug: String,
2767    /// The hub's feed head at export time.
2768    #[serde(rename = "headSeq")]
2769    pub head_seq: u64,
2770    /// How many files were written.
2771    pub files: usize,
2772    /// Where they were written (as given or derived from the slug).
2773    pub dest: String,
2774    /// Local content files that the export did not carry — present so a
2775    /// caller sees divergence; nothing is ever deleted locally.
2776    #[serde(rename = "extraLocal")]
2777    pub extra_local: Vec<String>,
2778    /// Exact convergence result from the post-install barrier.
2779    #[serde(rename = "syncStatus")]
2780    pub sync_status: String,
2781}
2782
2783struct V2PulledSnapshot {
2784    report: PullReport,
2785    head: V2VerifiedHead,
2786    files: std::collections::BTreeMap<String, V2BaselineFile>,
2787    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2788    local: V2LocalView,
2789    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2790}
2791
2792fn download_verified_snapshot_pack(
2793    cfg: &HubConfig,
2794    brain: &str,
2795    remote: &VerifiedRemote,
2796) -> LinkResult<Vec<u8>> {
2797    let feed_hash = remote
2798        .head
2799        .feed_hash
2800        .as_deref()
2801        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2802    let signed_head = remote
2803        .head_entry
2804        .as_ref()
2805        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2806    let expected = &signed_head.entry.pack_sha256;
2807    if !is_sha256(expected) {
2808        return Err(invalid_feed(
2809            "signed head carries an invalid snapshot pack digest",
2810        ));
2811    }
2812    let path = format!(
2813        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2814        remote.head.seq
2815    );
2816    let body = ensure_ok(
2817        request(cfg, "GET", &path, None, Auth::Required)?,
2818        "sync pull",
2819    )?;
2820    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2821        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2822        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2823        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2824    {
2825        return Err(invalid_feed(
2826            "export response is not bound to the exact verified snapshot",
2827        ));
2828    }
2829    let url = body
2830        .get("url")
2831        .and_then(Value::as_str)
2832        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2833    let bytes = get_presigned(cfg, url)?;
2834    if content_sha256(&bytes) != *expected {
2835        return Err(LinkError::InvalidPack {
2836            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2837        });
2838    }
2839    let entries = parse_store_pack(bytes.clone())?;
2840    if signed_head.entry.kind == "push" {
2841        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2842    }
2843    Ok(bytes)
2844}
2845
2846#[derive(Debug, Clone, Deserialize, Serialize)]
2847struct V2PointerBody {
2848    v: u8,
2849    brain: String,
2850    seq: u64,
2851    commit_hash: String,
2852    feed_hash: String,
2853    content_root: Option<String>,
2854    asset_root: Option<String>,
2855    materializer: String,
2856    signer_epoch: u64,
2857    control_revision: String,
2858    backup_preparation: String,
2859    prior_pointer_hash: Option<String>,
2860    signed_at: String,
2861}
2862
2863#[derive(Debug, Clone, Deserialize)]
2864struct V2SignedPointer {
2865    pointer: V2PointerBody,
2866    hub_public_key: String,
2867    hub_fingerprint: String,
2868    sig: String,
2869}
2870
2871#[derive(Debug, Clone, Deserialize)]
2872struct V2HeadIdentity {
2873    #[serde(default)]
2874    custody: String,
2875    fingerprint: String,
2876    public_key_spki: String,
2877    #[serde(default)]
2878    previous: Vec<V2PreviousIdentity>,
2879    #[serde(default)]
2880    rotations: Vec<String>,
2881}
2882
2883#[derive(Debug, Clone, Deserialize)]
2884struct V2PreviousIdentity {
2885    fingerprint: String,
2886    public_key_spki: String,
2887}
2888
2889#[derive(Debug, Deserialize)]
2890struct V2HeadResponse {
2891    v: u8,
2892    brain_id: String,
2893    profile: String,
2894    view: Option<V2HeadView>,
2895    pointer: Option<V2SignedPointer>,
2896    identity: Option<V2HeadIdentity>,
2897}
2898
2899#[derive(Debug, Clone, Deserialize)]
2900struct V2HeadView {
2901    kind: String,
2902    #[serde(default)]
2903    id: Option<String>,
2904    control_revision: String,
2905}
2906
2907#[derive(Debug, Clone)]
2908struct V2VerifiedHead {
2909    requested: String,
2910    brain_id: String,
2911    view_kind: String,
2912    /// Stable identity of the exact current-content path projection.
2913    view_revision: String,
2914    /// Use-time authority revision independently bound into writes/signatures.
2915    control_revision: String,
2916    identity: V2HeadIdentity,
2917    pointer: Option<V2PointerBody>,
2918    trust: TrustState,
2919    alias: Option<AliasBinding>,
2920}
2921
2922fn verify_v2_spki_signature(
2923    public_key: &str,
2924    message: &[u8],
2925    signature: &str,
2926) -> LinkResult<Vec<u8>> {
2927    let der = URL_SAFE_NO_PAD
2928        .decode(public_key)
2929        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2930    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2931        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2932    }
2933    let sig = URL_SAFE_NO_PAD
2934        .decode(signature)
2935        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2936    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2937        .verify(message, &sig)
2938        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2939    Ok(der)
2940}
2941
2942fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2943    if pointer.pointer.v != 2
2944        || pointer.pointer.brain != expected_brain
2945        || pointer.pointer.seq == 0
2946        || !is_sha256(&pointer.pointer.commit_hash)
2947        || !is_sha256(&pointer.pointer.feed_hash)
2948        || pointer
2949            .pointer
2950            .content_root
2951            .as_deref()
2952            .is_some_and(|hash| !is_sha256(hash))
2953        || !is_sha256(&pointer.pointer.backup_preparation)
2954    {
2955        return Err(invalid_feed("v2 pointer fields are invalid"));
2956    }
2957    let value = serde_json::to_value(&pointer.pointer)
2958        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2959    let message = crate::linkmd_v2::canonical_bytes(&value)
2960        .map_err(|error| invalid_feed(error.to_string()))?;
2961    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2962    let fingerprint = format!("{:x}", Sha256::digest(&der));
2963    if fingerprint != pointer.hub_fingerprint {
2964        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2965    }
2966    Ok(format!(
2967        "{}:{}",
2968        pointer.hub_fingerprint, pointer.hub_public_key
2969    ))
2970}
2971
2972fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2973    FeedIdentity {
2974        fingerprint: identity.fingerprint.clone(),
2975        public_key_spki: identity.public_key_spki.clone(),
2976        previous: identity
2977            .previous
2978            .iter()
2979            .map(|previous| PreviousIdentity {
2980                fingerprint: previous.fingerprint.clone(),
2981                public_key_spki: previous.public_key_spki.clone(),
2982            })
2983            .collect(),
2984        rotations: identity.rotations.clone(),
2985    }
2986}
2987
2988fn verified_v2_commit_object(
2989    raw: &[u8],
2990    identity: &V2HeadIdentity,
2991) -> LinkResult<serde_json::Map<String, Value>> {
2992    let mut value: Value =
2993        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2994    let canonical = crate::linkmd_v2::canonical_bytes(&value)
2995        .map_err(|error| invalid_feed(error.to_string()))?;
2996    if canonical != raw {
2997        return Err(invalid_feed("v2 commit is not canonical JSON"));
2998    }
2999    let object = value
3000        .as_object_mut()
3001        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
3002    let sig = object
3003        .remove("sig")
3004        .and_then(|value| value.as_str().map(str::to_string))
3005        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
3006    const FIELDS: [&str; 18] = [
3007        "actor_ref",
3008        "asset_root",
3009        "brain",
3010        "changes_sha256",
3011        "control_revision",
3012        "materializer",
3013        "op",
3014        "parent_asset_root",
3015        "parent_commit",
3016        "parent_root",
3017        "prev_entry_hash",
3018        "public_key",
3019        "seq",
3020        "signer_epoch",
3021        "state_root",
3022        "ts",
3023        "v",
3024        "v1_bridge",
3025    ];
3026    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
3027        return Err(invalid_feed("v2 commit has a non-normative field set"));
3028    }
3029    let seq = object
3030        .get("seq")
3031        .and_then(Value::as_u64)
3032        .filter(|seq| *seq > 0)
3033        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
3034    let signer_epoch = object
3035        .get("signer_epoch")
3036        .and_then(Value::as_u64)
3037        .filter(|epoch| *epoch > 0)
3038        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
3039    let hash_or_null = |field: &str| {
3040        object
3041            .get(field)
3042            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
3043    };
3044    if object.get("v").and_then(Value::as_u64) != Some(2)
3045        || object.get("op").and_then(Value::as_str) != Some("changeset")
3046        || !object
3047            .get("changes_sha256")
3048            .and_then(Value::as_str)
3049            .is_some_and(is_sha256)
3050        || !object
3051            .get("actor_ref")
3052            .and_then(Value::as_str)
3053            .is_some_and(is_sha256)
3054        || !object
3055            .get("control_revision")
3056            .and_then(Value::as_str)
3057            .is_some_and(is_sha256)
3058        || !object
3059            .get("state_root")
3060            .and_then(Value::as_str)
3061            .is_some_and(is_sha256)
3062        || !hash_or_null("parent_commit")
3063        || !hash_or_null("parent_root")
3064        || !hash_or_null("parent_asset_root")
3065        || !hash_or_null("asset_root")
3066        || !hash_or_null("prev_entry_hash")
3067        || !object
3068            .get("materializer")
3069            .and_then(Value::as_str)
3070            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
3071        || !object
3072            .get("ts")
3073            .and_then(Value::as_str)
3074            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3075    {
3076        return Err(invalid_feed("v2 commit fields are invalid"));
3077    }
3078    if (seq == 1
3079        && [
3080            "parent_commit",
3081            "parent_root",
3082            "parent_asset_root",
3083            "prev_entry_hash",
3084        ]
3085        .iter()
3086        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3087        || (seq > 1
3088            && ["parent_commit", "parent_root", "prev_entry_hash"]
3089                .iter()
3090                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3091    {
3092        return Err(invalid_feed("v2 commit parent shape is invalid"));
3093    }
3094    match object.get("v1_bridge") {
3095        Some(Value::Null) => {}
3096        Some(Value::Object(bridge))
3097            if seq == 1
3098                && bridge.len() == 3
3099                && bridge
3100                    .get("head_seq")
3101                    .and_then(Value::as_u64)
3102                    .is_some_and(|v| v > 0)
3103                && bridge
3104                    .get("feed_hash")
3105                    .and_then(Value::as_str)
3106                    .is_some_and(is_sha256)
3107                && bridge
3108                    .get("pack_sha256")
3109                    .and_then(Value::as_str)
3110                    .is_some_and(is_sha256) => {}
3111        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3112    }
3113    let public_key = object
3114        .get("public_key")
3115        .and_then(Value::as_str)
3116        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3117    let der = URL_SAFE_NO_PAD
3118        .decode(public_key)
3119        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3120    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3121    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3122        return Err(invalid_feed("v2 commit brain identity mismatch"));
3123    }
3124    // Verify the old-key-signed chain before trusting any prior public key.
3125    verify_identity_chain(&v2_identity(identity), None)?;
3126    // Old commits remain verifiable after brain-key rotation. `previous` is
3127    // newest-first, while signer epochs and rotation statements are oldest-first.
3128    let mut chain: Vec<(&str, &str)> = identity
3129        .previous
3130        .iter()
3131        .rev()
3132        .map(|previous| {
3133            (
3134                previous.fingerprint.as_str(),
3135                previous.public_key_spki.as_str(),
3136            )
3137        })
3138        .collect();
3139    chain.push((&identity.fingerprint, &identity.public_key_spki));
3140    let signer_index = chain.iter().position(|(fingerprint, spki)| {
3141        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3142    });
3143    let Some(signer_index) = signer_index else {
3144        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3145    };
3146    if signer_epoch != signer_index as u64 + 1 {
3147        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3148    }
3149    let lower_boundary = if signer_index == 0 {
3150        None
3151    } else {
3152        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3153            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3154        Some(prior.prior_head_seq)
3155    };
3156    let upper_boundary = if signer_index == identity.rotations.len() {
3157        None
3158    } else {
3159        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3160            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3161        Some(next.prior_head_seq)
3162    };
3163    if lower_boundary.is_some_and(|boundary| seq <= boundary)
3164        || upper_boundary.is_some_and(|boundary| seq > boundary)
3165    {
3166        return Err(invalid_feed(
3167            "v2 commit signer is outside its authenticated rotation epoch",
3168        ));
3169    }
3170    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3171        .map_err(|error| invalid_feed(error.to_string()))?;
3172    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3173    Ok(object.clone())
3174}
3175
3176#[derive(Debug, Deserialize)]
3177struct V2FeedWireEntry {
3178    seq: u64,
3179    commit_hash: String,
3180    feed_hash: String,
3181    bytes_base64: String,
3182}
3183
3184#[derive(Debug, Deserialize)]
3185struct V2FeedPage {
3186    v: u8,
3187    head_seq: u64,
3188    head_commit_hash: String,
3189    head_feed_hash: String,
3190    entries: Vec<V2FeedWireEntry>,
3191    next_after: u64,
3192    complete: bool,
3193}
3194
3195fn replay_v2_feed(
3196    cfg: &HubConfig,
3197    brain: &str,
3198    pointer: &V2PointerBody,
3199    identity: &V2HeadIdentity,
3200    start_after: u64,
3201    start_feed: Option<String>,
3202) -> LinkResult<()> {
3203    let mut after = start_after;
3204    let mut prior_feed = start_feed;
3205    let mut final_object = None;
3206    let mut replayed_entries = 0_u64;
3207    let mut replayed_bytes = 0_u64;
3208    while after < pointer.seq {
3209        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3210        let value = ensure_ok(
3211            request_capped(
3212                cfg,
3213                "GET",
3214                &path,
3215                None,
3216                Auth::Required,
3217                MAX_FEED_REPLAY_BYTES,
3218            )?,
3219            "v2 feed replay",
3220        )?;
3221        let page: V2FeedPage = serde_json::from_value(value)
3222            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3223        if page.v != 2
3224            || page.head_seq != pointer.seq
3225            || page.head_commit_hash != pointer.commit_hash
3226            || page.head_feed_hash != pointer.feed_hash
3227            || page.entries.is_empty()
3228            || page.entries.len() > FEED_PAGE_LIMIT
3229        {
3230            return Err(invalid_feed("v2 feed page differs from the signed head"));
3231        }
3232        for entry in page.entries {
3233            if entry.seq != after + 1
3234                || !is_sha256(&entry.commit_hash)
3235                || !is_sha256(&entry.feed_hash)
3236            {
3237                return Err(invalid_feed("v2 feed sequence is not contiguous"));
3238            }
3239            let raw = base64::engine::general_purpose::STANDARD
3240                .decode(&entry.bytes_base64)
3241                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3242            replayed_entries = replayed_entries
3243                .checked_add(1)
3244                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3245            replayed_bytes = replayed_bytes
3246                .checked_add(raw.len() as u64)
3247                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3248            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3249            {
3250                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3251            }
3252            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3253                .map_err(|error| invalid_feed(error.to_string()))?
3254                != entry.commit_hash
3255                || content_sha256(&raw) != entry.feed_hash
3256            {
3257                return Err(invalid_feed("v2 feed entry address mismatch"));
3258            }
3259            let object = verified_v2_commit_object(&raw, identity)?;
3260            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3261                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3262            {
3263                return Err(invalid_feed(
3264                    "v2 feed entry does not extend its predecessor",
3265                ));
3266            }
3267            after = entry.seq;
3268            prior_feed = Some(entry.feed_hash);
3269            final_object = Some((entry.commit_hash, object));
3270        }
3271        if page.next_after != after || (page.complete != (after == pointer.seq)) {
3272            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3273        }
3274    }
3275    let (final_hash, object) =
3276        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3277    if final_hash != pointer.commit_hash
3278        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3279        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3280        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3281        || object.get("control_revision").and_then(Value::as_str)
3282            != Some(pointer.control_revision.as_str())
3283        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3284    {
3285        return Err(invalid_feed(
3286            "v2 replay did not converge on the signed pointer",
3287        ));
3288    }
3289    Ok(())
3290}
3291
3292fn verify_v1_to_v2_bridge(
3293    cfg: &HubConfig,
3294    brain: &str,
3295    pointer: &V2PointerBody,
3296    identity: &V2HeadIdentity,
3297    checkpoint: &TrustState,
3298) -> LinkResult<()> {
3299    let value = ensure_ok(
3300        request_capped(
3301            cfg,
3302            "GET",
3303            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3304            None,
3305            Auth::Required,
3306            MAX_FEED_RESPONSE_BYTES,
3307        )?,
3308        "v2 genesis bridge",
3309    )?;
3310    let page: V2FeedPage = serde_json::from_value(value)
3311        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3312    if page.v != 2
3313        || page.head_seq != pointer.seq
3314        || page.head_commit_hash != pointer.commit_hash
3315        || page.head_feed_hash != pointer.feed_hash
3316        || page.entries.len() != 1
3317        || page.entries[0].seq != 1
3318        || !is_sha256(&page.entries[0].commit_hash)
3319        || !is_sha256(&page.entries[0].feed_hash)
3320    {
3321        return Err(invalid_feed(
3322            "v2 genesis bridge page differs from the signed head",
3323        ));
3324    }
3325    let first = &page.entries[0];
3326    let raw = STANDARD
3327        .decode(&first.bytes_base64)
3328        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3329    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3330        .map_err(|error| invalid_feed(error.to_string()))?
3331        != first.commit_hash
3332        || content_sha256(&raw) != first.feed_hash
3333    {
3334        return Err(invalid_feed("v2 genesis bridge address mismatch"));
3335    }
3336    let object = verified_v2_commit_object(&raw, identity)?;
3337    if checkpoint.head_seq == 0 {
3338        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3339            return Err(invalid_feed(
3340                "empty v1 checkpoint did not transition through an empty v2 genesis",
3341            ));
3342        }
3343        return Ok(());
3344    }
3345    let bridge = object
3346        .get("v1_bridge")
3347        .and_then(Value::as_object)
3348        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3349    let checkpoint_feed = checkpoint
3350        .feed_hash
3351        .as_deref()
3352        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3353    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3354        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3355    {
3356        return Err(invalid_feed(
3357            "v2 genesis bridge differs from the pinned v1 checkpoint",
3358        ));
3359    }
3360    let legacy_raw = ensure_raw_ok(
3361        request_raw(
3362            cfg,
3363            "GET",
3364            &format!(
3365                "/api/hub/brains/{brain}/feed?after={}&limit=1",
3366                checkpoint.head_seq - 1
3367            ),
3368            None,
3369            Auth::Required,
3370            MAX_FEED_RESPONSE_BYTES,
3371        )?,
3372        "v1 bridge boundary",
3373    )?;
3374    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3375        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3376    let legacy_identity = legacy
3377        .identity
3378        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3379    let item = legacy
3380        .entries
3381        .first()
3382        .filter(|_| legacy.entries.len() == 1)
3383        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3384    if legacy.scope_limited
3385        || legacy.head_seq != checkpoint.head_seq
3386        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3387        || item.entry.seq != checkpoint.head_seq
3388        || item.hash != checkpoint_feed
3389        || legacy_identity != v2_identity(identity)
3390        || bridge.get("pack_sha256").and_then(Value::as_str)
3391            != Some(item.entry.pack_sha256.as_str())
3392    {
3393        return Err(invalid_feed(
3394            "v1 bridge boundary differs from its signed legacy head",
3395        ));
3396    }
3397    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3398    if anchor != checkpoint.anchor {
3399        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3400    }
3401    verify_feed_item(item, &legacy_identity)?;
3402    verify_rotation_feed_boundaries(
3403        &legacy_identity,
3404        Some(checkpoint),
3405        std::slice::from_ref(item),
3406        checkpoint.head_seq,
3407    )?;
3408    Ok(())
3409}
3410
3411fn verify_v2_commit(
3412    cfg: &HubConfig,
3413    brain: &str,
3414    pointer: &V2PointerBody,
3415    identity: &V2HeadIdentity,
3416    pinned: Option<&TrustState>,
3417) -> LinkResult<()> {
3418    let path = format!(
3419        "/api/hub/brains/{brain}/v2/commit?commit={}",
3420        pointer.commit_hash
3421    );
3422    let raw = ensure_raw_ok(
3423        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3424        "v2 commit",
3425    )?;
3426    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3427        .map_err(|error| invalid_feed(error.to_string()))?
3428        != pointer.commit_hash
3429        || content_sha256(&raw) != pointer.feed_hash
3430    {
3431        return Err(invalid_feed("v2 commit address differs from the pointer"));
3432    }
3433    let object = verified_v2_commit_object(&raw, identity)?;
3434    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3435        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3436        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3437        || object.get("control_revision").and_then(Value::as_str)
3438            != Some(pointer.control_revision.as_str())
3439        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3440    {
3441        return Err(invalid_feed("v2 commit fields differ from the pointer"));
3442    }
3443    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3444        if pointer.seq == checkpoint.head_seq + 1
3445            && object.get("prev_entry_hash").and_then(Value::as_str)
3446                != checkpoint.feed_hash.as_deref()
3447        {
3448            return Err(invalid_feed(
3449                "v2 commit does not extend the pinned feed hash",
3450            ));
3451        }
3452        if pointer.seq > checkpoint.head_seq + 1 {
3453            return replay_v2_feed(
3454                cfg,
3455                brain,
3456                pointer,
3457                identity,
3458                checkpoint.head_seq,
3459                checkpoint.feed_hash.clone(),
3460            );
3461        }
3462    } else {
3463        if let Some(checkpoint) = pinned {
3464            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3465        }
3466        if pointer.seq > 1 {
3467            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3468        }
3469    }
3470    Ok(())
3471}
3472
3473fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3474    require_hardened_filesystem("verified link.md v2 state")?;
3475    require_safe_ref(brain)?;
3476    // Open and pin the private trust directory before a credential can leave
3477    // the process. A hostile checkout must not turn an unsafe state root into
3478    // a network oracle or receive a bearer before checkpoint safety is known.
3479    let trust_directory = open_trust_dir(cfg)?;
3480    let path = format!("/api/hub/brains/{brain}/v2/head");
3481    let response = request(cfg, "GET", &path, None, Auth::Required)?;
3482    if response.status == 404 {
3483        if has_accepted_v2_ref(cfg, brain)? {
3484            return Err(LinkError::BrainUnavailable);
3485        }
3486        return Ok(None);
3487    }
3488    let body = ensure_ok(response, "v2 head")?;
3489    let head: V2HeadResponse = serde_json::from_value(body)
3490        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3491    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3492        return Err(invalid_feed("v2 head has no canonical brain id"));
3493    }
3494    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3495        return Err(invalid_feed("v2 head resolved a different brain id"));
3496    }
3497    if head.profile == "v1" {
3498        return Ok(None);
3499    }
3500    if head.profile != "v2" && head.profile != "v2-empty" {
3501        return Err(invalid_feed("v2 head advertised an unknown profile"));
3502    }
3503    let view = head
3504        .view
3505        .as_ref()
3506        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3507    if !matches!(view.kind.as_str(), "full" | "scoped")
3508        || !is_sha256(&view.control_revision)
3509        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3510    {
3511        return Err(invalid_feed("v2 head has an invalid permission view"));
3512    }
3513    let view_kind = view.kind.clone();
3514    // `id` is the stable materialized namespace. Older v2 hubs did not expose
3515    // it, so retain the fail-closed control-revision fallback for compatibility.
3516    let view_revision = view
3517        .id
3518        .clone()
3519        .unwrap_or_else(|| view.control_revision.clone());
3520    let control_revision = view.control_revision.clone();
3521    let identity = head
3522        .identity
3523        .as_ref()
3524        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3525    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3526    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3527    let feed_identity = v2_identity(identity);
3528    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3529    let (seq, feed_hash, hub_signer) = match &head.pointer {
3530        None => {
3531            if head.profile != "v2-empty" {
3532                return Err(invalid_feed("initialized v2 head has no pointer"));
3533            }
3534            (
3535                0,
3536                None,
3537                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3538            )
3539        }
3540        Some(signed) => {
3541            let signer = verify_v2_pointer(signed, &head.brain_id)?;
3542            if pinned
3543                .as_ref()
3544                .and_then(|state| state.hub_signer.as_ref())
3545                .is_some_and(|known| known != &signer)
3546            {
3547                return Err(invalid_feed(
3548                    "v2 hub pointer signer changed without a trust transition",
3549                ));
3550            }
3551            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3552                if signed.pointer.seq < checkpoint.head_seq
3553                    || (signed.pointer.seq == checkpoint.head_seq
3554                        && checkpoint.feed_hash.as_deref()
3555                            != Some(signed.pointer.feed_hash.as_str()))
3556                {
3557                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3558                }
3559            }
3560            verify_v2_commit(
3561                cfg,
3562                &head.brain_id,
3563                &signed.pointer,
3564                identity,
3565                pinned.as_ref(),
3566            )?;
3567            (
3568                signed.pointer.seq,
3569                Some(signed.pointer.feed_hash.clone()),
3570                Some(signer),
3571            )
3572        }
3573    };
3574    let trust = TrustState {
3575        v: 2,
3576        origin: normalized_origin(&cfg.hub)?,
3577        requested: head.brain_id.clone(),
3578        brain: head.brain_id.clone(),
3579        home: None,
3580        anchor,
3581        current: format!("ed25519:{}", identity.fingerprint),
3582        head_seq: seq,
3583        feed_hash,
3584        rotations: identity.rotations.clone(),
3585        hub_signer,
3586        protocol_profile: Some("link-v2".to_string()),
3587    };
3588    Ok(Some(V2VerifiedHead {
3589        requested: brain.to_string(),
3590        brain_id: head.brain_id,
3591        view_kind,
3592        view_revision,
3593        control_revision,
3594        identity: identity.clone(),
3595        pointer: head.pointer.map(|signed| signed.pointer),
3596        trust,
3597        alias: alias_binding,
3598    }))
3599}
3600
3601fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3602    let directory = open_trust_dir(cfg)?;
3603    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3604    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3605    if let Some(current) = current {
3606        let common_invalid = head.trust.anchor != current.anchor
3607            || !head.trust.rotations.starts_with(&current.rotations);
3608        let profile_invalid = if accepted_as_v2(&current) {
3609            head.trust.head_seq < current.head_seq
3610                || (head.trust.head_seq == current.head_seq
3611                    && head.trust.feed_hash != current.feed_hash)
3612                || current
3613                    .hub_signer
3614                    .as_ref()
3615                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3616        } else {
3617            head.trust.protocol_profile.as_deref() != Some("link-v2")
3618                || head.trust.hub_signer.is_none()
3619        };
3620        if common_invalid || profile_invalid {
3621            return Err(invalid_feed(
3622                "v2 head cannot advance the currently accepted trust checkpoint",
3623            ));
3624        }
3625    }
3626    save_canonical_pin_and_alias(
3627        cfg,
3628        &directory,
3629        &head.requested,
3630        &head.brain_id,
3631        head.trust.clone(),
3632        alias.as_ref().or(head.alias.as_ref()),
3633    )
3634}
3635
3636#[derive(Debug, Clone, Deserialize, Serialize)]
3637struct V2BaselineFile {
3638    sha256: String,
3639    bytes: u64,
3640    #[serde(skip)]
3641    proof: Option<Vec<V2ProofStep>>,
3642}
3643
3644#[derive(Debug, Clone, Deserialize, Serialize)]
3645struct V2SyncBaseline {
3646    v: u8,
3647    origin: String,
3648    brain: String,
3649    #[serde(default)]
3650    checkout_id: Option<String>,
3651    #[serde(default)]
3652    head_seq: Option<u64>,
3653    commit_hash: Option<String>,
3654    content_root: Option<String>,
3655    #[serde(default)]
3656    asset_root: Option<String>,
3657    #[serde(default)]
3658    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3659    #[serde(default)]
3660    view_kind: Option<String>,
3661    #[serde(default)]
3662    view_revision: Option<String>,
3663    /// The authority revision observed with this materialized view. Older
3664    /// baselines omit it and deliberately take one full verified refresh
3665    /// before they become eligible for unchanged-head reuse.
3666    #[serde(default)]
3667    control_revision: Option<String>,
3668    #[serde(default)]
3669    projection_sha256: Option<String>,
3670    files: std::collections::BTreeMap<String, V2BaselineFile>,
3671    #[serde(default)]
3672    local_policy_digest: Option<String>,
3673    #[serde(default)]
3674    local_eligibility: std::collections::BTreeMap<String, bool>,
3675    #[serde(default)]
3676    remote_copy_remains: std::collections::BTreeMap<String, String>,
3677}
3678
3679struct V2LocalView {
3680    riding: std::collections::BTreeMap<String, (String, u64)>,
3681    eligibility: std::collections::BTreeMap<String, bool>,
3682    policy: crate::linkmd_sync_policy::SyncPolicy,
3683    withheld_links: Vec<V2WithheldLink>,
3684}
3685
3686#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3687struct V2WithheldLink {
3688    source: String,
3689    target: String,
3690}
3691
3692#[derive(Debug, Clone, Deserialize, Serialize)]
3693struct V2ProofStep {
3694    directory_root: String,
3695    component: String,
3696    proof: crate::linkmd_v2::HamtProof,
3697}
3698
3699#[derive(Debug, Deserialize)]
3700struct V2ManifestFile {
3701    path: String,
3702    sha256: String,
3703    bytes: u64,
3704    proof: Vec<V2ProofStep>,
3705}
3706
3707#[derive(Debug, Deserialize)]
3708struct V2ManifestPage {
3709    v: u8,
3710    commit: String,
3711    content_root: Option<String>,
3712    files: Vec<V2ManifestFile>,
3713    next_cursor: Option<String>,
3714}
3715
3716#[derive(Debug, Clone, Deserialize, Serialize)]
3717struct V2BaselineAsset {
3718    blob_sha256: String,
3719    bytes: u64,
3720    media_type: String,
3721    wrappers: Vec<String>,
3722    required: bool,
3723    disposition: String,
3724    leaf_hash: String,
3725}
3726
3727#[derive(Debug, Deserialize)]
3728struct V2AssetManifestItem {
3729    path: String,
3730    blob_sha256: String,
3731    bytes: u64,
3732    media_type: String,
3733    wrappers: Vec<String>,
3734    required: bool,
3735    disposition: String,
3736    leaf_hash: String,
3737    proof: crate::linkmd_v2::HamtProof,
3738}
3739
3740#[derive(Debug, Deserialize)]
3741struct V2AssetManifestPage {
3742    v: u8,
3743    commit: String,
3744    asset_root: Option<String>,
3745    assets: Vec<V2AssetManifestItem>,
3746    next_cursor: Option<String>,
3747}
3748
3749#[derive(Debug, Deserialize)]
3750struct V2SigningCandidate {
3751    seq: u64,
3752    content_root: Option<String>,
3753    asset_root: Option<String>,
3754    signing_bytes_base64: String,
3755    changes_base64: String,
3756    actor_claim_base64: String,
3757}
3758
3759#[derive(Debug, Deserialize)]
3760struct V2SigningCandidatePage {
3761    v: u8,
3762    challenge_id: String,
3763    mutation_id: String,
3764    request_hash: String,
3765    parent: V2SigningParent,
3766    candidate: V2SigningCandidate,
3767    files: Vec<V2ManifestFile>,
3768    #[serde(default)]
3769    assets: Vec<V2AssetManifestItem>,
3770    next_cursor: Option<String>,
3771    expires_at: String,
3772}
3773
3774#[derive(Debug, Deserialize)]
3775struct V2SigningParent {
3776    seq: u64,
3777    commit_hash: Option<String>,
3778}
3779
3780fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3781    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3782        .map_err(|error| invalid_feed(error.to_string()))?;
3783    let components = normalized.split('/').collect::<Vec<_>>();
3784    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3785        return Err(invalid_feed("v2 file proof has the wrong shape"));
3786    }
3787    let mut directory_root = root.to_string();
3788    for (index, step) in file.proof.iter().enumerate() {
3789        if step.directory_root != directory_root || step.component != components[index] {
3790            return Err(invalid_feed(
3791                "v2 file proof path chain differs from its manifest",
3792            ));
3793        }
3794        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3795            .map_err(|error| invalid_feed(error.to_string()))?
3796        {
3797            return Err(invalid_feed("v2 file proof failed verification"));
3798        }
3799        let entry = match &step.proof {
3800            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3801            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3802                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3803            }
3804        };
3805        if index + 1 == components.len() {
3806            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3807                || entry.child_hash != file.sha256
3808                || entry.bytes != Some(file.bytes)
3809            {
3810                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3811            }
3812        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3813            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3814        } else {
3815            directory_root = entry.child_hash.clone();
3816        }
3817    }
3818    Ok(())
3819}
3820
3821fn v2_manifest(
3822    cfg: &HubConfig,
3823    brain: &str,
3824    pointer: Option<&V2PointerBody>,
3825) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3826    let Some(pointer) = pointer else {
3827        return Ok(std::collections::BTreeMap::new());
3828    };
3829    let Some(root) = pointer.content_root.as_deref() else {
3830        return Ok(std::collections::BTreeMap::new());
3831    };
3832    let mut files = std::collections::BTreeMap::new();
3833    let mut after = String::new();
3834    loop {
3835        let encoded_after: String =
3836            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3837        let path = format!(
3838            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3839            pointer.commit_hash
3840        );
3841        let value = ensure_ok(
3842            request_capped(
3843                cfg,
3844                "GET",
3845                &path,
3846                None,
3847                Auth::Required,
3848                MAX_FEED_RESPONSE_BYTES,
3849            )?,
3850            "v2 file manifest",
3851        )?;
3852        let page: V2ManifestPage = serde_json::from_value(value)
3853            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3854        if page.v != 2
3855            || page.commit != pointer.commit_hash
3856            || page.content_root.as_deref() != Some(root)
3857            || page.files.len() > 500
3858        {
3859            return Err(invalid_feed(
3860                "v2 file manifest is not bound to the verified head",
3861            ));
3862        }
3863        for file in page.files {
3864            verify_v2_file_proof(root, &file)?;
3865            if files
3866                .insert(
3867                    file.path.clone(),
3868                    V2BaselineFile {
3869                        sha256: file.sha256,
3870                        bytes: file.bytes,
3871                        proof: Some(file.proof),
3872                    },
3873                )
3874                .is_some()
3875            {
3876                return Err(invalid_feed("v2 file manifest repeats a path"));
3877            }
3878            if files.len() > MAX_PUSH_FILES {
3879                return Err(invalid_feed(
3880                    "v2 file manifest exceeds the file-count bound",
3881                ));
3882            }
3883        }
3884        match page.next_cursor {
3885            None => break,
3886            Some(next) if next > after => after = next,
3887            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3888        }
3889    }
3890    Ok(files)
3891}
3892
3893/// Fetch one exact path proof instead of enumerating the whole permission
3894/// view. This keeps `resolve @brain/path` proportional to path depth even for
3895/// a very large brain. The returned leaf is accepted only after its complete
3896/// proof chain reaches the content root signed by `pointer`.
3897fn v2_manifest_file(
3898    cfg: &HubConfig,
3899    brain: &str,
3900    pointer: &V2PointerBody,
3901    path: &str,
3902) -> LinkResult<Option<V2BaselineFile>> {
3903    let Some(root) = pointer.content_root.as_deref() else {
3904        return Ok(None);
3905    };
3906    crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3907        path: error.to_string(),
3908    })?;
3909    let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3910    let response = request_capped(
3911        cfg,
3912        "GET",
3913        &format!(
3914            "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3915            pointer.commit_hash
3916        ),
3917        None,
3918        Auth::Required,
3919        MAX_FEED_RESPONSE_BYTES,
3920    )?;
3921    // Exact v2 file reads deliberately collapse absent and unreadable paths
3922    // into the same 404. Preserve that non-oracle as a normal `None`; callers
3923    // such as `resolve` then produce the stable, local `NOT_FOUND` contract.
3924    if response.status == 404 {
3925        return Ok(None);
3926    }
3927    let value = ensure_ok(response, "v2 exact file proof")?;
3928    let mut page: V2ManifestPage = serde_json::from_value(value)
3929        .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3930    if page.v != 2
3931        || page.commit != pointer.commit_hash
3932        || page.content_root.as_deref() != Some(root)
3933        || page.next_cursor.is_some()
3934        || page.files.len() != 1
3935        || page.files[0].path != path
3936    {
3937        return Err(invalid_feed(
3938            "v2 exact file proof is not bound to the requested signed path",
3939        ));
3940    }
3941    let file = page.files.pop().expect("exactly one file was checked");
3942    verify_v2_file_proof(root, &file)?;
3943    Ok(Some(V2BaselineFile {
3944        sha256: file.sha256,
3945        bytes: file.bytes,
3946        proof: Some(file.proof),
3947    }))
3948}
3949
3950/// Resolve an id through the hub's synchronously maintained, content-light
3951/// validation certificate, then require the same exact signed path proof as a
3952/// path lookup. The certificate is only an efficient locator: the downloaded
3953/// bytes are parsed again and their id is checked by the caller.
3954fn v2_manifest_file_by_id(
3955    cfg: &HubConfig,
3956    brain: &str,
3957    pointer: &V2PointerBody,
3958    id: &str,
3959) -> LinkResult<(String, V2BaselineFile)> {
3960    let root = pointer
3961        .content_root
3962        .as_deref()
3963        .ok_or_else(|| LinkError::Http {
3964            what: "resolve",
3965            status: 404,
3966            message: "record not found".to_string(),
3967            code: Some("NOT_FOUND".to_string()),
3968            details: None,
3969        })?;
3970    if !crate::ulid::is_ulid(id) {
3971        return Err(LinkError::BadAddress {
3972            given: id.to_string(),
3973            reason: BAD_TARGET_REASON.to_string(),
3974        });
3975    }
3976    let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3977    let value = ensure_ok(
3978        request_capped(
3979            cfg,
3980            "GET",
3981            &format!(
3982                "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3983                pointer.commit_hash
3984            ),
3985            None,
3986            Auth::Required,
3987            MAX_FEED_RESPONSE_BYTES,
3988        )?,
3989        "v2 exact id proof",
3990    )?;
3991    let mut page: V2ManifestPage = serde_json::from_value(value)
3992        .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3993    if page.v != 2
3994        || page.commit != pointer.commit_hash
3995        || page.content_root.as_deref() != Some(root)
3996        || page.next_cursor.is_some()
3997        || page.files.len() != 1
3998    {
3999        return Err(invalid_feed(
4000            "v2 exact id proof is not bound to one signed path",
4001        ));
4002    }
4003    let file = page.files.pop().expect("exactly one file was checked");
4004    if !safe_store_rel_path(&file.path)
4005        || !file.path.ends_with(".md")
4006        || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4007    {
4008        return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4009    }
4010    verify_v2_file_proof(root, &file)?;
4011    Ok((
4012        file.path,
4013        V2BaselineFile {
4014            sha256: file.sha256,
4015            bytes: file.bytes,
4016            proof: Some(file.proof),
4017        },
4018    ))
4019}
4020
4021fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4022    crate::linkmd_v2::normalize_path(&item.path)
4023        .map_err(|error| invalid_feed(error.to_string()))?;
4024    if !is_sha256(&item.blob_sha256)
4025        || !is_sha256(&item.leaf_hash)
4026        || item.bytes > MAX_ASSET_BYTES
4027        || item.wrappers.is_empty()
4028        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4029        || item
4030            .wrappers
4031            .iter()
4032            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4033    {
4034        return Err(invalid_feed("v2 asset manifest item is invalid"));
4035    }
4036    let leaf = json!({
4037        "blob_sha256": item.blob_sha256,
4038        "bytes": item.bytes,
4039        "disposition": item.disposition,
4040        "media_type": item.media_type,
4041        "path": item.path,
4042        "required": item.required,
4043        "v": 2,
4044        "wrappers": item.wrappers,
4045    });
4046    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4047        .map_err(|error| invalid_feed(error.to_string()))?
4048        != item.leaf_hash
4049        || !crate::linkmd_v2::verify_proof_with_domain(
4050            root,
4051            &item.path,
4052            &item.proof,
4053            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4054        )
4055        .map_err(|error| invalid_feed(error.to_string()))?
4056    {
4057        return Err(invalid_feed("v2 asset inclusion proof failed"));
4058    }
4059    match &item.proof {
4060        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4061            if entry.name == item.path
4062                && entry.kind == crate::linkmd_v2::EntryKind::Blob
4063                && entry.child_hash == item.leaf_hash
4064                && entry.bytes == Some(item.bytes) =>
4065        {
4066            Ok(())
4067        }
4068        _ => Err(invalid_feed(
4069            "v2 asset proof leaf differs from its manifest",
4070        )),
4071    }
4072}
4073
4074fn v2_asset_manifest(
4075    cfg: &HubConfig,
4076    brain: &str,
4077    pointer: Option<&V2PointerBody>,
4078) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4079    let Some(pointer) = pointer else {
4080        return Ok(std::collections::BTreeMap::new());
4081    };
4082    let Some(root) = pointer.asset_root.as_deref() else {
4083        return Ok(std::collections::BTreeMap::new());
4084    };
4085    let mut assets = std::collections::BTreeMap::new();
4086    let mut after = String::new();
4087    loop {
4088        let encoded_after: String =
4089            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4090        let path = format!(
4091            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4092            pointer.commit_hash
4093        );
4094        let value = ensure_ok(
4095            request_capped(
4096                cfg,
4097                "GET",
4098                &path,
4099                None,
4100                Auth::Required,
4101                MAX_FEED_RESPONSE_BYTES,
4102            )?,
4103            "v2 asset manifest",
4104        )?;
4105        let page: V2AssetManifestPage = serde_json::from_value(value)
4106            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4107        if page.v != 2
4108            || page.commit != pointer.commit_hash
4109            || page.asset_root.as_deref() != Some(root)
4110            || page.assets.len() > 500
4111        {
4112            return Err(invalid_feed(
4113                "v2 asset manifest is not bound to the verified head",
4114            ));
4115        }
4116        for item in page.assets {
4117            verify_v2_asset_proof(root, &item)?;
4118            let path = item.path.clone();
4119            if assets
4120                .insert(
4121                    path,
4122                    V2BaselineAsset {
4123                        blob_sha256: item.blob_sha256,
4124                        bytes: item.bytes,
4125                        media_type: item.media_type,
4126                        wrappers: item.wrappers,
4127                        required: item.required,
4128                        disposition: item.disposition,
4129                        leaf_hash: item.leaf_hash,
4130                    },
4131                )
4132                .is_some()
4133            {
4134                return Err(invalid_feed("v2 asset manifest repeats a path"));
4135            }
4136            if assets.len() > MAX_PUSH_FILES {
4137                return Err(invalid_feed(
4138                    "v2 asset manifest exceeds the item-count bound",
4139                ));
4140            }
4141        }
4142        match page.next_cursor {
4143            None => break,
4144            Some(next) if next > after => after = next,
4145            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4146        }
4147    }
4148    Ok(assets)
4149}
4150
4151fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4152    crate::AssetRecord {
4153        path: path.to_string(),
4154        sha256: asset.blob_sha256.clone(),
4155        bytes: asset.bytes,
4156        media_type: asset.media_type.clone(),
4157        wrappers: asset.wrappers.clone(),
4158        required: asset.required,
4159    }
4160}
4161
4162fn v2_asset_resumes_hosting(
4163    remote: Option<&V2BaselineAsset>,
4164    path: &str,
4165    record: &crate::AssetRecord,
4166    disposition: &str,
4167) -> bool {
4168    remote.is_some_and(|asset| {
4169        asset.disposition == "withheld"
4170            && disposition == "hosted"
4171            && v2_asset_record(asset, path) == *record
4172    })
4173}
4174
4175fn v2_asset_record_manifest_bytes(
4176    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4177) -> LinkResult<Vec<u8>> {
4178    let mut bytes = Vec::new();
4179    for (path, asset) in assets {
4180        if asset.path != *path {
4181            return Err(invalid_feed(
4182                "local asset manifest key differs from its record path",
4183            ));
4184        }
4185        serde_json::to_writer(&mut bytes, asset)
4186            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4187        bytes.push(b'\n');
4188    }
4189    Ok(bytes)
4190}
4191
4192fn v2_local_asset_records(
4193    store: &Store,
4194) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4195    let assets = crate::assets::read_manifest(store)
4196        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4197    if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4198        return Err(LinkError::InvalidPack {
4199            message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4200        });
4201    }
4202    Ok(assets
4203        .into_iter()
4204        .map(|asset| (asset.path.clone(), asset))
4205        .collect())
4206}
4207
4208fn v2_asset_records_match_remote(
4209    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4210    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4211) -> bool {
4212    local.len() == remote.len()
4213        && remote
4214            .iter()
4215            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4216}
4217
4218#[derive(Debug, Clone, PartialEq, Eq)]
4219struct V2PulledMerge<T> {
4220    records: std::collections::BTreeMap<String, T>,
4221    accept_remote: std::collections::BTreeSet<String>,
4222    conflicts: Vec<String>,
4223}
4224
4225/// Compute the local checkout that a pull may install without discarding a
4226/// local-only edit. Remote-only changes are accepted, identical concurrent
4227/// changes converge, and divergent concurrent changes are reported. The
4228/// caller can exempt kept-home coordinates from both remote installation and
4229/// conflict disclosure.
4230fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4231    base: &std::collections::BTreeMap<String, Base>,
4232    remote: &std::collections::BTreeMap<String, Remote>,
4233    local: &std::collections::BTreeMap<String, Record>,
4234    base_record: BaseRecord,
4235    remote_record: RemoteRecord,
4236    keep_local: KeepLocal,
4237) -> V2PulledMerge<Record>
4238where
4239    Record: Clone + Eq,
4240    BaseRecord: Fn(&Base, &str) -> Record,
4241    RemoteRecord: Fn(&Remote, &str) -> Record,
4242    KeepLocal: Fn(&str) -> bool,
4243{
4244    let paths = base
4245        .keys()
4246        .chain(remote.keys())
4247        .chain(local.keys())
4248        .cloned()
4249        .collect::<std::collections::BTreeSet<_>>();
4250    let mut records = local.clone();
4251    let mut accept_remote = std::collections::BTreeSet::new();
4252    let mut conflicts = Vec::new();
4253    for path in paths {
4254        if keep_local(&path) {
4255            continue;
4256        }
4257        let base_value = base.get(&path).map(|value| base_record(value, &path));
4258        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4259        let local_value = local.get(&path).cloned();
4260        if local_value != base_value && remote_value != base_value && local_value != remote_value {
4261            conflicts.push(path);
4262            continue;
4263        }
4264        if local_value == base_value || local_value == remote_value {
4265            accept_remote.insert(path.clone());
4266            match remote_value {
4267                Some(value) => {
4268                    records.insert(path, value);
4269                }
4270                None => {
4271                    records.remove(&path);
4272                }
4273            }
4274        }
4275    }
4276    V2PulledMerge {
4277        records,
4278        accept_remote,
4279        conflicts,
4280    }
4281}
4282
4283fn sign_verified_v2_candidate(
4284    cfg: &HubConfig,
4285    head: &V2VerifiedHead,
4286    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4287    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4288    mutation_id: &str,
4289    request_body: &Value,
4290    challenge_value: &Value,
4291) -> LinkResult<(String, String, String)> {
4292    if head.view_kind != "full" {
4293        return Err(invalid_feed(
4294            "a scoped self-custody writer must use the proposal workflow",
4295        ));
4296    }
4297    if head.identity.custody != "self" {
4298        return Err(invalid_feed(
4299            "a hub-custodied brain unexpectedly requested an external signature",
4300        ));
4301    }
4302    let key = cfg
4303        .brain_key
4304        .as_ref()
4305        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4306    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4307        || key.public_key_spki != head.identity.public_key_spki
4308    {
4309        return Err(bad_agent_key(
4310            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4311        ));
4312    }
4313    let challenge_id = challenge_value
4314        .get("id")
4315        .and_then(Value::as_str)
4316        .filter(|id| crate::ulid::is_ulid(id))
4317        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4318    let expected_endpoint = format!(
4319        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4320        head.brain_id
4321    );
4322    if challenge_value
4323        .get("candidate_endpoint")
4324        .and_then(Value::as_str)
4325        != Some(expected_endpoint.as_str())
4326    {
4327        return Err(invalid_feed(
4328            "self-custody challenge candidate endpoint is not origin-bound",
4329        ));
4330    }
4331
4332    let mut files = std::collections::BTreeMap::new();
4333    let mut after = String::new();
4334    type CandidateCoordinate = (
4335        String,
4336        String,
4337        String,
4338        String,
4339        Option<String>,
4340        Option<String>,
4341        u64,
4342        Option<String>,
4343    );
4344    let mut pinned: Option<CandidateCoordinate> = None;
4345    loop {
4346        let encoded_after: String =
4347            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4348        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4349        let value = ensure_ok(
4350            request_capped(
4351                cfg,
4352                "GET",
4353                &path,
4354                None,
4355                Auth::Required,
4356                MAX_FEED_RESPONSE_BYTES,
4357            )?,
4358            "v2 self-custody candidate",
4359        )?;
4360        let page: V2SigningCandidatePage = serde_json::from_value(value)
4361            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4362        if page.v != 2
4363            || page.challenge_id != challenge_id
4364            || page.mutation_id != mutation_id
4365            || page.candidate.seq != page.parent.seq + 1
4366            || page.files.len() > 500
4367            || page.expires_at.is_empty()
4368        {
4369            return Err(invalid_feed(
4370                "self-custody candidate is not bound to this mutation",
4371            ));
4372        }
4373        let coordinate = (
4374            page.request_hash.clone(),
4375            page.candidate.signing_bytes_base64.clone(),
4376            page.candidate.changes_base64.clone(),
4377            page.candidate.actor_claim_base64.clone(),
4378            page.candidate.content_root.clone(),
4379            page.candidate.asset_root.clone(),
4380            page.parent.seq,
4381            page.parent.commit_hash.clone(),
4382        );
4383        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4384            return Err(invalid_feed(
4385                "self-custody candidate changed between manifest pages",
4386            ));
4387        }
4388        pinned = Some(coordinate);
4389        let root = page
4390            .candidate
4391            .content_root
4392            .as_deref()
4393            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4394        for file in page.files {
4395            verify_v2_file_proof(root, &file)?;
4396            if files
4397                .insert(
4398                    file.path.clone(),
4399                    V2BaselineFile {
4400                        sha256: file.sha256,
4401                        bytes: file.bytes,
4402                        proof: Some(file.proof),
4403                    },
4404                )
4405                .is_some()
4406            {
4407                return Err(invalid_feed(
4408                    "self-custody candidate repeats a manifest path",
4409                ));
4410            }
4411            if files.len() > MAX_PUSH_FILES {
4412                return Err(invalid_feed(
4413                    "self-custody candidate exceeds the file-count bound",
4414                ));
4415            }
4416        }
4417        match page.next_cursor {
4418            None => break,
4419            Some(next) if next > after => after = next,
4420            Some(_) => {
4421                return Err(invalid_feed(
4422                    "self-custody candidate cursor did not advance",
4423                ))
4424            }
4425        }
4426    }
4427    if files.len() != expected.len()
4428        || files.iter().any(|(path, file)| {
4429            expected.get(path).is_none_or(|expected| {
4430                expected.sha256 != file.sha256 || expected.bytes != file.bytes
4431            })
4432        })
4433    {
4434        return Err(invalid_feed(
4435            "self-custody candidate contains an unexpected file mutation",
4436        ));
4437    }
4438    let mut assets = std::collections::BTreeMap::new();
4439    after.clear();
4440    loop {
4441        let encoded_after: String =
4442            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4443        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4444        let value = ensure_ok(
4445            request_capped(
4446                cfg,
4447                "GET",
4448                &path,
4449                None,
4450                Auth::Required,
4451                MAX_FEED_RESPONSE_BYTES,
4452            )?,
4453            "v2 self-custody asset candidate",
4454        )?;
4455        let page: V2SigningCandidatePage = serde_json::from_value(value)
4456            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4457        let coordinate = (
4458            page.request_hash.clone(),
4459            page.candidate.signing_bytes_base64.clone(),
4460            page.candidate.changes_base64.clone(),
4461            page.candidate.actor_claim_base64.clone(),
4462            page.candidate.content_root.clone(),
4463            page.candidate.asset_root.clone(),
4464            page.parent.seq,
4465            page.parent.commit_hash.clone(),
4466        );
4467        if page.v != 2
4468            || page.challenge_id != challenge_id
4469            || page.mutation_id != mutation_id
4470            || page.assets.len() > 500
4471            || pinned.as_ref() != Some(&coordinate)
4472        {
4473            return Err(invalid_feed(
4474                "self-custody asset candidate changed or is not bound",
4475            ));
4476        }
4477        let root = page.candidate.asset_root.as_deref();
4478        if !page.assets.is_empty() && root.is_none() {
4479            return Err(invalid_feed("asset candidate has no asset root"));
4480        }
4481        for item in page.assets {
4482            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4483            if assets
4484                .insert(
4485                    item.path.clone(),
4486                    V2BaselineAsset {
4487                        blob_sha256: item.blob_sha256,
4488                        bytes: item.bytes,
4489                        media_type: item.media_type,
4490                        wrappers: item.wrappers,
4491                        required: item.required,
4492                        disposition: item.disposition,
4493                        leaf_hash: item.leaf_hash,
4494                    },
4495                )
4496                .is_some()
4497            {
4498                return Err(invalid_feed("self-custody candidate repeats an asset"));
4499            }
4500        }
4501        match page.next_cursor {
4502            None => break,
4503            Some(next) if next > after => after = next,
4504            Some(_) => {
4505                return Err(invalid_feed(
4506                    "self-custody asset candidate cursor did not advance",
4507                ))
4508            }
4509        }
4510    }
4511    if assets.len() != expected_assets.len()
4512        || assets.iter().any(|(path, asset)| {
4513            expected_assets.get(path).is_none_or(|expected| {
4514                asset.blob_sha256 != expected.blob_sha256
4515                    || asset.bytes != expected.bytes
4516                    || asset.media_type != expected.media_type
4517                    || asset.wrappers != expected.wrappers
4518                    || asset.required != expected.required
4519                    || asset.disposition != expected.disposition
4520            })
4521        })
4522    {
4523        return Err(invalid_feed(
4524            "self-custody candidate contains an unexpected asset mutation",
4525        ));
4526    }
4527    let Some((
4528        request_hash,
4529        signing_b64,
4530        changes_b64,
4531        actor_b64,
4532        root,
4533        asset_root,
4534        parent_seq,
4535        parent,
4536    )) = pinned
4537    else {
4538        return Err(invalid_feed("self-custody candidate has no manifest"));
4539    };
4540    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4541    let current_commit = head
4542        .pointer
4543        .as_ref()
4544        .map(|pointer| pointer.commit_hash.clone());
4545    if parent_seq != current_seq || parent != current_commit {
4546        return Err(LinkError::RemoteAdvancedDuringSync);
4547    }
4548    let changes = STANDARD
4549        .decode(changes_b64)
4550        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4551    let mut expected_changes = json!({
4552        "mutation_id": mutation_id,
4553        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4554        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4555        "v": 2,
4556    });
4557    if let Some(withheld_links) = request_body.get("withheld_links") {
4558        expected_changes["withheld_links"] = withheld_links.clone();
4559    }
4560    if let Some(checkout_id) = request_body.get("checkout_id") {
4561        expected_changes["checkout_id"] = checkout_id.clone();
4562    }
4563    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4564        .map_err(|error| invalid_feed(error.to_string()))?;
4565    if changes != expected_changes_bytes {
4566        return Err(invalid_feed(
4567            "self-custody changeset differs from the requested mutation",
4568        ));
4569    }
4570    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4571        .map_err(|error| invalid_feed(error.to_string()))?;
4572    let request_value = json!({
4573        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4574        "brain": head.brain_id,
4575        "changes_sha256": changes_hash,
4576        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4577        "v": 2,
4578        "v1_bridge": Value::Null,
4579    });
4580    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4581        .map_err(|error| invalid_feed(error.to_string()))?;
4582    if request_hash != expected_request_hash {
4583        return Err(invalid_feed(
4584            "self-custody request hash differs from the requested mutation",
4585        ));
4586    }
4587    let actor = STANDARD
4588        .decode(actor_b64)
4589        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4590    let actor_value: Value = serde_json::from_slice(&actor)
4591        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4592    if crate::linkmd_v2::canonical_bytes(&actor_value)
4593        .map_err(|error| invalid_feed(error.to_string()))?
4594        != actor
4595    {
4596        return Err(invalid_feed("self-custody actor claim is not canonical"));
4597    }
4598    let actor_object = actor_value
4599        .as_object()
4600        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4601    let actor_claim = actor_object
4602        .get("claim")
4603        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4604    let actor_public_key = actor_object
4605        .get("public_key")
4606        .and_then(Value::as_str)
4607        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4608    let actor_fingerprint = actor_object
4609        .get("fingerprint")
4610        .and_then(Value::as_str)
4611        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4612    let actor_signature = actor_object
4613        .get("sig")
4614        .and_then(Value::as_str)
4615        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4616    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4617        .map_err(|error| invalid_feed(error.to_string()))?;
4618    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4619    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4620    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4621    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4622    let impact = actor_claim
4623        .get("result")
4624        .and_then(|result| result.get("impact"))
4625        .and_then(Value::as_object);
4626    let impact_fields = [
4627        "creates",
4628        "updates",
4629        "deletes",
4630        "withdrawals",
4631        "renames",
4632        "restores",
4633        "asset_changes",
4634        "public_expansions",
4635        "executable_activations",
4636    ];
4637    let impact_is_valid = impact.is_some_and(|impact| {
4638        impact.len() == impact_fields.len() + 1
4639            && impact.get("v").and_then(Value::as_u64) == Some(1)
4640            && impact_fields
4641                .iter()
4642                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4643    });
4644    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4645        || head
4646            .trust
4647            .hub_signer
4648            .as_ref()
4649            .is_some_and(|known| known != &expected_actor_signer)
4650        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4651        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4652        || actor_claim
4653            .get("candidate")
4654            .and_then(|candidate| candidate.get("changes_sha256"))
4655            .and_then(Value::as_str)
4656            != Some(changes_hash.as_str())
4657        || actor_claim
4658            .get("candidate")
4659            .and_then(|candidate| candidate.get("state_root"))
4660            != Some(&expected_actor_root)
4661        || actor_claim
4662            .get("candidate")
4663            .and_then(|candidate| candidate.get("asset_root"))
4664            != Some(&expected_actor_asset_root)
4665        || actor_claim
4666            .get("candidate")
4667            .and_then(|candidate| candidate.get("control_revision"))
4668            .and_then(Value::as_str)
4669            != Some(head.control_revision.as_str())
4670        || !impact_is_valid
4671    {
4672        return Err(invalid_feed(
4673            "self-custody actor claim does not bind the verified authority",
4674        ));
4675    }
4676    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4677        .map_err(|error| invalid_feed(error.to_string()))?;
4678    let signing = STANDARD
4679        .decode(signing_b64)
4680        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4681    let signing_value: Value = serde_json::from_slice(&signing)
4682        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4683    if crate::linkmd_v2::canonical_bytes(&signing_value)
4684        .map_err(|error| invalid_feed(error.to_string()))?
4685        != signing
4686    {
4687        return Err(invalid_feed("self-custody signing bytes are not canonical"));
4688    }
4689    let pointer = head.pointer.as_ref();
4690    let expected_materializer = pointer
4691        .map(|value| value.materializer.as_str())
4692        .unwrap_or("dbmd-projection-v1");
4693    let expected_parent_commit = request_body
4694        .get("base")
4695        .and_then(|base| base.get("commit_hash"))
4696        .cloned()
4697        .unwrap_or(Value::Null);
4698    let expected_parent_root = request_body
4699        .get("base")
4700        .and_then(|base| base.get("content_root"))
4701        .cloned()
4702        .unwrap_or(Value::Null);
4703    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4704    let expected_parent_asset_root = request_body
4705        .get("base")
4706        .and_then(|base| base.get("asset_root"))
4707        .cloned()
4708        .unwrap_or(Value::Null);
4709    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4710    let expected_prev_entry = pointer
4711        .map(|value| Value::String(value.feed_hash.clone()))
4712        .unwrap_or(Value::Null);
4713    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4714        .map_err(|_| invalid_feed("brain identity history is too large"))?
4715        + 1;
4716    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4717        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4718        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4719        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4720        || signing_value.get("public_key").and_then(Value::as_str)
4721            != Some(key.public_key_spki.as_str())
4722        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4723        || signing_value.get("parent_root") != Some(&expected_parent_root)
4724        || signing_value.get("state_root") != Some(&expected_state_root)
4725        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4726        || signing_value.get("asset_root") != Some(&expected_asset_root)
4727        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4728        || signing_value.get("changes_sha256").and_then(Value::as_str)
4729            != Some(changes_hash.as_str())
4730        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4731        || signing_value
4732            .get("control_revision")
4733            .and_then(Value::as_str)
4734            != Some(head.control_revision.as_str())
4735        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4736        || signing_value.get("v1_bridge") != Some(&Value::Null)
4737        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4738    {
4739        return Err(invalid_feed(
4740            "self-custody signing bytes do not bind the verified candidate",
4741        ));
4742    }
4743    let pair = agent_keypair(&key.pkcs8)?;
4744    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4745    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4746}
4747
4748fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4749    let origin = normalized_origin(&cfg.hub)?;
4750    let absolute = if checkout.is_absolute() {
4751        checkout.to_path_buf()
4752    } else {
4753        std::env::current_dir()?.join(checkout)
4754    };
4755    Ok(format!(
4756        "sync-{}.json",
4757        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4758    ))
4759}
4760
4761fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4762    if let Some(value) = existing {
4763        if !is_sha256(value) {
4764            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4765        }
4766        return Ok(value.to_string());
4767    }
4768    use ring::rand::SecureRandom as _;
4769    let mut random = [0_u8; 32];
4770    ring::rand::SystemRandom::new()
4771        .fill(&mut random)
4772        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4773    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4774}
4775
4776#[cfg(any(unix, windows))]
4777fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4778    let directory = open_trust_dir(cfg)?;
4779    let origin = normalized_origin(&cfg.hub)?;
4780    let name = format!(
4781        "operation-{}.lock",
4782        content_sha256(format!("{origin}\0{brain}").as_bytes())
4783    );
4784    lock_trust_name(&directory, &name)
4785}
4786
4787#[cfg(not(any(unix, windows)))]
4788fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4789    Err(LinkError::UnsupportedPlatform {
4790        operation: "serialized link.md v2 sync",
4791    })
4792}
4793
4794fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4795    left.brain_id == right.brain_id
4796        && left.view_kind == right.view_kind
4797        && left.view_revision == right.view_revision
4798        && left.control_revision == right.control_revision
4799        && match (&left.pointer, &right.pointer) {
4800            (None, None) => true,
4801            (Some(left), Some(right)) => {
4802                left.seq == right.seq
4803                    && left.commit_hash == right.commit_hash
4804                    && left.content_root == right.content_root
4805                    && left.asset_root == right.asset_root
4806                    && left.feed_hash == right.feed_hash
4807            }
4808            _ => false,
4809        }
4810}
4811
4812/// A private sync baseline is an already verified materialization of the
4813/// remote manifest. It may replace another paginated manifest download only
4814/// when a freshly verified signed head names the exact same content, assets,
4815/// view, and authority revision. Any remote or permission movement falls back
4816/// to proof-bearing hub reads.
4817fn v2_baseline_matches_head(head: &V2VerifiedHead, baseline: &V2SyncBaseline) -> bool {
4818    let pointer = head.pointer.as_ref();
4819    baseline.head_seq == Some(pointer.map_or(0, |value| value.seq))
4820        && baseline.commit_hash.as_deref() == pointer.map(|value| value.commit_hash.as_str())
4821        && baseline.content_root.as_deref()
4822            == pointer.and_then(|value| value.content_root.as_deref())
4823        && baseline.asset_root.as_deref() == pointer.and_then(|value| value.asset_root.as_deref())
4824        && baseline.view_kind.as_deref() == Some(head.view_kind.as_str())
4825        && baseline.view_revision.as_deref() == Some(head.view_revision.as_str())
4826        && baseline.control_revision.as_deref() == Some(head.control_revision.as_str())
4827}
4828
4829fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4830    format!(
4831        "---\ntype: db-md\nscope: company\nowner: link.md scoped view\n---\n\n# Scoped brain view\n\nThis DB.md is generated locally by dbmd. It is not the brain's canonical contract and is never uploaded.\n\nCanonical brain: @{brain}\n"
4832    )
4833    .into_bytes()
4834}
4835
4836fn scoped_projection_sha256(brain: &str) -> String {
4837    content_sha256(&scoped_projection_bytes(brain))
4838}
4839
4840#[derive(Deserialize)]
4841struct LocalScopedViewMarker {
4842    v: u8,
4843    kind: String,
4844    authoritative: bool,
4845    brain: String,
4846    projection_sha256: String,
4847}
4848
4849/// True only when this store carries the exact generated marker for a local
4850/// link.md scoped view. This is presentation context, never authorization;
4851/// the hub remains the only authority for reads and writes.
4852pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4853    let marker = store
4854        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4855        .ok()
4856        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4857    let Some(marker) = marker else {
4858        return false;
4859    };
4860    if marker.v != 1
4861        || marker.kind != "link.md-scoped-view"
4862        || marker.authoritative
4863        || !crate::ulid::is_ulid(&marker.brain)
4864        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4865    {
4866        return false;
4867    }
4868    store
4869        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4870        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4871}
4872
4873fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4874    let mut bytes = serde_json::to_vec_pretty(&json!({
4875        "v": 1,
4876        "kind": "link.md-scoped-view",
4877        "authoritative": false,
4878        "brain": head.brain_id,
4879        "view_revision": head.view_revision,
4880        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4881        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4882        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4883        "visible_files": files,
4884        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4885    }))
4886    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4887    bytes.push(b'\n');
4888    Ok(bytes)
4889}
4890
4891fn refresh_scoped_view_marker(
4892    store: &Store,
4893    head: &V2VerifiedHead,
4894    files: usize,
4895) -> LinkResult<()> {
4896    if head.view_kind == "scoped" {
4897        store.write_atomic(
4898            Path::new(".dbmd/view.json"),
4899            &scoped_view_metadata(head, files)?,
4900        )?;
4901    }
4902    Ok(())
4903}
4904
4905fn ensure_v2_view_compatible(
4906    head: &V2VerifiedHead,
4907    baseline: Option<&V2SyncBaseline>,
4908) -> LinkResult<()> {
4909    let Some(baseline) = baseline else {
4910        return Ok(());
4911    };
4912    match (
4913        baseline.view_kind.as_deref(),
4914        baseline.view_revision.as_deref(),
4915    ) {
4916        (None, None) if head.view_kind == "full" => Ok(()),
4917        (Some(kind), Some(revision))
4918            if kind == head.view_kind && revision == head.view_revision =>
4919        {
4920            Ok(())
4921        }
4922        _ => Err(LinkError::ScopedViewChanged),
4923    }
4924}
4925
4926fn ensure_established_v2_checkout_opened(
4927    head: &V2VerifiedHead,
4928    baseline: Option<&V2SyncBaseline>,
4929    opened: bool,
4930) -> LinkResult<()> {
4931    if baseline.is_none() || opened {
4932        return Ok(());
4933    }
4934    if head.view_kind == "scoped" {
4935        return Err(LinkError::ScopedProjectionModified);
4936    }
4937    Err(LinkError::InvalidPack {
4938        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4939    })
4940}
4941
4942fn remove_scoped_projection(
4943    head: &V2VerifiedHead,
4944    baseline: Option<&V2SyncBaseline>,
4945    view: &mut V2LocalView,
4946) -> LinkResult<()> {
4947    if head.view_kind != "scoped" {
4948        return Ok(());
4949    }
4950    let expected = scoped_projection_sha256(&head.brain_id);
4951    if baseline
4952        .and_then(|state| state.projection_sha256.as_deref())
4953        .is_some_and(|pinned| pinned != expected)
4954    {
4955        return Err(LinkError::ScopedViewChanged);
4956    }
4957    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4958        return Err(LinkError::ScopedProjectionModified);
4959    }
4960    view.riding.remove("DB.md");
4961    view.eligibility.remove("DB.md");
4962    Ok(())
4963}
4964
4965fn local_view_for_v2_push(
4966    store: &Store,
4967    head: &V2VerifiedHead,
4968    baseline: Option<&V2SyncBaseline>,
4969    carried: Option<V2LocalView>,
4970) -> LinkResult<V2LocalView> {
4971    match carried {
4972        // The pull half already verified and removed the generated scoped
4973        // projection before constructing its in-memory handoff. Requiring it
4974        // a second time would turn every scoped bidirectional write into a
4975        // false tamper refusal.
4976        Some(view) => Ok(view),
4977        None => {
4978            let mut view = v2_local_files(store)?;
4979            remove_scoped_projection(head, baseline, &mut view)?;
4980            Ok(view)
4981        }
4982    }
4983}
4984
4985fn files_for_v2_view(
4986    head: &V2VerifiedHead,
4987    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4988) -> std::collections::BTreeMap<String, V2BaselineFile> {
4989    if head.view_kind == "scoped" {
4990        // Even when a grant explicitly includes canonical DB.md, a partial
4991        // checkout uses a generated marker. Canonical validation remains a
4992        // server-side operation over the complete brain.
4993        files.remove("DB.md");
4994    }
4995    files
4996}
4997
4998fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4999    let baseline: V2SyncBaseline =
5000        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
5001    if baseline.v != 2
5002        || baseline.origin != normalized_origin(&cfg.hub)?
5003        || baseline.brain != brain
5004        || baseline
5005            .commit_hash
5006            .as_deref()
5007            .is_some_and(|hash| !is_sha256(hash))
5008        || baseline
5009            .content_root
5010            .as_deref()
5011            .is_some_and(|hash| !is_sha256(hash))
5012        || baseline
5013            .asset_root
5014            .as_deref()
5015            .is_some_and(|hash| !is_sha256(hash))
5016        || baseline
5017            .local_policy_digest
5018            .as_deref()
5019            .is_some_and(|hash| !is_sha256(hash))
5020        || baseline
5021            .view_kind
5022            .as_deref()
5023            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5024        || baseline
5025            .view_revision
5026            .as_deref()
5027            .is_some_and(|hash| !is_sha256(hash))
5028        || baseline
5029            .control_revision
5030            .as_deref()
5031            .is_some_and(|hash| !is_sha256(hash))
5032        || baseline
5033            .projection_sha256
5034            .as_deref()
5035            .is_some_and(|hash| !is_sha256(hash))
5036        || (baseline.view_kind.as_deref() == Some("scoped")
5037            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5038        || baseline.files.len() > MAX_PUSH_FILES
5039        || baseline.assets.len() > MAX_PUSH_FILES
5040        || baseline.local_eligibility.len() > MAX_PUSH_FILES
5041        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5042        || baseline.files.iter().any(|(path, file)| {
5043            crate::linkmd_v2::normalize_path(path).is_err()
5044                || !is_sha256(&file.sha256)
5045                || file.bytes > MAX_STORE_BYTES
5046        })
5047        || baseline.assets.iter().any(|(path, asset)| {
5048            crate::linkmd_v2::normalize_path(path).is_err()
5049                || !is_sha256(&asset.blob_sha256)
5050                || !is_sha256(&asset.leaf_hash)
5051                || asset.bytes > MAX_ASSET_BYTES
5052                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5053                || asset.wrappers.is_empty()
5054                || asset
5055                    .wrappers
5056                    .iter()
5057                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5058        })
5059        || baseline
5060            .local_eligibility
5061            .keys()
5062            .chain(baseline.remote_copy_remains.keys())
5063            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5064        || baseline
5065            .remote_copy_remains
5066            .values()
5067            .any(|hash| !is_sha256(hash))
5068        || baseline
5069            .checkout_id
5070            .as_deref()
5071            .is_some_and(|checkout_id| !is_sha256(checkout_id))
5072    {
5073        return Err(invalid_feed("v2 sync baseline failed validation"));
5074    }
5075    Ok(baseline)
5076}
5077
5078#[cfg(unix)]
5079fn load_v2_baseline(
5080    cfg: &HubConfig,
5081    brain: &str,
5082    checkout: &Path,
5083) -> LinkResult<Option<V2SyncBaseline>> {
5084    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5085    let directory = open_trust_dir(cfg)?;
5086    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5087    let _lock = lock_trust_name(&directory, &name_string)?;
5088    let name = c_name(name_string.as_bytes(), &name_string)?;
5089    let fd = unsafe {
5090        libc::openat(
5091            directory.as_raw_fd(),
5092            name.as_ptr(),
5093            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5094        )
5095    };
5096    if fd < 0 {
5097        let error = std::io::Error::last_os_error();
5098        return if error.kind() == std::io::ErrorKind::NotFound {
5099            Ok(None)
5100        } else {
5101            Err(LinkError::UnsafePath { path: name_string })
5102        };
5103    }
5104    let file = unsafe { std::fs::File::from_raw_fd(fd) };
5105    let mut bytes = Vec::new();
5106    file.take(MAX_FEED_RESPONSE_BYTES + 1)
5107        .read_to_end(&mut bytes)?;
5108    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
5109        return Err(invalid_feed("v2 sync baseline is oversized"));
5110    }
5111    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5112}
5113
5114#[cfg(windows)]
5115fn load_v2_baseline(
5116    cfg: &HubConfig,
5117    brain: &str,
5118    checkout: &Path,
5119) -> LinkResult<Option<V2SyncBaseline>> {
5120    let directory = open_trust_dir(cfg)?;
5121    let name = v2_baseline_name(cfg, brain, checkout)?;
5122    let _lock = lock_trust_name(&directory, &name)?;
5123    let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5124    match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5125        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5126        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5127        Err(_) => Err(LinkError::UnsafePath { path: name }),
5128    }
5129}
5130
5131#[cfg(not(any(unix, windows)))]
5132fn load_v2_baseline(
5133    _cfg: &HubConfig,
5134    _brain: &str,
5135    _checkout: &Path,
5136) -> LinkResult<Option<V2SyncBaseline>> {
5137    Err(LinkError::UnsupportedPlatform {
5138        operation: "verified link.md v2 baseline",
5139    })
5140}
5141
5142#[cfg(unix)]
5143fn save_v2_baseline(
5144    cfg: &HubConfig,
5145    brain: &str,
5146    checkout: &Path,
5147    baseline: &V2SyncBaseline,
5148) -> LinkResult<()> {
5149    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5150    let directory = open_trust_dir(cfg)?;
5151    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5152    let _lock = lock_trust_name(&directory, &name_string)?;
5153    let name = c_name(name_string.as_bytes(), &name_string)?;
5154    let mut bytes = serde_json::to_vec(baseline)
5155        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5156    bytes.push(b'\n');
5157    let temp_string = format!(
5158        ".{name_string}.tmp.{}-{}",
5159        std::process::id(),
5160        std::time::SystemTime::now()
5161            .duration_since(std::time::UNIX_EPOCH)
5162            .unwrap_or_default()
5163            .as_nanos()
5164    );
5165    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5166    let fd = unsafe {
5167        libc::openat(
5168            directory.as_raw_fd(),
5169            temp.as_ptr(),
5170            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5171            0o600,
5172        )
5173    };
5174    if fd < 0 {
5175        return Err(std::io::Error::last_os_error().into());
5176    }
5177    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5178    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5179        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5180        return Err(error.into());
5181    }
5182    drop(file);
5183    if unsafe {
5184        libc::renameat(
5185            directory.as_raw_fd(),
5186            temp.as_ptr(),
5187            directory.as_raw_fd(),
5188            name.as_ptr(),
5189        )
5190    } != 0
5191    {
5192        let error = std::io::Error::last_os_error();
5193        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5194        return Err(error.into());
5195    }
5196    directory.sync_all()?;
5197    Ok(())
5198}
5199
5200#[cfg(windows)]
5201fn save_v2_baseline(
5202    cfg: &HubConfig,
5203    brain: &str,
5204    checkout: &Path,
5205    baseline: &V2SyncBaseline,
5206) -> LinkResult<()> {
5207    let directory = open_trust_dir(cfg)?;
5208    let name = v2_baseline_name(cfg, brain, checkout)?;
5209    let _lock = lock_trust_name(&directory, &name)?;
5210    let mut bytes = serde_json::to_vec(baseline)
5211        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5212    bytes.push(b'\n');
5213    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5214    Ok(())
5215}
5216
5217#[cfg(not(any(unix, windows)))]
5218fn save_v2_baseline(
5219    _cfg: &HubConfig,
5220    _brain: &str,
5221    _checkout: &Path,
5222    _baseline: &V2SyncBaseline,
5223) -> LinkResult<()> {
5224    Err(LinkError::UnsupportedPlatform {
5225        operation: "verified link.md v2 baseline",
5226    })
5227}
5228
5229fn v2_baseline_from_head(
5230    cfg: &HubConfig,
5231    head: &V2VerifiedHead,
5232    files: std::collections::BTreeMap<String, V2BaselineFile>,
5233    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5234    local: Option<&V2LocalView>,
5235    checkout_id: Option<&str>,
5236) -> LinkResult<V2SyncBaseline> {
5237    let mut local_eligibility = local
5238        .map(|view| view.eligibility.clone())
5239        .unwrap_or_default();
5240    if let Some(view) = local {
5241        for path in files.keys() {
5242            local_eligibility
5243                .entry(path.clone())
5244                .or_insert_with(|| !view.policy.keeps_home(path));
5245        }
5246    }
5247    let remote_copy_remains = local_eligibility
5248        .iter()
5249        .filter(|(_, riding)| !**riding)
5250        .filter_map(|(path, _)| {
5251            files
5252                .get(path)
5253                .map(|file| (path.clone(), file.sha256.clone()))
5254        })
5255        .collect();
5256    Ok(V2SyncBaseline {
5257        v: 2,
5258        origin: normalized_origin(&cfg.hub)?,
5259        brain: head.brain_id.clone(),
5260        checkout_id: Some(v2_checkout_id(checkout_id)?),
5261        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5262        commit_hash: head
5263            .pointer
5264            .as_ref()
5265            .map(|pointer| pointer.commit_hash.clone()),
5266        content_root: head
5267            .pointer
5268            .as_ref()
5269            .and_then(|pointer| pointer.content_root.clone()),
5270        asset_root: head
5271            .pointer
5272            .as_ref()
5273            .and_then(|pointer| pointer.asset_root.clone()),
5274        assets,
5275        view_kind: Some(head.view_kind.clone()),
5276        view_revision: Some(head.view_revision.clone()),
5277        control_revision: Some(head.control_revision.clone()),
5278        projection_sha256: (head.view_kind == "scoped")
5279            .then(|| scoped_projection_sha256(&head.brain_id)),
5280        files,
5281        local_policy_digest: local.map(|view| view.policy.digest.clone()),
5282        local_eligibility,
5283        remote_copy_remains,
5284    })
5285}
5286
5287fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5288    let policy = crate::linkmd_sync_policy::load(store)
5289        .map_err(|message| LinkError::InvalidPack { message })?;
5290    let asset_paths = crate::assets::read_manifest(store)
5291        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5292        .into_iter()
5293        .map(|asset| asset.path)
5294        .collect::<std::collections::BTreeSet<_>>();
5295    let mut result = std::collections::BTreeMap::new();
5296    let mut eligibility = std::collections::BTreeMap::new();
5297    let mut riding_links = Vec::<(String, Vec<String>)>::new();
5298    let mut total = 0_u64;
5299    let mut paths = vec![PathBuf::from("DB.md")];
5300    paths.extend(store.walk()?);
5301    for relative in paths {
5302        let path = relative.to_string_lossy().replace('\\', "/");
5303        // v2 catalogs and asset inventory are materialized/signed separately.
5304        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5305            continue;
5306        }
5307        if asset_paths.contains(&path) {
5308            continue;
5309        }
5310        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5311            path: error.to_string(),
5312        })?;
5313        let riding = !policy.keeps_home(&path);
5314        eligibility.insert(path.clone(), riding);
5315        if !riding {
5316            continue;
5317        }
5318        let remaining = MAX_STORE_BYTES.saturating_sub(total);
5319        let bytes = store.read_bounded(&relative, remaining)?;
5320        total = total
5321            .checked_add(bytes.len() as u64)
5322            .ok_or_else(|| LinkError::PushTooLarge {
5323                detail: "v2 local byte count overflow".to_string(),
5324            })?;
5325        if total > MAX_STORE_BYTES {
5326            return Err(LinkError::PushTooLarge {
5327                detail: format!("{total} uncompressed bytes"),
5328            });
5329        }
5330        if std::str::from_utf8(&bytes).is_err() {
5331            return Err(LinkError::NotUtf8 { path });
5332        }
5333        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5334        riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5335        result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5336    }
5337    let kept_home = eligibility
5338        .iter()
5339        .filter(|(_, riding)| !**riding)
5340        .map(|(path, _)| path.clone())
5341        .collect::<std::collections::BTreeSet<_>>();
5342    let mut withheld_links = riding_links
5343        .into_iter()
5344        .flat_map(|(source, targets)| {
5345            let kept_home = &kept_home;
5346            let policy = &policy;
5347            targets.into_iter().filter_map(move |target| {
5348                let target = format!("{target}.md");
5349                // A kept-home target need not be a file on THIS machine. An
5350                // export omits kept-home content by construction, so a store
5351                // restored from one links to names it does not carry — and
5352                // without this the owner's own export could never be pushed
5353                // back, which is the opposite of owning your data. The policy
5354                // is the owner's statement about where a path lives, and it
5355                // stands whether or not this checkout holds a copy. It
5356                // discloses nothing either way: the name is already written in
5357                // the content that links to it.
5358                (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5359                    V2WithheldLink {
5360                        source: source.clone(),
5361                        target,
5362                    },
5363                )
5364            })
5365        })
5366        .collect::<Vec<_>>();
5367    withheld_links.sort();
5368    withheld_links.dedup();
5369    Ok(V2LocalView {
5370        riding: result,
5371        eligibility,
5372        policy,
5373        withheld_links,
5374    })
5375}
5376
5377#[derive(Debug, Clone, Deserialize)]
5378struct V2DownloadItem {
5379    path: String,
5380    sha256: String,
5381    bytes: u64,
5382    url: String,
5383    method: String,
5384}
5385
5386#[derive(Debug, Deserialize)]
5387struct V2DownloadWindow {
5388    v: u8,
5389    commit: String,
5390    downloads: Vec<V2DownloadItem>,
5391}
5392
5393#[derive(Debug, Deserialize)]
5394struct V2BulkStreamHeader {
5395    v: u8,
5396    path: String,
5397    sha256: String,
5398    bytes: u64,
5399}
5400
5401fn parse_v2_bulk_stream(
5402    bytes: &[u8],
5403    expected: &[(&String, &V2BaselineFile)],
5404) -> LinkResult<Vec<(String, Vec<u8>)>> {
5405    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5406        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5407    }
5408    let mut cursor = V2_BULK_STREAM_MAGIC.len();
5409    let mut result = Vec::with_capacity(expected.len());
5410    for (expected_path, expected_file) in expected {
5411        let length_bytes = bytes
5412            .get(cursor..cursor + 4)
5413            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5414        cursor += 4;
5415        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5416        if header_len == 0 || header_len > 4 * 1024 {
5417            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5418        }
5419        let header_bytes = bytes
5420            .get(cursor..cursor + header_len)
5421            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5422        cursor += header_len;
5423        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5424            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5425        if header.v != 2
5426            || &header.path != *expected_path
5427            || header.sha256 != expected_file.sha256
5428            || header.bytes != expected_file.bytes
5429            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5430        {
5431            return Err(invalid_feed(
5432                "v2 bulk stream frame differs from its proven manifest entry",
5433            ));
5434        }
5435        let body_len = usize::try_from(header.bytes)
5436            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5437        let body = bytes
5438            .get(cursor..cursor + body_len)
5439            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5440        cursor += body_len;
5441        if content_sha256(body) != header.sha256 {
5442            return Err(invalid_feed(
5443                "v2 bulk stream file differs from its proven manifest entry",
5444            ));
5445        }
5446        result.push((header.path, body.to_vec()));
5447    }
5448    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5449        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5450    }
5451    cursor += 4;
5452    if cursor != bytes.len() {
5453        return Err(invalid_feed("v2 bulk stream carries trailing data"));
5454    }
5455    Ok(result)
5456}
5457
5458fn download_v2_bulk_stream(
5459    cfg: &HubConfig,
5460    brain: &str,
5461    pointer: &V2PointerBody,
5462    pending: &[(&String, &V2BaselineFile)],
5463) -> LinkResult<Vec<(String, Vec<u8>)>> {
5464    let claims = pending
5465        .iter()
5466        .map(|(path, file)| {
5467            Ok(json!({
5468                "path": path,
5469                "sha256": file.sha256,
5470                "bytes": file.bytes,
5471                "proof": file.proof.as_ref().ok_or_else(|| {
5472                    invalid_feed("v2 manifest omitted a bulk-stream proof")
5473                })?,
5474            }))
5475        })
5476        .collect::<LinkResult<Vec<_>>>()?;
5477    let raw = request_raw_retryable_read(
5478        cfg,
5479        "POST",
5480        &format!("/api/hub/brains/{brain}/v2/stream"),
5481        Some(&json!({
5482            "commit": pointer.commit_hash,
5483            "files": claims,
5484        })),
5485        Auth::Required,
5486        V2_BULK_STREAM_RESPONSE_BYTES,
5487    )?;
5488    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5489    parse_v2_bulk_stream(&body, pending)
5490}
5491
5492fn request_capped_retryable_read(
5493    cfg: &HubConfig,
5494    method: &str,
5495    path: &str,
5496    body: Option<&Value>,
5497    auth: Auth,
5498    max_response_bytes: u64,
5499) -> LinkResult<HubResponse> {
5500    let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5501    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5502    Ok(HubResponse {
5503        status: raw.status,
5504        body: parsed,
5505    })
5506}
5507
5508fn prepare_v2_downloads(
5509    cfg: &HubConfig,
5510    brain: &str,
5511    pointer: &V2PointerBody,
5512    pending: &[(&String, &V2BaselineFile)],
5513) -> LinkResult<Vec<V2DownloadItem>> {
5514    let mut result = Vec::with_capacity(pending.len());
5515    for chunk in pending.chunks(128) {
5516        let claims = chunk
5517            .iter()
5518            .map(|(path, file)| {
5519                Ok(json!({
5520                    "path": path,
5521                    "sha256": file.sha256,
5522                    "bytes": file.bytes,
5523                    "proof": file.proof.as_ref().ok_or_else(|| {
5524                        invalid_feed("v2 manifest omitted a download proof")
5525                    })?,
5526                }))
5527            })
5528            .collect::<LinkResult<Vec<_>>>()?;
5529        let value = ensure_ok(
5530            request_capped_retryable_read(
5531                cfg,
5532                "POST",
5533                &format!("/api/hub/brains/{brain}/v2/downloads"),
5534                Some(&json!({
5535                    "commit": pointer.commit_hash,
5536                    "files": claims,
5537                })),
5538                Auth::Required,
5539                MAX_FEED_RESPONSE_BYTES,
5540            )?,
5541            "prepare v2 blob downloads",
5542        )?;
5543        let window: V2DownloadWindow = serde_json::from_value(value)
5544            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5545        if window.v != 2
5546            || window.commit != pointer.commit_hash
5547            || window.downloads.len() != chunk.len()
5548        {
5549            return Err(invalid_feed(
5550                "v2 download window is not bound to the requested files",
5551            ));
5552        }
5553        let mut by_path = window
5554            .downloads
5555            .into_iter()
5556            .map(|item| (item.path.clone(), item))
5557            .collect::<std::collections::BTreeMap<_, _>>();
5558        if by_path.len() != chunk.len() {
5559            return Err(invalid_feed("v2 download window repeats a path"));
5560        }
5561        for (path, file) in chunk {
5562            let item = by_path
5563                .remove(*path)
5564                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5565            if item.method != "GET"
5566                || item.sha256 != file.sha256
5567                || item.bytes != file.bytes
5568                || item.url.is_empty()
5569            {
5570                return Err(invalid_feed(
5571                    "v2 download capability differs from its proven file",
5572                ));
5573            }
5574            result.push(item);
5575        }
5576    }
5577    Ok(result)
5578}
5579
5580fn prepare_v2_asset_downloads(
5581    cfg: &HubConfig,
5582    brain: &str,
5583    pointer: &V2PointerBody,
5584    pending: &[(&String, &V2BaselineAsset)],
5585) -> LinkResult<Vec<V2DownloadItem>> {
5586    let mut result = Vec::with_capacity(pending.len());
5587    for chunk in pending.chunks(128) {
5588        let claims = chunk
5589            .iter()
5590            .map(|(path, asset)| {
5591                json!({
5592                    "path": path,
5593                    "sha256": asset.blob_sha256,
5594                    "bytes": asset.bytes,
5595                    "leaf_hash": asset.leaf_hash,
5596                })
5597            })
5598            .collect::<Vec<_>>();
5599        let value = ensure_ok(
5600            request_capped_retryable_read(
5601                cfg,
5602                "POST",
5603                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5604                Some(&json!({
5605                    "commit": pointer.commit_hash,
5606                    "assets": claims,
5607                })),
5608                Auth::Required,
5609                MAX_FEED_RESPONSE_BYTES,
5610            )?,
5611            "prepare v2 asset downloads",
5612        )?;
5613        let window: V2DownloadWindow = serde_json::from_value(value)
5614            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5615        if window.v != 2
5616            || window.commit != pointer.commit_hash
5617            || window.downloads.len() != chunk.len()
5618        {
5619            return Err(invalid_feed(
5620                "v2 asset download window is not bound to the requested assets",
5621            ));
5622        }
5623        let mut by_path = window
5624            .downloads
5625            .into_iter()
5626            .map(|item| (item.path.clone(), item))
5627            .collect::<std::collections::BTreeMap<_, _>>();
5628        if by_path.len() != chunk.len() {
5629            return Err(invalid_feed("v2 asset download window repeats a path"));
5630        }
5631        for (path, asset) in chunk {
5632            let item = by_path
5633                .remove(*path)
5634                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5635            if item.method != "GET"
5636                || item.sha256 != asset.blob_sha256
5637                || item.bytes != asset.bytes
5638                || item.url.is_empty()
5639            {
5640                return Err(invalid_feed(
5641                    "v2 asset download capability differs from its signed leaf",
5642                ));
5643            }
5644            result.push(item);
5645        }
5646    }
5647    Ok(result)
5648}
5649
5650#[cfg(any(unix, windows))]
5651fn stage_v2_asset_download_window(
5652    cfg: &HubConfig,
5653    brain: &str,
5654    pointer: &V2PointerBody,
5655    cache_dir: &Path,
5656    pending: &[(&String, &V2BaselineAsset)],
5657) -> LinkResult<Vec<V2StagedFile>> {
5658    if pending.is_empty() {
5659        return Ok(Vec::new());
5660    }
5661    if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5662        return Err(invalid_feed("v2 asset capability window is oversized"));
5663    }
5664
5665    let mut last_error = None;
5666    for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5667        .iter()
5668        .copied()
5669        .map(Some)
5670        .chain(std::iter::once(None))
5671    {
5672        // This POST rechecks the exact head and current path authority before
5673        // issuing fresh, immutable GET capabilities. Successful prior workers
5674        // are already durable in the content-addressed cache, so retrying the
5675        // complete small window neither redownloads them nor widens access.
5676        let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5677        let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5678        for item in downloads {
5679            match unique.get(&item.sha256) {
5680                Some(prior) if prior.bytes != item.bytes => {
5681                    return Err(invalid_feed(
5682                        "one v2 asset hash has conflicting byte lengths",
5683                    ));
5684                }
5685                Some(_) => {}
5686                None => {
5687                    unique.insert(item.sha256.clone(), item);
5688                }
5689            }
5690        }
5691        let downloads = unique.into_values().collect::<Vec<_>>();
5692        let next = std::sync::atomic::AtomicUsize::new(0);
5693        let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5694        let mut results = std::iter::repeat_with(|| None)
5695            .take(downloads.len())
5696            .collect::<Vec<Option<LinkResult<PathBuf>>>>();
5697        std::thread::scope(|scope| {
5698            let (sender, receiver) = std::sync::mpsc::channel();
5699            for _ in 0..worker_count {
5700                let sender = sender.clone();
5701                let downloads = &downloads;
5702                let next = &next;
5703                scope.spawn(move || loop {
5704                    let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5705                    let Some(item) = downloads.get(index) else {
5706                        break;
5707                    };
5708                    let result = download_presigned_to_cache(
5709                        cfg,
5710                        &item.url,
5711                        cache_dir,
5712                        &item.sha256,
5713                        item.bytes,
5714                    );
5715                    if sender.send((index, result)).is_err() {
5716                        break;
5717                    }
5718                });
5719            }
5720            drop(sender);
5721            for (index, result) in receiver {
5722                results[index] = Some(result);
5723            }
5724        });
5725
5726        let mut failed = None;
5727        for result in results {
5728            match result {
5729                Some(Ok(_)) => {}
5730                Some(Err(error)) if failed.is_none() => failed = Some(error),
5731                Some(Err(_)) => {}
5732                None if failed.is_none() => {
5733                    failed = Some(LinkError::Transport {
5734                        hub: cfg.hub.clone(),
5735                        message: "a bounded v2 asset worker stopped before reporting its result"
5736                            .to_string(),
5737                    });
5738                }
5739                None => {}
5740            }
5741        }
5742        if let Some(error) = failed {
5743            last_error = Some(error);
5744            if let Some(milliseconds) = retry_delay {
5745                std::thread::sleep(std::time::Duration::from_millis(milliseconds));
5746                continue;
5747            }
5748            break;
5749        }
5750
5751        return pending
5752            .iter()
5753            .map(|(path, asset)| {
5754                let source = cache_dir.join(&asset.blob_sha256);
5755                if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
5756                    return Err(invalid_feed(
5757                        "v2 asset download cache omitted a proven blob",
5758                    ));
5759                }
5760                Ok(V2StagedFile {
5761                    path: (*path).clone(),
5762                    source,
5763                    sha256: asset.blob_sha256.clone(),
5764                    bytes: asset.bytes,
5765                })
5766            })
5767            .collect();
5768    }
5769    Err(last_error
5770        .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
5771}
5772
5773fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5774    let bytes = get_presigned(cfg, &item.url)?;
5775    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5776        return Err(invalid_feed("v2 blob differs from its proven path entry"));
5777    }
5778    Ok(bytes)
5779}
5780
5781#[derive(Debug, Clone)]
5782struct V2StagedFile {
5783    path: String,
5784    source: PathBuf,
5785    sha256: String,
5786    bytes: u64,
5787}
5788
5789#[cfg(unix)]
5790fn v2_download_cache_dir(
5791    cfg: &HubConfig,
5792    brain: &str,
5793    pointer: &V2PointerBody,
5794) -> LinkResult<PathBuf> {
5795    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5796}
5797
5798#[cfg(unix)]
5799fn v2_download_cache_dir_for(
5800    cfg: &HubConfig,
5801    brain: &str,
5802    transaction: &str,
5803) -> LinkResult<PathBuf> {
5804    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5805        return Err(invalid_feed("v2 download cache address is invalid"));
5806    }
5807    let path = cfg
5808        .state_dir
5809        .join("downloads")
5810        .join(brain)
5811        .join(transaction);
5812    let directory = open_or_create_dir_nofollow(&path)?;
5813    use std::os::fd::AsRawFd as _;
5814    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5815        return Err(std::io::Error::last_os_error().into());
5816    }
5817    directory.sync_all()?;
5818    Ok(path)
5819}
5820
5821#[cfg(windows)]
5822fn v2_download_cache_dir(
5823    cfg: &HubConfig,
5824    brain: &str,
5825    pointer: &V2PointerBody,
5826) -> LinkResult<PathBuf> {
5827    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5828}
5829
5830#[cfg(windows)]
5831fn v2_download_cache_dir_for(
5832    cfg: &HubConfig,
5833    brain: &str,
5834    transaction: &str,
5835) -> LinkResult<PathBuf> {
5836    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5837        return Err(invalid_feed("v2 download cache address is invalid"));
5838    }
5839    let path = cfg
5840        .state_dir
5841        .join("downloads")
5842        .join(brain)
5843        .join(transaction);
5844    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5845    crate::fsx::open_directory_nofollow(&path)?;
5846    Ok(path)
5847}
5848
5849#[cfg(unix)]
5850fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5851    use std::os::fd::AsRawFd as _;
5852    let parent = cfg.state_dir.join("downloads").join(brain);
5853    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5854        return;
5855    };
5856    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5857        return;
5858    };
5859    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5860    let _ = directory.sync_all();
5861}
5862
5863#[cfg(windows)]
5864fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5865    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5866        return;
5867    }
5868    let parent = cfg.state_dir.join("downloads").join(brain);
5869    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5870        return;
5871    };
5872    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5873}
5874
5875#[cfg(not(any(unix, windows)))]
5876fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5877
5878#[cfg(not(any(unix, windows)))]
5879fn v2_download_cache_dir_for(
5880    _cfg: &HubConfig,
5881    _brain: &str,
5882    _transaction: &str,
5883) -> LinkResult<PathBuf> {
5884    Err(LinkError::UnsupportedPlatform {
5885        operation: "resumable v2 download staging",
5886    })
5887}
5888
5889#[cfg(any(unix, windows))]
5890fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5891    let file = match crate::fsx::open_regular_nofollow(path) {
5892        Ok(file) => file,
5893        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5894        Err(error) => return Err(error.into()),
5895    };
5896    if file.metadata()?.len() != bytes {
5897        return Ok(false);
5898    }
5899    Ok(content_sha256_reader(file)? == sha256)
5900}
5901
5902#[cfg(any(unix, windows))]
5903fn cache_v2_blob_bytes(
5904    cache_dir: &Path,
5905    sha256: &str,
5906    expected_bytes: u64,
5907    bytes: &[u8],
5908) -> LinkResult<PathBuf> {
5909    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5910        return Err(invalid_feed("v2 cached blob differs from its declaration"));
5911    }
5912    let path = cache_dir.join(sha256);
5913    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5914        crate::fsx::write_atomic(&path, bytes)?;
5915    }
5916    Ok(path)
5917}
5918
5919#[cfg(not(any(unix, windows)))]
5920fn cache_v2_blob_bytes(
5921    _cache_dir: &Path,
5922    _sha256: &str,
5923    _expected_bytes: u64,
5924    _bytes: &[u8],
5925) -> LinkResult<PathBuf> {
5926    Err(LinkError::UnsupportedPlatform {
5927        operation: "resumable v2 download staging",
5928    })
5929}
5930
5931#[cfg(unix)]
5932fn download_presigned_to_cache(
5933    cfg: &HubConfig,
5934    url: &str,
5935    cache_dir: &Path,
5936    sha256: &str,
5937    expected_bytes: u64,
5938) -> LinkResult<PathBuf> {
5939    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5940
5941    let target = cache_dir.join(sha256);
5942    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5943        return Ok(target);
5944    }
5945    let directory = open_existing_dir_nofollow(cache_dir)?;
5946    let mut nonce = [0_u8; 16];
5947    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5948        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5949    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5950    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5951    let fd = unsafe {
5952        libc::openat(
5953            directory.as_raw_fd(),
5954            temp.as_ptr(),
5955            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5956            0o600,
5957        )
5958    };
5959    if fd < 0 {
5960        return Err(std::io::Error::last_os_error().into());
5961    }
5962    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5963    let response = match presigned_agent(cfg, url)?.get(url).call() {
5964        Ok(response) => response,
5965        Err(ureq::Error::Status(_, response)) => {
5966            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5967            return Err(LinkError::Http {
5968                what: "v2 direct download",
5969                status: response.status(),
5970                message: "object store rejected the download".to_string(),
5971                code: None,
5972                details: None,
5973            });
5974        }
5975        Err(ureq::Error::Transport(error)) => {
5976            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5977            return Err(LinkError::Transport {
5978                hub: cfg.hub.clone(),
5979                message: error.to_string(),
5980            });
5981        }
5982    };
5983    let mut reader = response
5984        .into_reader()
5985        .take(expected_bytes.saturating_add(1));
5986    let mut digest = Sha256::new();
5987    let mut total = 0_u64;
5988    let mut buffer = [0_u8; 64 * 1024];
5989    // Reading the socket and writing the file are different failures wearing
5990    // the same io::Error: a stalled download is the peer's problem and a full
5991    // disk is this machine's, and an operator who is told only "timed out"
5992    // cannot tell which one to act on.
5993    let write_result = (|| -> LinkResult<()> {
5994        loop {
5995            let read = reader
5996                .read(&mut buffer)
5997                .map_err(|error| LinkError::Transport {
5998                    hub: cfg.hub.clone(),
5999                    message: error.to_string(),
6000                })?;
6001            if read == 0 {
6002                break;
6003            }
6004            total = total.saturating_add(read as u64);
6005            digest.update(&buffer[..read]);
6006            output.write_all(&buffer[..read])?;
6007        }
6008        output.sync_all().map_err(LinkError::from)
6009    })();
6010    if let Err(error) = write_result {
6011        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6012        return Err(error);
6013    }
6014    drop(output);
6015    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6016        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6017        return Err(invalid_feed(
6018            "v2 direct download failed integrity verification",
6019        ));
6020    }
6021    let target_name = c_name(sha256.as_bytes(), sha256)?;
6022    // Replace only a previously inspected cache leaf. Cache bytes are private
6023    // and content-addressed; final install re-verifies them again.
6024    if unsafe {
6025        libc::renameat(
6026            directory.as_raw_fd(),
6027            temp.as_ptr(),
6028            directory.as_raw_fd(),
6029            target_name.as_ptr(),
6030        )
6031    } != 0
6032    {
6033        let error = std::io::Error::last_os_error();
6034        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6035        return Err(error.into());
6036    }
6037    directory.sync_all()?;
6038    Ok(target)
6039}
6040
6041#[cfg(windows)]
6042fn download_presigned_to_cache(
6043    cfg: &HubConfig,
6044    url: &str,
6045    cache_dir: &Path,
6046    sha256: &str,
6047    expected_bytes: u64,
6048) -> LinkResult<PathBuf> {
6049    use std::fs::OpenOptions;
6050
6051    let target = cache_dir.join(sha256);
6052    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6053        return Ok(target);
6054    }
6055    // Keep the checked directory handle alive while the private cache path is
6056    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
6057    // an ancestor can be swapped after validation.
6058    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6059    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6060    let mut output = OpenOptions::new()
6061        .write(true)
6062        .create_new(true)
6063        .open(&temp)?;
6064    let response = match presigned_agent(cfg, url)?.get(url).call() {
6065        Ok(response) => response,
6066        Err(ureq::Error::Status(_, response)) => {
6067            let _ = std::fs::remove_file(&temp);
6068            return Err(LinkError::Http {
6069                what: "v2 direct download",
6070                status: response.status(),
6071                message: "object store rejected the download".to_string(),
6072                code: None,
6073                details: None,
6074            });
6075        }
6076        Err(ureq::Error::Transport(error)) => {
6077            let _ = std::fs::remove_file(&temp);
6078            return Err(LinkError::Transport {
6079                hub: cfg.hub.clone(),
6080                message: error.to_string(),
6081            });
6082        }
6083    };
6084    let mut reader = response
6085        .into_reader()
6086        .take(expected_bytes.saturating_add(1));
6087    let mut digest = Sha256::new();
6088    let mut total = 0_u64;
6089    let mut buffer = [0_u8; 64 * 1024];
6090    // Same split as the cache-fill path: a stalled socket is not a disk error.
6091    let copied = (|| -> LinkResult<()> {
6092        loop {
6093            let read = reader
6094                .read(&mut buffer)
6095                .map_err(|error| LinkError::Transport {
6096                    hub: cfg.hub.clone(),
6097                    message: error.to_string(),
6098                })?;
6099            if read == 0 {
6100                break;
6101            }
6102            total = total.saturating_add(read as u64);
6103            digest.update(&buffer[..read]);
6104            output.write_all(&buffer[..read])?;
6105        }
6106        output.sync_all()?;
6107        Ok(())
6108    })();
6109    if let Err(error) = copied {
6110        let _ = std::fs::remove_file(&temp);
6111        return Err(error);
6112    }
6113    drop(output);
6114    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6115        let _ = std::fs::remove_file(&temp);
6116        return Err(invalid_feed(
6117            "v2 direct download failed integrity verification",
6118        ));
6119    }
6120    if target.exists() {
6121        std::fs::remove_file(&target)?;
6122    }
6123    if let Err(error) = std::fs::rename(&temp, &target) {
6124        let _ = std::fs::remove_file(&temp);
6125        return Err(error.into());
6126    }
6127    Ok(target)
6128}
6129
6130#[cfg(not(any(unix, windows)))]
6131fn download_presigned_to_cache(
6132    _cfg: &HubConfig,
6133    _url: &str,
6134    _cache_dir: &Path,
6135    _sha256: &str,
6136    _expected_bytes: u64,
6137) -> LinkResult<PathBuf> {
6138    Err(LinkError::UnsupportedPlatform {
6139        operation: "resumable v2 download staging",
6140    })
6141}
6142
6143fn download_v2_blobs(
6144    cfg: &HubConfig,
6145    brain: &str,
6146    pointer: &V2PointerBody,
6147    pending: Vec<(&String, &V2BaselineFile)>,
6148) -> LinkResult<Vec<(String, Vec<u8>)>> {
6149    if pending.is_empty() {
6150        return Ok(Vec::new());
6151    }
6152    let expected_order = pending
6153        .iter()
6154        .map(|(path, _)| (*path).clone())
6155        .collect::<Vec<_>>();
6156    let mut streamed = std::collections::BTreeMap::new();
6157    let mut direct = Vec::new();
6158    let mut window = Vec::new();
6159    let mut window_bytes = 0_u64;
6160    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6161                 window_bytes: &mut u64,
6162                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6163     -> LinkResult<()> {
6164        if window.is_empty() {
6165            return Ok(());
6166        }
6167        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6168            if streamed.insert(path, bytes).is_some() {
6169                return Err(invalid_feed("v2 bulk streams repeated a path"));
6170            }
6171        }
6172        window.clear();
6173        *window_bytes = 0;
6174        Ok(())
6175    };
6176    for &(path, file) in &pending {
6177        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6178            flush(&mut window, &mut window_bytes, &mut streamed)?;
6179            direct.push((path, file));
6180            continue;
6181        }
6182        if window.len() == V2_BULK_STREAM_FILES
6183            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6184        {
6185            flush(&mut window, &mut window_bytes, &mut streamed)?;
6186        }
6187        window.push((path, file));
6188        window_bytes += file.bytes;
6189    }
6190    flush(&mut window, &mut window_bytes, &mut streamed)?;
6191
6192    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6193    let next = std::sync::atomic::AtomicUsize::new(0);
6194    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6195    let mut results = std::iter::repeat_with(|| None)
6196        .take(downloads.len())
6197        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6198    std::thread::scope(|scope| {
6199        let (sender, receiver) = std::sync::mpsc::channel();
6200        for _ in 0..worker_count {
6201            let sender = sender.clone();
6202            let downloads = &downloads;
6203            let next = &next;
6204            scope.spawn(move || loop {
6205                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6206                let Some(item) = downloads.get(index) else {
6207                    break;
6208                };
6209                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6210                if sender.send((index, result)).is_err() {
6211                    break;
6212                }
6213            });
6214        }
6215        drop(sender);
6216        for (index, result) in receiver {
6217            results[index] = Some(result);
6218        }
6219    });
6220    for result in results.into_iter().map(|result| {
6221        result.ok_or_else(|| LinkError::Transport {
6222            hub: cfg.hub.clone(),
6223            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6224        })?
6225    }) {
6226        let (path, bytes) = result?;
6227        if streamed.insert(path, bytes).is_some() {
6228            return Err(invalid_feed("v2 download lanes repeated a path"));
6229        }
6230    }
6231    expected_order
6232        .into_iter()
6233        .map(|path| {
6234            streamed
6235                .remove(&path)
6236                .map(|bytes| (path, bytes))
6237                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6238        })
6239        .collect()
6240}
6241
6242/// Resume an exact-head materialization into the private global cache. Each
6243/// verified frame is durable before the next network window begins, so a
6244/// retry skips it and memory never grows with total changed bytes.
6245#[cfg(any(unix, windows))]
6246fn stage_v2_blobs(
6247    cfg: &HubConfig,
6248    brain: &str,
6249    pointer: &V2PointerBody,
6250    pending: Vec<(&String, &V2BaselineFile)>,
6251) -> LinkResult<Vec<V2StagedFile>> {
6252    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6253    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6254    let mut direct = Vec::new();
6255    let mut window = Vec::new();
6256    let mut window_bytes = 0_u64;
6257    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6258                 window_bytes: &mut u64,
6259                 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6260     -> LinkResult<()> {
6261        if window.is_empty() {
6262            return Ok(());
6263        }
6264        let missing = window
6265            .iter()
6266            .filter_map(|(path, file)| {
6267                let target = cache_dir.join(&file.sha256);
6268                match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6269                    Ok(true) => {
6270                        staged.insert(
6271                            (*path).clone(),
6272                            V2StagedFile {
6273                                path: (*path).clone(),
6274                                source: target,
6275                                sha256: file.sha256.clone(),
6276                                bytes: file.bytes,
6277                            },
6278                        );
6279                        None
6280                    }
6281                    Ok(false) => Some(Ok((*path, *file))),
6282                    Err(error) => Some(Err(error)),
6283                }
6284            })
6285            .collect::<LinkResult<Vec<_>>>()?;
6286        if !missing.is_empty() {
6287            for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6288                let file = missing
6289                    .iter()
6290                    .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6291                    .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6292                let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6293                staged.insert(
6294                    path.clone(),
6295                    V2StagedFile {
6296                        path,
6297                        source,
6298                        sha256: file.sha256.clone(),
6299                        bytes: file.bytes,
6300                    },
6301                );
6302            }
6303        }
6304        window.clear();
6305        *window_bytes = 0;
6306        Ok(())
6307    };
6308    for &(path, file) in &pending {
6309        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6310            flush(&mut window, &mut window_bytes, &mut staged)?;
6311            direct.push((path, file));
6312            continue;
6313        }
6314        if window.len() == V2_BULK_STREAM_FILES
6315            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6316        {
6317            flush(&mut window, &mut window_bytes, &mut staged)?;
6318        }
6319        window.push((path, file));
6320        window_bytes += file.bytes;
6321    }
6322    flush(&mut window, &mut window_bytes, &mut staged)?;
6323    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6324        let source =
6325            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6326        staged.insert(
6327            item.path.clone(),
6328            V2StagedFile {
6329                path: item.path,
6330                source,
6331                sha256: item.sha256,
6332                bytes: item.bytes,
6333            },
6334        );
6335    }
6336    pending
6337        .into_iter()
6338        .map(|(path, _)| {
6339            staged
6340                .remove(path)
6341                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6342        })
6343        .collect()
6344}
6345
6346#[cfg(not(any(unix, windows)))]
6347fn stage_v2_blobs(
6348    _cfg: &HubConfig,
6349    _brain: &str,
6350    _pointer: &V2PointerBody,
6351    _pending: Vec<(&String, &V2BaselineFile)>,
6352) -> LinkResult<Vec<V2StagedFile>> {
6353    Err(LinkError::UnsupportedPlatform {
6354        operation: "resumable v2 download staging",
6355    })
6356}
6357
6358const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6359const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6360const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6361
6362#[derive(Debug, Clone, Deserialize, Serialize)]
6363struct V2ConflictCoordinate {
6364    sha256: Option<String>,
6365    bytes: Option<u64>,
6366    file: Option<String>,
6367}
6368
6369#[derive(Debug, Clone, Deserialize, Serialize)]
6370struct V2ConflictFile {
6371    path: String,
6372    base: V2ConflictCoordinate,
6373    local: V2ConflictCoordinate,
6374    remote: V2ConflictCoordinate,
6375}
6376
6377#[derive(Debug, Clone, Deserialize, Serialize)]
6378struct V2ConflictPlan {
6379    v: u8,
6380    class: String,
6381    bundle: String,
6382    brain: String,
6383    origin: String,
6384    created_unix: u64,
6385    expires_unix: u64,
6386    base_seq: Option<u64>,
6387    base_commit: Option<String>,
6388    remote_seq: u64,
6389    remote_commit: Option<String>,
6390    remote_content_root: Option<String>,
6391    view_kind: String,
6392    view_revision: String,
6393    files: Vec<V2ConflictFile>,
6394}
6395
6396fn v2_take_remote_selection(
6397    files: &[V2ConflictFile],
6398    current: &std::collections::BTreeMap<String, V2BaselineFile>,
6399) -> LinkResult<(
6400    std::collections::BTreeMap<String, V2BaselineFile>,
6401    Vec<String>,
6402)> {
6403    let mut selected = std::collections::BTreeMap::new();
6404    let mut deleted = Vec::new();
6405    for file in files {
6406        match (&file.remote.sha256, file.remote.bytes) {
6407            (Some(sha256), Some(bytes)) => {
6408                let proven = current.get(&file.path).ok_or_else(|| {
6409                    invalid_feed("conflict remote coordinate disappeared from the exact head")
6410                })?;
6411                if proven.sha256 != *sha256 || proven.bytes != bytes {
6412                    return Err(invalid_feed(
6413                        "conflict remote coordinate differs from the exact head",
6414                    ));
6415                }
6416                if selected.insert(file.path.clone(), proven.clone()).is_some() {
6417                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6418                }
6419            }
6420            (None, None) => {
6421                if current.contains_key(&file.path) {
6422                    return Err(invalid_feed(
6423                        "conflict remote deletion differs from the exact head",
6424                    ));
6425                }
6426                deleted.push(file.path.clone());
6427            }
6428            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6429        }
6430    }
6431    Ok((selected, deleted))
6432}
6433
6434fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6435    PathBuf::from(".dbmd")
6436        .join("conflicts")
6437        .join(bundle)
6438        .join(suffix)
6439}
6440
6441fn read_historical_conflict_blob(
6442    cfg: &HubConfig,
6443    brain: &str,
6444    baseline: &V2SyncBaseline,
6445    path: &str,
6446    file: &V2BaselineFile,
6447) -> LinkResult<Option<Vec<u8>>> {
6448    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6449        return Ok(None);
6450    };
6451    if seq == 0 {
6452        return Ok(None);
6453    }
6454    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6455    let endpoint = format!(
6456        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6457        file.sha256
6458    );
6459    let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6460    if raw.status == 404 || raw.status == 403 {
6461        return Ok(None);
6462    }
6463    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6464    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6465        return Err(invalid_feed(
6466            "v2 conflict base failed integrity verification",
6467        ));
6468    }
6469    Ok(Some(bytes))
6470}
6471
6472/// Persist a readable content conflict without modifying either side. The
6473/// plan is written last, so an interrupted bundle build is never actionable.
6474fn create_v2_conflict_bundle(
6475    cfg: &HubConfig,
6476    store: &Store,
6477    head: &V2VerifiedHead,
6478    baseline: Option<&V2SyncBaseline>,
6479    local: &std::collections::BTreeMap<String, (String, u64)>,
6480    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6481    paths: &[String],
6482) -> LinkResult<(String, Vec<String>)> {
6483    let conflicts_root = Path::new(".dbmd/conflicts");
6484    store.create_dir_all(conflicts_root)?;
6485    let completed = store
6486        .directory_names(conflicts_root)?
6487        .into_iter()
6488        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6489        .count();
6490    if completed >= V2_CONFLICT_BUNDLE_MAX {
6491        return Err(LinkError::InvalidPack {
6492            message: format!(
6493                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6494            ),
6495        });
6496    }
6497
6498    // A conflict bundle is diagnostic state, not a second whole-brain cache.
6499    // Materialize a deterministic prefix whose remote artifacts fit one
6500    // bounded window. Resolving it exposes the next prefix on the next sync.
6501    let mut selected_paths = Vec::new();
6502    let mut selected_remote_bytes = 0_u64;
6503    for path in paths {
6504        let bytes = remote.get(path).map_or(0, |file| file.bytes);
6505        if !selected_paths.is_empty()
6506            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6507        {
6508            break;
6509        }
6510        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6511        selected_paths.push(path.clone());
6512        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6513            break;
6514        }
6515    }
6516    if selected_paths.is_empty() {
6517        return Err(invalid_feed("content conflict set is empty"));
6518    }
6519    let bundle = crate::ulid::mint();
6520    let bundle_root = v2_conflict_relative(&bundle, "");
6521    store.create_dir_all(&bundle_root.join("files"))?;
6522    let pointer = head.pointer.as_ref();
6523    let remote_bytes = match pointer {
6524        Some(pointer) => download_v2_blobs(
6525            cfg,
6526            &head.brain_id,
6527            pointer,
6528            selected_paths
6529                .iter()
6530                .filter_map(|path| {
6531                    remote
6532                        .get(path)
6533                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6534                        .map(|file| (path, file))
6535                })
6536                .collect(),
6537        )?
6538        .into_iter()
6539        .collect::<std::collections::BTreeMap<_, _>>(),
6540        None => std::collections::BTreeMap::new(),
6541    };
6542
6543    let mut files = Vec::with_capacity(selected_paths.len());
6544    for (index, path) in selected_paths.iter().enumerate() {
6545        let base_file = baseline.and_then(|state| state.files.get(path));
6546        let base_bytes = match (baseline, base_file) {
6547            (Some(state), Some(file)) => {
6548                read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6549            }
6550            _ => None,
6551        };
6552        let local_file = local.get(path);
6553        let remote_file = remote.get(path);
6554        let remote_content = remote_bytes.get(path);
6555        let prefix = format!("files/{index:04}");
6556        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6557        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6558        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6559        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6560            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6561        }
6562        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6563            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6564            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6565                return Err(LinkError::InvalidPack {
6566                    message: format!("local conflict path `{path}` changed while bundling"),
6567                });
6568            }
6569            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6570        }
6571        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6572            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6573        }
6574        files.push(V2ConflictFile {
6575            path: path.clone(),
6576            base: V2ConflictCoordinate {
6577                sha256: base_file.map(|file| file.sha256.clone()),
6578                bytes: base_file.map(|file| file.bytes),
6579                file: base_name,
6580            },
6581            local: V2ConflictCoordinate {
6582                sha256: local_file.map(|(sha256, _)| sha256.clone()),
6583                bytes: local_file.map(|(_, bytes)| *bytes),
6584                file: local_name,
6585            },
6586            remote: V2ConflictCoordinate {
6587                sha256: remote_file.map(|file| file.sha256.clone()),
6588                bytes: remote_file.map(|file| file.bytes),
6589                file: remote_name,
6590            },
6591        });
6592    }
6593    let now = SystemTime::now()
6594        .duration_since(UNIX_EPOCH)
6595        .unwrap_or_default()
6596        .as_secs();
6597    let plan = V2ConflictPlan {
6598        v: 2,
6599        class: "content_resolution_required".to_string(),
6600        bundle: bundle.clone(),
6601        brain: head.brain_id.clone(),
6602        origin: normalized_origin(&cfg.hub)?,
6603        created_unix: now,
6604        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6605        base_seq: baseline.and_then(|state| state.head_seq),
6606        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6607        remote_seq: pointer.map_or(0, |value| value.seq),
6608        remote_commit: pointer.map(|value| value.commit_hash.clone()),
6609        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6610        view_kind: head.view_kind.clone(),
6611        view_revision: head.view_revision.clone(),
6612        files,
6613    };
6614    let mut bytes = serde_json::to_vec_pretty(&plan)
6615        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6616    bytes.push(b'\n');
6617    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6618    Ok((bundle, selected_paths))
6619}
6620
6621fn v2_sync_pull_with_resolution(
6622    cfg: &HubConfig,
6623    requested_brain: &str,
6624    expected_head: V2VerifiedHead,
6625    out: Option<&Path>,
6626    take_remote: Option<&std::collections::BTreeSet<String>>,
6627) -> LinkResult<V2PulledSnapshot> {
6628    let dest = out
6629        .map(Path::to_path_buf)
6630        .unwrap_or_else(|| PathBuf::from(requested_brain));
6631    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6632    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6633    let head = v2_verified_head(cfg, requested_brain)?
6634        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6635    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6636        return Err(LinkError::RemoteAdvancedDuringSync);
6637    }
6638    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6639    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6640    let (remote, remote_assets) = match baseline
6641        .as_ref()
6642        .filter(|state| v2_baseline_matches_head(&head, state))
6643    {
6644        Some(state) => (state.files.clone(), state.assets.clone()),
6645        None => (
6646            files_for_v2_view(
6647                &head,
6648                v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6649            ),
6650            v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6651        ),
6652    };
6653    let local_store = Store::open_strict(&dest).ok();
6654    // A baseline proves this path is an established checkout. Never reinterpret
6655    // a damaged/edited store marker as an empty clone destination: doing so
6656    // would let the pull half erase the local evidence before the push half can
6657    // reject it (most importantly for the generated scoped-view DB.md).
6658    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6659    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6660    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6661        return Err(LinkError::ScopedViewChanged);
6662    }
6663    if let Some(view) = local_view.as_mut() {
6664        remove_scoped_projection(&head, baseline.as_ref(), view)?;
6665    }
6666    let empty_local = std::collections::BTreeMap::new();
6667    let local = local_view
6668        .as_ref()
6669        .map_or(&empty_local, |view| &view.riding);
6670    let kept_home = |path: &str| {
6671        local_view
6672            .as_ref()
6673            .is_some_and(|view| view.policy.keeps_home(path))
6674    };
6675    let empty_base = std::collections::BTreeMap::new();
6676    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6677    let empty_base_assets = std::collections::BTreeMap::new();
6678    let base_assets = baseline
6679        .as_ref()
6680        .map_or(&empty_base_assets, |state| &state.assets);
6681    let mut local_assets = local_store
6682        .as_ref()
6683        .map(v2_local_asset_records)
6684        .transpose()?
6685        .unwrap_or_default();
6686    let mut content_merge = merge_v2_pulled_records(
6687        base,
6688        &remote,
6689        local,
6690        |file, _| (file.sha256.clone(), file.bytes),
6691        |file, _| (file.sha256.clone(), file.bytes),
6692        kept_home,
6693    );
6694    if let Some(selected) = take_remote {
6695        for path in selected {
6696            if let Some(position) = content_merge
6697                .conflicts
6698                .iter()
6699                .position(|conflict| conflict == path)
6700            {
6701                content_merge.conflicts.remove(position);
6702                content_merge.accept_remote.insert(path.clone());
6703                match remote.get(path) {
6704                    Some(file) => {
6705                        content_merge
6706                            .records
6707                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
6708                    }
6709                    None => {
6710                        content_merge.records.remove(path);
6711                    }
6712                }
6713            } else if !content_merge.accept_remote.contains(path) {
6714                return Err(LinkError::InvalidPack {
6715                    message: format!(
6716                        "take-remote path `{path}` is no longer at its conflict coordinate"
6717                    ),
6718                });
6719            }
6720        }
6721    }
6722    if !content_merge.conflicts.is_empty() {
6723        let mut conflicts = content_merge.conflicts.clone();
6724        conflicts.truncate(100);
6725        if let Some(store) = local_store.as_ref() {
6726            let (bundle, paths) = create_v2_conflict_bundle(
6727                cfg,
6728                store,
6729                &head,
6730                baseline.as_ref(),
6731                local,
6732                &remote,
6733                &conflicts,
6734            )?;
6735            return Err(LinkError::ConflictBundle { bundle, paths });
6736        }
6737        return Err(LinkError::Conflict { paths: conflicts });
6738    }
6739    let asset_merge = merge_v2_pulled_records(
6740        base_assets,
6741        &remote_assets,
6742        &local_assets,
6743        v2_asset_record,
6744        v2_asset_record,
6745        |_| false,
6746    );
6747    if !asset_merge.conflicts.is_empty() {
6748        let mut conflicts = asset_merge.conflicts.clone();
6749        conflicts.truncate(100);
6750        return Err(LinkError::Conflict { paths: conflicts });
6751    }
6752    let pointer = head.pointer.as_ref();
6753    let cache_transaction = pointer.map_or_else(
6754        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6755        |value| value.commit_hash.clone(),
6756    );
6757    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6758    let mut changed = match pointer {
6759        Some(pointer) => stage_v2_blobs(
6760            cfg,
6761            &head.brain_id,
6762            pointer,
6763            remote
6764                .iter()
6765                .filter(|(path, file)| {
6766                    content_merge.accept_remote.contains(*path)
6767                        && local.get(*path).map(|value| value.0.as_str())
6768                            != Some(file.sha256.as_str())
6769                })
6770                .collect(),
6771        )?,
6772        None => Vec::new(),
6773    };
6774    let mut deleted = content_merge
6775        .accept_remote
6776        .iter()
6777        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6778        .cloned()
6779        .collect::<Vec<_>>();
6780    if local_assets != asset_merge.records {
6781        if asset_merge.records.is_empty() {
6782            deleted.push("assets.jsonl".to_string());
6783        } else {
6784            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6785            let sha256 = content_sha256(&bytes);
6786            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6787            changed.push(V2StagedFile {
6788                path: "assets.jsonl".to_string(),
6789                source,
6790                sha256,
6791                bytes: bytes.len() as u64,
6792            });
6793        }
6794    }
6795    if let Some(pointer) = pointer {
6796        let mut pending_assets = Vec::new();
6797        for (path, asset) in &remote_assets {
6798            if asset.disposition != "hosted"
6799                || kept_home(path)
6800                || !asset_merge.accept_remote.contains(path)
6801            {
6802                continue;
6803            }
6804            let already_current = local_store.as_ref().is_some_and(|store| {
6805                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6806                    && store
6807                        .read_bounded(Path::new(path), asset.bytes)
6808                        .ok()
6809                        .is_some_and(|bytes| {
6810                            bytes.len() as u64 == asset.bytes
6811                                && content_sha256(&bytes) == asset.blob_sha256
6812                        })
6813            });
6814            if !already_current {
6815                pending_assets.push((path, asset));
6816            }
6817        }
6818        let mut window = Vec::new();
6819        let mut window_bytes = 0_u64;
6820        let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
6821                     window_bytes: &mut u64,
6822                     changed: &mut Vec<V2StagedFile>|
6823         -> LinkResult<()> {
6824            changed.extend(stage_v2_asset_download_window(
6825                cfg,
6826                &head.brain_id,
6827                pointer,
6828                &cache_dir,
6829                window,
6830            )?);
6831            window.clear();
6832            *window_bytes = 0;
6833            Ok(())
6834        };
6835        for item @ (_, asset) in pending_assets {
6836            if !window.is_empty()
6837                && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
6838                    || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
6839            {
6840                flush(&mut window, &mut window_bytes, &mut changed)?;
6841            }
6842            window.push(item);
6843            window_bytes = window_bytes.saturating_add(asset.bytes);
6844            if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
6845                flush(&mut window, &mut window_bytes, &mut changed)?;
6846            }
6847        }
6848        flush(&mut window, &mut window_bytes, &mut changed)?;
6849    }
6850    for (path, prior) in base_assets {
6851        if remote_assets.contains_key(path)
6852            || kept_home(path)
6853            || !asset_merge.accept_remote.contains(path)
6854        {
6855            continue;
6856        }
6857        let unchanged = local_store.as_ref().is_some_and(|store| {
6858            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6859                && store
6860                    .read_bounded(Path::new(path), prior.bytes)
6861                    .ok()
6862                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6863        });
6864        if unchanged {
6865            deleted.push(path.clone());
6866        }
6867    }
6868    let extra_local = content_merge
6869        .records
6870        .keys()
6871        .filter(|path| !remote.contains_key(*path))
6872        .cloned()
6873        .collect::<Vec<_>>();
6874    if head.view_kind == "scoped" {
6875        for (path, bytes) in [
6876            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6877            (
6878                ".dbmd/view.json".to_string(),
6879                scoped_view_metadata(&head, remote.len())?,
6880            ),
6881        ] {
6882            let sha256 = content_sha256(&bytes);
6883            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6884            changed.push(V2StagedFile {
6885                path,
6886                source,
6887                sha256,
6888                bytes: bytes.len() as u64,
6889            });
6890        }
6891    }
6892    let install_changed = !changed.is_empty() || !deleted.is_empty();
6893    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6894    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6895        let installed_store =
6896            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6897                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6898            })?;
6899        let installed_local = if install_changed {
6900            let mut scanned = v2_local_files(&installed_store)?;
6901            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6902            scanned
6903        } else {
6904            local_view
6905                .take()
6906                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6907        };
6908        if installed_local.riding != content_merge.records {
6909            return Err(LinkError::InvalidPack {
6910                message: "local content changed while installing the v2 pull".to_string(),
6911            });
6912        }
6913        let installed_assets = if install_changed {
6914            v2_local_asset_records(&installed_store)?
6915        } else {
6916            std::mem::take(&mut local_assets)
6917        };
6918        if installed_assets != asset_merge.records {
6919            return Err(LinkError::InvalidPack {
6920                message: "local assets changed while installing the v2 pull".to_string(),
6921            });
6922        }
6923        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6924            installed_local.policy.keeps_home(path)
6925        })
6926            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6927        let final_head = v2_verified_head(cfg, requested_brain)?
6928            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6929        if !same_v2_head(&head, &final_head) {
6930            return Err(LinkError::RemoteAdvancedDuringSync);
6931        }
6932        accept_v2_head(cfg, &final_head)?;
6933        save_v2_baseline(
6934            cfg,
6935            &head.brain_id,
6936            &dest,
6937            &v2_baseline_from_head(
6938                cfg,
6939                &head,
6940                remote.clone(),
6941                remote_assets.clone(),
6942                Some(&installed_local),
6943                baseline
6944                    .as_ref()
6945                    .and_then(|current| current.checkout_id.as_deref()),
6946            )?,
6947        )?;
6948        complete_v2_pull(&dest)?;
6949        Ok((local_dirty, installed_local, installed_assets))
6950    })();
6951    let (local_dirty, installed_local, installed_assets) = match finalized {
6952        Ok(value) => value,
6953        Err(error) => {
6954            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6955                return Err(LinkError::InvalidPack {
6956                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
6957                });
6958            }
6959            return Err(error);
6960        }
6961    };
6962    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6963    let report = PullReport {
6964        brain: head.brain_id.clone(),
6965        slug: requested_brain.to_string(),
6966        head_seq: pointer.map_or(0, |value| value.seq),
6967        files: remote.len() + remote_assets.len(),
6968        dest: dest.to_string_lossy().into_owned(),
6969        extra_local,
6970        sync_status: if local_dirty {
6971            "local_dirty_after_install".to_string()
6972        } else {
6973            "synced".to_string()
6974        },
6975    };
6976    Ok(V2PulledSnapshot {
6977        report,
6978        head,
6979        files: remote,
6980        assets: remote_assets,
6981        local: installed_local,
6982        local_assets: installed_assets,
6983    })
6984}
6985
6986fn v2_sync_pull(
6987    cfg: &HubConfig,
6988    requested_brain: &str,
6989    head: V2VerifiedHead,
6990    out: Option<&Path>,
6991) -> LinkResult<PullReport> {
6992    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6993}
6994
6995fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6996    match remote {
6997        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6998        None => json!({ "kind": "absent" }),
6999    }
7000}
7001
7002fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7003    match remote {
7004        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7005        None => json!({ "kind": "absent" }),
7006    }
7007}
7008
7009fn v2_content_withdrawal_operation(
7010    store: &Store,
7011    local_view: &V2LocalView,
7012    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7013    path: &str,
7014    reason: &str,
7015) -> LinkResult<Value> {
7016    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7017        || path == "DB.md"
7018    {
7019        return Err(LinkError::InvalidPack {
7020            message: format!("content withdrawal path `{path}` is not a record or source"),
7021        });
7022    }
7023    if !local_view.policy.keeps_home(path)
7024        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7025    {
7026        return Err(LinkError::InvalidPack {
7027            message: format!(
7028                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7029            ),
7030        });
7031    }
7032    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7033        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7034    })?;
7035    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
7036    Ok(json!({
7037        "op": "withdraw_from_hosting",
7038        "path": path,
7039        "expected": { "kind": "blob", "hash": current.sha256 },
7040        "reason": reason,
7041    }))
7042}
7043
7044fn v2_asset_withdrawal_operation(
7045    store: &Store,
7046    local_view: &V2LocalView,
7047    path: &str,
7048    local: &crate::AssetRecord,
7049    current: &V2BaselineAsset,
7050    reason: &str,
7051) -> LinkResult<Value> {
7052    if !local_view.policy.keeps_home(path)
7053        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7054    {
7055        return Err(LinkError::InvalidPack {
7056            message: format!(
7057                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7058            ),
7059        });
7060    }
7061    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7062    if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
7063        return Err(LinkError::InvalidPack {
7064            message: format!(
7065                "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
7066            ),
7067        });
7068    }
7069    Ok(json!({
7070        "op": "asset_withdraw",
7071        "path": path,
7072        "expected": v2_asset_expected(Some(current)),
7073        "reason": reason,
7074    }))
7075}
7076
7077// A source is immutable, but its exact bytes may move from an intake namespace
7078// to an archive namespace under the distinct `promote_source` capability. A
7079// plain filesystem move appears to sync as one absent old path plus one new
7080// path. Preserve that intent as a protocol rename only when the hash has one
7081// unambiguous source delete and one unambiguous absent-destination source put.
7082// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
7083fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7084    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7085    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7086    for (index, operation) in operations.iter().enumerate() {
7087        match operation.get("op").and_then(Value::as_str) {
7088            Some("delete") => {
7089                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7090                    continue;
7091                };
7092                let Some(hash) = operation
7093                    .get("expected")
7094                    .and_then(|value| value.get("hash"))
7095                    .and_then(Value::as_str)
7096                else {
7097                    continue;
7098                };
7099                if path.starts_with("sources/") {
7100                    deletes
7101                        .entry(hash.to_string())
7102                        .or_default()
7103                        .push((index, path.to_string()));
7104                }
7105            }
7106            Some("put") => {
7107                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7108                    continue;
7109                };
7110                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7111                    continue;
7112                };
7113                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7114                    continue;
7115                };
7116                let destination_absent = operation
7117                    .get("expected")
7118                    .and_then(|value| value.get("kind"))
7119                    .and_then(Value::as_str)
7120                    == Some("absent");
7121                if path.starts_with("sources/") && destination_absent {
7122                    puts.entry(hash.to_string()).or_default().push((
7123                        index,
7124                        path.to_string(),
7125                        bytes,
7126                    ));
7127                }
7128            }
7129            _ => {}
7130        }
7131    }
7132    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7133    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7134    for (hash, source) in deletes {
7135        let Some(destination) = puts.get(&hash) else {
7136            continue;
7137        };
7138        if source.len() != 1 || destination.len() != 1 {
7139            continue;
7140        }
7141        let (delete_index, from) = &source[0];
7142        let (put_index, to, bytes) = &destination[0];
7143        if from == to {
7144            continue;
7145        }
7146        rename_at.insert(
7147            *delete_index,
7148            json!({
7149                "op": "rename",
7150                "from": from,
7151                "to": to,
7152                "expected_from": { "kind": "blob", "hash": hash },
7153                "expected_to": { "kind": "absent" },
7154                "blob": hash,
7155                "bytes": bytes,
7156            }),
7157        );
7158        consumed_puts.insert(*put_index);
7159    }
7160    operations
7161        .into_iter()
7162        .enumerate()
7163        .filter_map(|(index, operation)| {
7164            if let Some(rename) = rename_at.remove(&index) {
7165                Some(rename)
7166            } else if consumed_puts.contains(&index) {
7167                None
7168            } else {
7169                Some(operation)
7170            }
7171        })
7172        .collect()
7173}
7174
7175fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7176    json!({
7177        "blob_sha256": record.sha256,
7178        "bytes": record.bytes,
7179        "media_type": record.media_type,
7180        "wrappers": record.wrappers,
7181        "required": record.required,
7182        "disposition": disposition,
7183    })
7184}
7185
7186/// Apply the exact operation subset generated by this client to an already
7187/// verified manifest. This avoids downloading the same 50k-path listing again
7188/// after an unrebased commit; a rebased response still re-reads the manifest.
7189fn apply_generated_v2_operations(
7190    operations: &[Value],
7191    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7192    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7193    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7194) -> LinkResult<bool> {
7195    let mut asset_changed = false;
7196    for operation in operations {
7197        match operation.get("op").and_then(Value::as_str) {
7198            Some("put") => {
7199                let path = operation
7200                    .get("path")
7201                    .and_then(Value::as_str)
7202                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7203                let sha256 = operation
7204                    .get("blob")
7205                    .and_then(Value::as_str)
7206                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7207                let bytes = operation
7208                    .get("bytes")
7209                    .and_then(Value::as_u64)
7210                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7211                candidate.insert(
7212                    path.to_string(),
7213                    V2BaselineFile {
7214                        sha256: sha256.to_string(),
7215                        bytes,
7216                        proof: None,
7217                    },
7218                );
7219            }
7220            Some("rename") => {
7221                let from = operation
7222                    .get("from")
7223                    .and_then(Value::as_str)
7224                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7225                let to = operation
7226                    .get("to")
7227                    .and_then(Value::as_str)
7228                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7229                let sha256 = operation
7230                    .get("blob")
7231                    .and_then(Value::as_str)
7232                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7233                let bytes = operation
7234                    .get("bytes")
7235                    .and_then(Value::as_u64)
7236                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7237                let expected_from = operation
7238                    .get("expected_from")
7239                    .and_then(|expected| expected.get("hash"))
7240                    .and_then(Value::as_str);
7241                let expected_to_absent = operation
7242                    .get("expected_to")
7243                    .and_then(|expected| expected.get("kind"))
7244                    .and_then(Value::as_str)
7245                    == Some("absent");
7246                if from == to
7247                    || !from.starts_with("sources/")
7248                    || !to.starts_with("sources/")
7249                    || expected_from != Some(sha256)
7250                    || !expected_to_absent
7251                    || candidate.contains_key(to)
7252                {
7253                    return Err(invalid_feed("generated v2 source rename is malformed"));
7254                }
7255                let source = candidate
7256                    .remove(from)
7257                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7258                if source.sha256 != sha256 || source.bytes != bytes {
7259                    return Err(invalid_feed(
7260                        "v2 rename source differs from its exact-byte claim",
7261                    ));
7262                }
7263                candidate.insert(
7264                    to.to_string(),
7265                    V2BaselineFile {
7266                        sha256: sha256.to_string(),
7267                        bytes,
7268                        proof: None,
7269                    },
7270                );
7271            }
7272            Some("delete" | "withdraw_from_hosting") => {
7273                let path = operation
7274                    .get("path")
7275                    .and_then(Value::as_str)
7276                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7277                candidate.remove(path);
7278            }
7279            Some("asset_delete") => {
7280                let path = operation
7281                    .get("path")
7282                    .and_then(Value::as_str)
7283                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7284                candidate_assets.remove(path);
7285                asset_changed = true;
7286            }
7287            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7288                let path = operation
7289                    .get("path")
7290                    .and_then(Value::as_str)
7291                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7292                let record = local_assets
7293                    .get(path)
7294                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7295                let disposition =
7296                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7297                        "withheld"
7298                    } else {
7299                        operation
7300                            .get("asset")
7301                            .and_then(|asset| asset.get("disposition"))
7302                            .and_then(Value::as_str)
7303                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7304                    };
7305                candidate_assets.insert(
7306                    path.to_string(),
7307                    V2BaselineAsset {
7308                        blob_sha256: record.sha256.clone(),
7309                        bytes: record.bytes,
7310                        media_type: record.media_type.clone(),
7311                        wrappers: record.wrappers.clone(),
7312                        required: record.required,
7313                        disposition: disposition.to_string(),
7314                        // The server assigns the signed leaf address. Callers
7315                        // that need an exact asset manifest re-read it below.
7316                        leaf_hash: String::new(),
7317                    },
7318                );
7319                asset_changed = true;
7320            }
7321            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7322        }
7323    }
7324    Ok(asset_changed)
7325}
7326
7327fn v2_riding_matches_remote(
7328    local: &std::collections::BTreeMap<String, (String, u64)>,
7329    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7330    keeps_home: impl Fn(&str) -> bool,
7331) -> bool {
7332    remote.iter().all(|(path, file)| {
7333        keeps_home(path)
7334            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7335    }) && local.iter().all(|(path, (hash, _))| {
7336        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7337    })
7338}
7339
7340#[derive(Debug, Clone)]
7341struct V2ResolutionOverride {
7342    expected_remote: Option<String>,
7343    selected_local: Option<String>,
7344}
7345
7346#[derive(Debug, Clone)]
7347struct V2UploadSource {
7348    path: String,
7349    bytes: u64,
7350}
7351
7352struct V2SyncPushOptions<'a> {
7353    resume_local_policy: bool,
7354    bulk_confirmation: Option<&'a V2BulkConfirmation>,
7355    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7356    pulled: Option<V2PulledSnapshot>,
7357    withdrawal_paths: &'a [String],
7358    withdrawal_reason: Option<&'a str>,
7359}
7360
7361fn verify_v2_upload_source(
7362    store: &Store,
7363    path: &str,
7364    sha256: &str,
7365    expected_bytes: u64,
7366) -> LinkResult<()> {
7367    let file = store.open_regular(Path::new(path))?;
7368    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7369        return Err(LinkError::InvalidPack {
7370            message: format!("local path `{path}` changed during sync planning"),
7371        });
7372    }
7373    Ok(())
7374}
7375
7376/// One reserved object waiting to be staged, held until its whole batch can be
7377/// uploaded together.
7378struct V2PendingUpload<'a> {
7379    url: String,
7380    headers: Value,
7381    sha256: String,
7382    source: &'a V2UploadSource,
7383}
7384
7385/// How many objects a migration stages at once. Bounded so a first push does
7386/// not behave like a burst against the object store, while still being far
7387/// faster than one-at-a-time.
7388/// Workers staging one batch. With a shared pooled connection the cost of an
7389/// object is the PUT itself rather than a handshake, so a wider pool converts
7390/// directly into throughput on a migration.
7391const V2_UPLOAD_CONCURRENCY: usize = 16;
7392
7393/// Stage a reserved batch, uploading up to `V2_UPLOAD_CONCURRENCY` objects at a
7394/// time and verifying each against the store after it lands. The first failure
7395/// is returned and the remaining work is abandoned.
7396fn upload_v2_batch_concurrently(
7397    cfg: &HubConfig,
7398    store: &Store,
7399    pending: &[V2PendingUpload<'_>],
7400) -> LinkResult<()> {
7401    if pending.is_empty() {
7402        return Ok(());
7403    }
7404    let urls = pending
7405        .iter()
7406        .map(|task| task.url.as_str())
7407        .collect::<Vec<_>>();
7408    let shared = shared_staging_agent(cfg, &urls);
7409    if pending.len() == 1 {
7410        let task = &pending[0];
7411        put_presigned_source(
7412            cfg,
7413            &task.url,
7414            &task.headers,
7415            store,
7416            task.source,
7417            shared.as_ref(),
7418        )?;
7419        return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7420    }
7421    let next = std::sync::atomic::AtomicUsize::new(0);
7422    let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7423    let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7424    std::thread::scope(|scope| {
7425        for _ in 0..workers {
7426            scope.spawn(|| loop {
7427                if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7428                    return;
7429                }
7430                let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7431                let Some(task) = pending.get(index) else {
7432                    return;
7433                };
7434                let outcome = put_presigned_source(
7435                    cfg,
7436                    &task.url,
7437                    &task.headers,
7438                    store,
7439                    task.source,
7440                    shared.as_ref(),
7441                )
7442                .and_then(|()| {
7443                    verify_v2_upload_source(
7444                        store,
7445                        &task.source.path,
7446                        &task.sha256,
7447                        task.source.bytes,
7448                    )
7449                });
7450                if let Err(error) = outcome {
7451                    if let Ok(mut guard) = failure.lock() {
7452                        guard.get_or_insert(error);
7453                    }
7454                    return;
7455                }
7456            });
7457        }
7458    });
7459    match failure.into_inner() {
7460        Ok(Some(error)) => Err(error),
7461        Ok(None) => Ok(()),
7462        Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7463    }
7464}
7465
7466fn put_presigned_source(
7467    cfg: &HubConfig,
7468    raw: &str,
7469    headers: &Value,
7470    store: &Store,
7471    source: &V2UploadSource,
7472    shared: Option<&ureq::Agent>,
7473) -> LinkResult<()> {
7474    put_presigned_source_with_budget(
7475        cfg,
7476        raw,
7477        headers,
7478        store,
7479        source,
7480        shared,
7481        std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7482    )
7483}
7484
7485fn put_presigned_source_with_budget(
7486    cfg: &HubConfig,
7487    raw: &str,
7488    headers: &Value,
7489    store: &Store,
7490    source: &V2UploadSource,
7491    shared: Option<&ureq::Agent>,
7492    total_budget: std::time::Duration,
7493) -> LinkResult<()> {
7494    // The URL's own safety checks run either way; only the connection pool is
7495    // shared, so a batch never trades a check for a warm socket.
7496    let owned = match shared {
7497        Some(_) => {
7498            checked_presigned_url(cfg, raw)?;
7499            None
7500        }
7501        None => Some(presigned_agent(cfg, raw)?),
7502    };
7503    let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7504    let deadline = std::time::Instant::now()
7505        .checked_add(total_budget)
7506        .ok_or_else(upload_deadline_error)?;
7507    let mut attempt = 0;
7508    let result = loop {
7509        let file = store.open_regular(Path::new(&source.path))?;
7510        if file.metadata()?.len() != source.bytes {
7511            return Err(LinkError::InvalidPack {
7512                message: format!("local path `{}` changed before upload", source.path),
7513            });
7514        }
7515        // The reservation already states content-length, lowercased. The HTTP
7516        // client de-duplicates headers by exact name, not case, so setting
7517        // `Content-Length` here as well leaves both on the wire and an edge
7518        // rejects the request as malformed. Supply it only when absent.
7519        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7520        let mut has_content_length = false;
7521        if let Some(map) = headers.as_object() {
7522            for (name, value) in map {
7523                if let Some(value) = value.as_str() {
7524                    has_content_length |= name.eq_ignore_ascii_case("content-length");
7525                    req = req.set(name, value);
7526                }
7527            }
7528        }
7529        if !has_content_length {
7530            req = req.set("Content-Length", &source.bytes.to_string());
7531        }
7532        match req.send(file) {
7533            // Every transport failure is retryable here, not only the
7534            // pre-request kinds: staging an object is idempotent (fixed content
7535            // address, exact length and checksum, write-once precondition), the
7536            // file is reopened on each attempt, and a migration holds
7537            // connections long enough that a mid-request drop is ordinary.
7538            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7539                attempt += 1;
7540            }
7541            // A migration stages tens of thousands of immutable objects, and an
7542            // edge or object store may refuse a single one under sustained
7543            // load. The upload is content-addressed and precondition-guarded,
7544            // so replaying it is safe; failing the whole push on one refusal is
7545            // not. 412 already means "someone else stored these exact bytes".
7546            Err(ureq::Error::Status(status, _))
7547                if status != 412
7548                    && is_retryable_upload_status(status)
7549                    && wait_for_upload_retry(deadline, attempt) =>
7550            {
7551                attempt += 1;
7552            }
7553            result => break result,
7554        }
7555    };
7556    match result {
7557        Ok(response) if (200..300).contains(&response.status()) => {
7558            drain_presigned_response(response);
7559            Ok(())
7560        }
7561        Ok(response) => {
7562            // The object store states its own reason (checksum mismatch,
7563            // precondition, expiry). Carrying a bounded excerpt turns an
7564            // unactionable refusal into one an operator can diagnose; the
7565            // coordinate is the store path, never file content.
7566            let status = response.status();
7567            let detail = response
7568                .into_string()
7569                .ok()
7570                .map(|body| body.chars().take(400).collect::<String>())
7571                .filter(|body| !body.trim().is_empty());
7572            Err(LinkError::Http {
7573                what: "v2 changed-byte upload",
7574                status,
7575                message: match detail {
7576                    Some(body) => format!(
7577                        "object store rejected the upload of `{}`: {}",
7578                        source.path,
7579                        body.replace('\n', " ")
7580                    ),
7581                    None => format!("object store rejected the upload of `{}`", source.path),
7582                },
7583                code: None,
7584                details: None,
7585            })
7586        }
7587        Err(error) => match error {
7588            ureq::Error::Status(412, _) => Ok(()),
7589            ureq::Error::Status(_, response) => {
7590                let status = response.status();
7591                let detail = response
7592                    .into_string()
7593                    .ok()
7594                    .map(|body| body.chars().take(400).collect::<String>())
7595                    .filter(|body| !body.trim().is_empty());
7596                Err(LinkError::Http {
7597                    what: "v2 changed-byte upload",
7598                    status,
7599                    message: match detail {
7600                        Some(body) => format!(
7601                            "object store rejected the upload of `{}`: {}",
7602                            source.path,
7603                            body.replace('\n', " ")
7604                        ),
7605                        None => {
7606                            format!("object store rejected the upload of `{}`", source.path)
7607                        }
7608                    },
7609                    code: None,
7610                    details: None,
7611                })
7612            }
7613            ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7614        },
7615    }
7616}
7617
7618/// The request as it is hashed and signed. A staged push carries its operations
7619/// by address rather than inline, so the signed view restores them: the request
7620/// hash covers the change itself, never the transport that carried it.
7621fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7622    if body.get("operations").is_some() {
7623        return body.clone();
7624    }
7625    let mut value = body.clone();
7626    if let Some(map) = value.as_object_mut() {
7627        map.remove("staged_change");
7628        map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7629    }
7630    value
7631}
7632
7633/// Open one staging window, waiting out a busy hub rather than abandoning the
7634/// push. The call reserves capacity and mints URLs; it commits nothing, so
7635/// asking again after a refusal costs only the wait.
7636fn reserve_upload_window(
7637    cfg: &HubConfig,
7638    path: &str,
7639    body: &Value,
7640    what: &'static str,
7641) -> LinkResult<Value> {
7642    let mut attempt = 0;
7643    loop {
7644        let pause = |attempt: usize| {
7645            std::thread::sleep(std::time::Duration::from_millis(
7646                RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7647            ));
7648        };
7649        match request(cfg, "POST", path, Some(body), Auth::Required) {
7650            // A migration opens dozens of windows over tens of minutes, so one
7651            // response that never arrives is ordinary rather than final. The
7652            // call reserves capacity and mints URLs; it commits nothing, so a
7653            // dropped or timed-out attempt is worth asking again.
7654            Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7655                pause(attempt);
7656                attempt += 1;
7657            }
7658            Err(error) => return Err(error),
7659            Ok(response) => {
7660                if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7661                    pause(attempt);
7662                    attempt += 1;
7663                    continue;
7664                }
7665                return ensure_ok(response, what);
7666            }
7667        }
7668    }
7669}
7670
7671/// The exact bytes of a staged change. The hub refuses a manifest carrying any
7672/// other key, so this is the whole contract: operations, and the blob
7673/// references they need, and nothing else.
7674fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7675    let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7676        .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7677    if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7678        return Err(LinkError::PushTooLarge {
7679            detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7680        });
7681    }
7682    Ok(bytes)
7683}
7684
7685/// State one change's operations and blob references through the same
7686/// content-addressed lane its bytes already ride.
7687///
7688/// A first push of a real store names one operation and one blob reference per
7689/// file, which alone outgrows the bounded commit request. Staging that metadata
7690/// and addressing it by hash keeps the push a single atomic commit: the hub
7691/// verifies the address, splices the manifest in, and validates exactly what it
7692/// would have validated inline. The alternative — many partial commits — would
7693/// make a failed migration leave a half-populated brain readable.
7694fn stage_v2_change(
7695    cfg: &HubConfig,
7696    requested_brain: &str,
7697    operations: &[Value],
7698    blobs: Value,
7699) -> LinkResult<Value> {
7700    let bytes = v2_change_manifest(operations, blobs)?;
7701    let sha256 = content_sha256(&bytes);
7702    let reserved = reserve_upload_window(
7703        cfg,
7704        &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7705        &json!({
7706            "blobs": [{
7707                "sha256": sha256,
7708                "bytes": bytes.len(),
7709                "kind": "staged_change",
7710            }],
7711        }),
7712        "stage the v2 change",
7713    )?;
7714    let items = reserved
7715        .get("uploads")
7716        .and_then(Value::as_array)
7717        .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7718    let [item] = items.as_slice() else {
7719        return Err(invalid_feed(
7720            "v2 change staging response changed the requested set",
7721        ));
7722    };
7723    let reservation_id = item
7724        .get("reservation_id")
7725        .and_then(Value::as_str)
7726        .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7727    if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7728        || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7729        || !crate::ulid::is_ulid(reservation_id)
7730    {
7731        return Err(invalid_feed("v2 change staging item is inconsistent"));
7732    }
7733    match item.get("status").and_then(Value::as_str) {
7734        Some("upload") => put_presigned(
7735            cfg,
7736            item.get("url")
7737                .and_then(Value::as_str)
7738                .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7739            item.get("headers").unwrap_or(&Value::Null),
7740            &bytes,
7741        )?,
7742        Some("already_present") => {}
7743        _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7744    }
7745    Ok(json!({
7746        "sha256": sha256,
7747        "bytes": bytes.len(),
7748        "reservation_id": reservation_id,
7749    }))
7750}
7751
7752/// Move a commit request's operations and blob references off the wire body
7753/// when they no longer fit in it. Idempotent by construction: the mutation ID
7754/// is derived from the change, never from how the change was transported.
7755fn stage_oversized_v2_change(
7756    cfg: &HubConfig,
7757    requested_brain: &str,
7758    operations: &[Value],
7759    body: &mut Value,
7760) -> LinkResult<()> {
7761    if body.to_string().len() <= MAX_PUSH_BYTES {
7762        return Ok(());
7763    }
7764    let staged = stage_v2_change(
7765        cfg,
7766        requested_brain,
7767        operations,
7768        body.get("blobs")
7769            .cloned()
7770            .unwrap_or(Value::Array(Vec::new())),
7771    )?;
7772    let map = body
7773        .as_object_mut()
7774        .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7775    map.remove("operations");
7776    map.remove("blobs");
7777    map.insert("staged_change".to_string(), staged);
7778    Ok(())
7779}
7780
7781fn v2_sync_push(
7782    cfg: &HubConfig,
7783    requested_brain: &str,
7784    store: &Store,
7785    head: V2VerifiedHead,
7786    options: V2SyncPushOptions<'_>,
7787) -> LinkResult<Value> {
7788    let V2SyncPushOptions {
7789        resume_local_policy,
7790        bulk_confirmation,
7791        resolution,
7792        pulled,
7793        withdrawal_paths,
7794        withdrawal_reason,
7795    } = options;
7796    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7797    let head = v2_verified_head(cfg, requested_brain)?
7798        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7799    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7800    ensure_v2_view_compatible(&head, baseline.as_ref())?;
7801    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7802        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7803            Some(snapshot) => (
7804                snapshot.files,
7805                snapshot.assets,
7806                Some(snapshot.local),
7807                Some(snapshot.local_assets),
7808            ),
7809            None => match baseline
7810                .as_ref()
7811                .filter(|state| v2_baseline_matches_head(&head, state))
7812            {
7813                Some(state) => (state.files.clone(), state.assets.clone(), None, None),
7814                None => (
7815                    files_for_v2_view(
7816                        &head,
7817                        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7818                    ),
7819                    v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7820                    None,
7821                    None,
7822                ),
7823            },
7824        };
7825    if head.view_kind == "scoped" && baseline.is_none() {
7826        return Err(LinkError::ScopedViewChanged);
7827    }
7828    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7829    let local = &local_view.riding;
7830    let local_assets = match carried_local_assets {
7831        Some(assets) => assets,
7832        None => v2_local_asset_records(store)?,
7833    };
7834    if withdrawal_paths.len() > MAX_PUSH_FILES {
7835        return Err(LinkError::PushTooLarge {
7836            detail: "too many explicit withdrawal paths".to_string(),
7837        });
7838    }
7839    let withdrawal_reason = if withdrawal_paths.is_empty() {
7840        None
7841    } else {
7842        let reason = withdrawal_reason
7843            .map(str::trim)
7844            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7845            .ok_or_else(|| LinkError::InvalidPack {
7846                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7847            })?;
7848        Some(reason)
7849    };
7850    let mut withdrawals = withdrawal_paths
7851        .iter()
7852        .map(|path| {
7853            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7854                path: error.to_string(),
7855            })
7856        })
7857        .collect::<LinkResult<Vec<_>>>()?;
7858    withdrawals.sort();
7859    withdrawals.dedup();
7860    if withdrawals.len() != withdrawal_paths.len() {
7861        return Err(LinkError::InvalidPack {
7862            message: "explicit withdrawal paths must be unique".to_string(),
7863        });
7864    }
7865    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7866    let mut consumed_withdrawals = BTreeSet::new();
7867    if let Some(previous) = baseline.as_ref() {
7868        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7869            && !resume_local_policy
7870        {
7871            let mut newly_eligible = previous
7872                .local_eligibility
7873                .iter()
7874                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7875                .map(|(path, _)| path.clone())
7876                .collect::<Vec<_>>();
7877            if !newly_eligible.is_empty() {
7878                newly_eligible.truncate(100);
7879                return Err(LinkError::LocalPolicyTransition {
7880                    paths: newly_eligible,
7881                });
7882            }
7883        }
7884    }
7885    let base = match baseline.as_ref() {
7886        Some(state) => &state.files,
7887        None if remote.is_empty() => &remote,
7888        None => {
7889            let mut conflicts = remote
7890                .iter()
7891                .filter(|(path, file)| {
7892                    local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7893                })
7894                .map(|(path, _)| path.clone())
7895                .collect::<Vec<_>>();
7896            if !conflicts.is_empty() {
7897                conflicts.truncate(100);
7898                let (bundle, paths) =
7899                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7900                return Err(LinkError::ConflictBundle { bundle, paths });
7901            }
7902            &remote
7903        }
7904    };
7905    let all_paths = base
7906        .keys()
7907        .chain(remote.keys())
7908        .chain(local.keys())
7909        .cloned()
7910        .collect::<std::collections::BTreeSet<_>>();
7911    let mut conflicts = Vec::new();
7912    let mut operations = Vec::new();
7913    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7914    for path in all_paths {
7915        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7916        let remote_file = remote.get(&path);
7917        let remote_hash = remote_file.map(|file| file.sha256.as_str());
7918        let local_file = local.get(&path);
7919        let local_hash = local_file.map(|file| file.0.as_str());
7920        if local_hash == base_hash {
7921            continue;
7922        }
7923        if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
7924            continue;
7925        }
7926        if local_view.policy.keeps_home(&path) {
7927            // Kept-home is a local transfer exclusion, never an implicit
7928            // delete of the company's already-hosted coordinate.
7929            continue;
7930        }
7931        if remote_hash != base_hash && local_hash != remote_hash {
7932            let explicitly_resolved = resolution
7933                .and_then(|allowed| allowed.get(&path))
7934                .is_some_and(|selected| {
7935                    selected.expected_remote.as_deref() == remote_hash
7936                        && selected.selected_local.as_deref() == local_hash
7937                });
7938            if !explicitly_resolved {
7939                conflicts.push(path);
7940                continue;
7941            }
7942        }
7943        match local_file {
7944            Some((sha256, byte_count)) => {
7945                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7946                operations.push(json!({
7947                    "op": "put",
7948                    "path": path,
7949                    "expected": v2_expected(remote_file),
7950                    "blob": sha256,
7951                    "bytes": byte_count,
7952                }));
7953                upload_sources
7954                    .entry(sha256.clone())
7955                    .or_insert_with(|| V2UploadSource {
7956                        path: path.clone(),
7957                        bytes: *byte_count,
7958                    });
7959            }
7960            None => {
7961                let Some(current) = remote_file else {
7962                    continue;
7963                };
7964                operations.push(json!({
7965                    "op": "delete",
7966                    "path": path,
7967                    "expected": { "kind": "blob", "hash": current.sha256 },
7968                }));
7969            }
7970        }
7971    }
7972    operations = infer_exact_source_promotions(operations);
7973    for path in &withdrawals {
7974        if local_assets.contains_key(path) {
7975            continue;
7976        }
7977        operations.push(v2_content_withdrawal_operation(
7978            store,
7979            &local_view,
7980            &remote,
7981            path,
7982            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7983        )?);
7984        consumed_withdrawals.insert(path.clone());
7985    }
7986    if !conflicts.is_empty() {
7987        conflicts.truncate(100);
7988        let (bundle, paths) = create_v2_conflict_bundle(
7989            cfg,
7990            store,
7991            &head,
7992            baseline.as_ref(),
7993            local,
7994            &remote,
7995            &conflicts,
7996        )?;
7997        return Err(LinkError::ConflictBundle { bundle, paths });
7998    }
7999    let base_assets = match baseline.as_ref() {
8000        Some(state) => &state.assets,
8001        None if remote_assets.is_empty() => &remote_assets,
8002        None => {
8003            let mismatched = remote_assets.iter().any(|(path, remote)| {
8004                local_assets.get(path) != Some(&v2_asset_record(remote, path))
8005            }) || local_assets.len() != remote_assets.len();
8006            if mismatched {
8007                return Err(LinkError::Conflict {
8008                    paths: vec!["assets.jsonl".to_string()],
8009                });
8010            }
8011            &remote_assets
8012        }
8013    };
8014    let asset_paths = base_assets
8015        .keys()
8016        .chain(remote_assets.keys())
8017        .chain(local_assets.keys())
8018        .cloned()
8019        .collect::<std::collections::BTreeSet<_>>();
8020    let mut asset_policy_transitions = Vec::new();
8021    for path in asset_paths {
8022        let base_record = base_assets
8023            .get(&path)
8024            .map(|asset| v2_asset_record(asset, &path));
8025        let remote = remote_assets.get(&path);
8026        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8027        let local_record = local_assets.get(&path);
8028        if withdrawal_set.contains(&path) {
8029            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8030                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8031            })?;
8032            let current = remote.ok_or_else(|| LinkError::InvalidPack {
8033                message: format!(
8034                    "asset withdrawal path `{path}` has no readable hosted coordinate"
8035                ),
8036            })?;
8037            operations.push(v2_asset_withdrawal_operation(
8038                store,
8039                &local_view,
8040                &path,
8041                record,
8042                current,
8043                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8044            )?);
8045            consumed_withdrawals.insert(path.clone());
8046            continue;
8047        }
8048        let mut raw_present = false;
8049        let mut disposition = "withheld";
8050        let mut resumes_hosting = false;
8051        if let Some(record) = local_record {
8052            crate::linkmd_v2::normalize_path(&record.path)
8053                .map_err(|error| invalid_feed(error.to_string()))?;
8054            let kept_home = local_view.policy.keeps_home(&path);
8055            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8056            disposition = if kept_home || !raw_present {
8057                "withheld"
8058            } else {
8059                "hosted"
8060            };
8061            if !raw_present && record.required && !kept_home {
8062                return Err(LinkError::InvalidPack {
8063                    message: format!("required asset {path} is missing"),
8064                });
8065            }
8066            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8067        }
8068        if local_record == base_record.as_ref() && !resumes_hosting {
8069            continue;
8070        }
8071        if remote_record != base_record && local_record != remote_record.as_ref() {
8072            conflicts.push(path);
8073            continue;
8074        }
8075        let Some(record) = local_record else {
8076            if let Some(remote) = remote {
8077                operations.push(json!({
8078                    "op": "asset_delete",
8079                    "path": path,
8080                    "expected": v2_asset_expected(Some(remote)),
8081                }));
8082            }
8083            continue;
8084        };
8085        let raw = if raw_present {
8086            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8087            Some(())
8088        } else {
8089            None
8090        };
8091        let op = if resumes_hosting {
8092            if !resume_local_policy {
8093                asset_policy_transitions.push(path);
8094                continue;
8095            }
8096            "asset_resume"
8097        } else {
8098            "asset_put"
8099        };
8100        operations.push(json!({
8101            "op": op,
8102            "path": path,
8103            "expected": v2_asset_expected(remote),
8104            "asset": v2_asset_value(record, disposition),
8105        }));
8106        if disposition == "hosted" {
8107            raw.expect("hosted asset was checked present");
8108            upload_sources
8109                .entry(record.sha256.clone())
8110                .or_insert_with(|| V2UploadSource {
8111                    path: path.clone(),
8112                    bytes: record.bytes,
8113                });
8114        }
8115    }
8116    if consumed_withdrawals != withdrawal_set {
8117        let missing = withdrawal_set
8118            .difference(&consumed_withdrawals)
8119            .next()
8120            .expect("different withdrawal sets have one member");
8121        return Err(LinkError::InvalidPack {
8122            message: format!(
8123                "withdrawal path `{missing}` is not a readable content or asset coordinate"
8124            ),
8125        });
8126    }
8127    if !conflicts.is_empty() {
8128        conflicts.truncate(100);
8129        return Err(LinkError::Conflict { paths: conflicts });
8130    }
8131    if !asset_policy_transitions.is_empty() {
8132        asset_policy_transitions.truncate(100);
8133        return Err(LinkError::LocalPolicyTransition {
8134            paths: asset_policy_transitions,
8135        });
8136    }
8137    let touched_sources = operations
8138        .iter()
8139        .filter_map(
8140            |operation| match operation.get("op").and_then(Value::as_str) {
8141                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
8142                Some("rename") => operation.get("to").and_then(Value::as_str),
8143                _ => None,
8144            },
8145        )
8146        .collect::<std::collections::BTreeSet<_>>();
8147    let withheld_links = local_view
8148        .withheld_links
8149        .iter()
8150        .filter(|link| touched_sources.contains(link.source.as_str()))
8151        .collect::<Vec<_>>();
8152    let checkout_pseudonym = v2_checkout_id(
8153        baseline
8154            .as_ref()
8155            .and_then(|current| current.checkout_id.as_deref()),
8156    )?;
8157    let checkout_id = if withheld_links.is_empty() {
8158        None
8159    } else {
8160        Some(checkout_pseudonym.clone())
8161    };
8162    if operations.is_empty() {
8163        let final_head = v2_verified_head(cfg, requested_brain)?
8164            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8165        if !same_v2_head(&head, &final_head) {
8166            return Err(LinkError::RemoteAdvancedDuringSync);
8167        }
8168        let mut final_local = v2_local_files(store)?;
8169        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8170        let final_assets = v2_local_asset_records(store)?;
8171        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8172        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8173            final_local.policy.keeps_home(path)
8174        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8175        let next = v2_baseline_from_head(
8176            cfg,
8177            &head,
8178            remote,
8179            remote_assets,
8180            Some(&final_local),
8181            Some(&checkout_pseudonym),
8182        )?;
8183        let split_count = next.remote_copy_remains.len();
8184        accept_v2_head(cfg, &final_head)?;
8185        if !local_changed && !remote_ahead {
8186            refresh_scoped_view_marker(store, &head, next.files.len())?;
8187            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8188        }
8189        return Ok(json!({
8190            "v": 2,
8191            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8192            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8193            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8194            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8195            "local_policy": {
8196                "remote_copy_remains": split_count,
8197            },
8198        }));
8199    }
8200    let includes_contract = operations
8201        .iter()
8202        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8203    let rebase = if head.pointer.is_none() || includes_contract {
8204        "strict"
8205    } else {
8206        "disjoint"
8207    };
8208    let base_value = head.pointer.as_ref().map(|pointer| {
8209        json!({
8210            "seq": pointer.seq,
8211            "commit_hash": pointer.commit_hash,
8212            "content_root": pointer.content_root,
8213            "asset_root": pointer.asset_root,
8214        })
8215    });
8216    // The mutation coordinate is stable for the exact observed base and local
8217    // operation set. A lost response can therefore be retried without a
8218    // duplicate commit/proposal even across process restarts.
8219    let entropy = format!(
8220        "{}\0{}\0{}\0{}\0{}\0{}",
8221        normalized_origin(&cfg.hub)?,
8222        head.brain_id,
8223        serde_json::to_string(&base_value).unwrap_or_default(),
8224        serde_json::to_string(&operations).unwrap_or_default(),
8225        serde_json::to_string(&withheld_links).unwrap_or_default(),
8226        checkout_id.as_deref().unwrap_or("")
8227    );
8228    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8229    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8230        total
8231            .checked_add(source.bytes)
8232            .ok_or_else(|| LinkError::PushTooLarge {
8233                detail: "v2 changed-byte total overflow".to_string(),
8234            })
8235    })?;
8236    let inline = changed_bytes <= 3 * 1024 * 1024;
8237    let inline_blobs = if inline {
8238        upload_sources
8239            .iter()
8240            .map(|(sha256, source)| {
8241                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8242                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8243                    return Err(LinkError::InvalidPack {
8244                        message: format!("local path `{}` changed before upload", source.path),
8245                    });
8246                }
8247                Ok(json!({
8248                    "sha256": sha256,
8249                    "bytes": source.bytes,
8250                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8251                }))
8252            })
8253            .collect::<LinkResult<Vec<_>>>()?
8254    } else {
8255        Vec::new()
8256    };
8257    let mut body = json!({
8258        "mutation_id": mutation_id,
8259        "base": base_value,
8260        "rebase": rebase,
8261        "reason": "dbmd sync",
8262        "operations": operations,
8263        "blobs": inline_blobs,
8264    });
8265    if !withheld_links.is_empty() {
8266        body["withheld_links"] = serde_json::to_value(&withheld_links)
8267            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8268        body["checkout_id"] =
8269            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8270    }
8271    if let Some(confirmation) = bulk_confirmation {
8272        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8273            return Err(LinkError::InvalidPack {
8274                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8275                    .to_string(),
8276            });
8277        }
8278        // A preview is bound to the exact observed head. Confirmation can
8279        // therefore never use disjoint rebase semantics, even when the normal
8280        // small-mutation path could.
8281        body["rebase"] = Value::String("strict".to_string());
8282        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8283        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8284    }
8285    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8286        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8287        for operation in &operations {
8288            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8289                return Err(invalid_feed("v2 upload operation has no kind"));
8290            };
8291            let hash = match kind {
8292                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8293                "asset_put" | "asset_resume" => operation
8294                    .get("asset")
8295                    .and_then(|asset| asset.get("blob_sha256"))
8296                    .and_then(Value::as_str),
8297                _ => None,
8298            };
8299            let Some(hash) = hash else { continue };
8300            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8301            if kind == "rename" {
8302                for field in ["from", "to"] {
8303                    coordinates.insert(
8304                        operation
8305                            .get(field)
8306                            .and_then(Value::as_str)
8307                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8308                            .to_string(),
8309                    );
8310                }
8311            } else {
8312                let path = operation
8313                    .get("path")
8314                    .and_then(Value::as_str)
8315                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8316                coordinates.insert(if kind.starts_with("asset_") {
8317                    format!("assets/{path}")
8318                } else {
8319                    path.to_string()
8320                });
8321            }
8322        }
8323        let declarations = upload_sources
8324            .iter()
8325            .map(|(sha256, source)| {
8326                json!({
8327                    "sha256": sha256,
8328                    "bytes": source.bytes,
8329                    "coordinates": coordinates_by_hash
8330                        .get(sha256)
8331                        .into_iter()
8332                        .flatten()
8333                        .collect::<Vec<_>>(),
8334                })
8335            })
8336            .collect::<Vec<_>>();
8337        let mut references = Vec::with_capacity(upload_sources.len());
8338        let mut seen = std::collections::BTreeSet::new();
8339        let mut reserved_count = 0usize;
8340        let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8341        // A reservation's presigned URL is short-lived, so each batch's bytes
8342        // ride before the next batch is reserved. Reserving every blob first
8343        // and uploading afterwards expires the earliest URLs on a real store.
8344        for batch in batch_upload_declarations(declarations) {
8345            let batch_len = batch.len();
8346            let reserved = reserve_upload_window(
8347                cfg,
8348                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8349                &json!({ "blobs": batch }),
8350                "prepare v2 changed-byte uploads",
8351            )?;
8352            let items = reserved
8353                .get("uploads")
8354                .and_then(Value::as_array)
8355                .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8356            if items.len() != batch_len {
8357                return Err(invalid_feed(
8358                    "v2 upload reservation response changed the requested set",
8359                ));
8360            }
8361            reserved_count += items.len();
8362            for item in items {
8363                let sha256 = item
8364                    .get("sha256")
8365                    .and_then(Value::as_str)
8366                    .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8367                let source = upload_sources
8368                    .get(sha256)
8369                    .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8370                let declared_bytes = item
8371                    .get("bytes")
8372                    .and_then(Value::as_u64)
8373                    .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8374                let reservation_id = item
8375                    .get("reservation_id")
8376                    .and_then(Value::as_str)
8377                    .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8378                let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8379                    invalid_feed("v2 upload reservation has no coordinate binding")
8380                })?;
8381                let returned_coordinates = item
8382                    .get("coordinates")
8383                    .and_then(Value::as_array)
8384                    .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8385                if declared_bytes != source.bytes
8386                    || !crate::ulid::is_ulid(reservation_id)
8387                    || !seen.insert(sha256.to_string())
8388                    || returned_coordinates.len() != expected_coordinates.len()
8389                    || returned_coordinates
8390                        .iter()
8391                        .zip(expected_coordinates)
8392                        .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8393                {
8394                    return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8395                }
8396                match item.get("status").and_then(Value::as_str) {
8397                    Some("upload") => {
8398                        let url = item
8399                            .get("url")
8400                            .and_then(Value::as_str)
8401                            .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8402                        pending_uploads.push(V2PendingUpload {
8403                            url: url.to_string(),
8404                            headers: item.get("headers").cloned().unwrap_or(Value::Null),
8405                            sha256: sha256.to_string(),
8406                            source,
8407                        });
8408                    }
8409                    Some("already_present") => {}
8410                    _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8411                }
8412                references.push(json!({
8413                    "sha256": sha256,
8414                    "bytes": source.bytes,
8415                    "reservation_id": reservation_id,
8416                }));
8417            }
8418            // Stage this batch's bytes concurrently. Sequential PUTs move a real
8419            // store at a few objects a second, which both takes hours and pushes
8420            // the batch past its reservation's upload window; the object store
8421            // is content-addressed and precondition-guarded, so parallel writes
8422            // stay safe.
8423            upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8424            pending_uploads.clear();
8425        }
8426        if reserved_count != upload_sources.len() {
8427            return Err(invalid_feed(
8428                "v2 upload reservation response changed the requested set",
8429            ));
8430        }
8431        body["blobs"] = Value::Array(references);
8432    }
8433    stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8434    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8435    let mut candidate_hub_signer: Option<String> = None;
8436    let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8437    let bulk_preview_required = !(200..300).contains(&response.status)
8438        && response.body.as_ref().is_some_and(|value| {
8439            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8440                || value
8441                    .get("details")
8442                    .and_then(|details| details.get("code"))
8443                    .and_then(Value::as_str)
8444                    == Some("bulk_preview_required")
8445        });
8446    if bulk_preview_required && bulk_confirmation.is_none() {
8447        body["rebase"] = Value::String("strict".to_string());
8448        body["preview_only"] = Value::Bool(true);
8449        let preview = ensure_ok(
8450            request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8451            "v2 bulk preview",
8452        )?;
8453        let preview_code = preview.get("code").and_then(Value::as_str);
8454        let required = preview.get("required").and_then(Value::as_bool);
8455        if preview.get("v").and_then(Value::as_u64) != Some(2)
8456            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8457            || !matches!(
8458                preview_code,
8459                Some("bulk_preview_created" | "bulk_preview_not_required")
8460            )
8461            || required.is_none()
8462        {
8463            return Err(invalid_feed(
8464                "bulk preview response is not bound to the requested mutation",
8465            ));
8466        }
8467        if required == Some(true) {
8468            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8469            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8470            if preview_code != Some("bulk_preview_created")
8471                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8472                || preview_digest.is_none_or(|value| !is_sha256(value))
8473                || preview.get("expires_at").and_then(Value::as_str).is_none()
8474                || !preview.get("impact").is_some_and(Value::is_object)
8475            {
8476                return Err(invalid_feed("bulk preview receipt is malformed"));
8477            }
8478            return Err(LinkError::BulkPreviewRequired { preview });
8479        }
8480        if preview_code != Some("bulk_preview_not_required") {
8481            return Err(invalid_feed("bulk preview requirement is inconsistent"));
8482        }
8483        // The rolling activity window can expire between the refusal and
8484        // preview. Retry once, still pinned strictly to the observed head.
8485        body.as_object_mut()
8486            .expect("v2 commit request is an object")
8487            .remove("preview_only");
8488        response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8489    }
8490    let mut result = ensure_ok(response, "v2 sync push")?;
8491    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8492        if let Some(object) = result.as_object_mut() {
8493            object.insert(
8494                "sync_status".to_string(),
8495                Value::String("proposal_pending".to_string()),
8496            );
8497        }
8498        return Ok(result);
8499    }
8500    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8501        let request_id = result
8502            .get("request_id")
8503            .and_then(Value::as_str)
8504            .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8505            .to_string();
8506        let challenge = result
8507            .get("signing_challenge")
8508            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8509        let mut expected_candidate = remote.clone();
8510        let mut expected_candidate_assets = remote_assets.clone();
8511        apply_generated_v2_operations(
8512            &operations,
8513            &local_assets,
8514            &mut expected_candidate,
8515            &mut expected_candidate_assets,
8516        )?;
8517        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8518            cfg,
8519            &head,
8520            &expected_candidate,
8521            &expected_candidate_assets,
8522            &mutation_id,
8523            &v2_signed_request_view(&body, &operations),
8524            challenge,
8525        )?;
8526        body["signing_challenge_id"] = Value::String(challenge_id);
8527        body["signature_base64url"] = Value::String(signature);
8528        candidate_hub_signer = Some(actor_signer);
8529        result = ensure_ok(
8530            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8531            "v2 self-custody commit",
8532        )?;
8533    }
8534    let refreshed = v2_verified_head(cfg, requested_brain)?
8535        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8536    if candidate_hub_signer
8537        .as_ref()
8538        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8539    {
8540        return Err(invalid_feed(
8541            "self-custody actor signer differs from the committed hub pointer signer",
8542        ));
8543    }
8544    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8545    if refreshed
8546        .pointer
8547        .as_ref()
8548        .map(|pointer| pointer.commit_hash.as_str())
8549        != accepted_hash
8550    {
8551        return Err(LinkError::RemoteAdvancedDuringSync);
8552    }
8553    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8554    let rebased = result
8555        .get("rebased")
8556        .and_then(Value::as_bool)
8557        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8558    let (refreshed_files, refreshed_assets) = if rebased {
8559        (
8560            files_for_v2_view(
8561                &refreshed,
8562                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8563            ),
8564            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8565        )
8566    } else {
8567        let asset_changed = apply_generated_v2_operations(
8568            &operations,
8569            &local_assets,
8570            &mut remote,
8571            &mut remote_assets,
8572        )?;
8573        let assets = if asset_changed {
8574            // Asset leaf hashes are server-generated signed coordinates; only
8575            // asset-changing commits need this usually tiny post-commit read.
8576            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8577        } else {
8578            remote_assets
8579        };
8580        (remote, assets)
8581    };
8582    let mut final_local = v2_local_files(store)?;
8583    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8584    let final_assets = v2_local_asset_records(store)?;
8585    let local_dirty = final_local.riding != local_view.riding
8586        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8587            final_local.policy.keeps_home(path)
8588        })
8589        || final_assets != local_assets
8590        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8591    let next = v2_baseline_from_head(
8592        cfg,
8593        &refreshed,
8594        refreshed_files,
8595        refreshed_assets,
8596        Some(&final_local),
8597        Some(&checkout_pseudonym),
8598    )?;
8599    let split_count = next.remote_copy_remains.len();
8600    accept_v2_head(cfg, &refreshed)?;
8601    if !local_dirty {
8602        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8603        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8604    }
8605    if let Some(object) = result.as_object_mut() {
8606        object.insert(
8607            "local_policy".to_string(),
8608            json!({ "remote_copy_remains": split_count }),
8609        );
8610        object.insert(
8611            "sync_status".to_string(),
8612            Value::String(if local_dirty {
8613                "remote_committed_local_dirty".to_string()
8614            } else {
8615                "synced".to_string()
8616            }),
8617        );
8618    }
8619    Ok(result)
8620}
8621
8622/// Negotiate v2 and send only local changes. A v1 hub retains the existing
8623/// whole-snapshot behavior until its brain advertises the new profile.
8624pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8625    sync_push_incremental_with_policy(cfg, brain, store, false)
8626}
8627
8628/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
8629/// path that became eligible because local policy was weakened or removed.
8630pub fn sync_push_incremental_with_policy(
8631    cfg: &HubConfig,
8632    brain: &str,
8633    store: &Store,
8634    resume_local_policy: bool,
8635) -> LinkResult<Value> {
8636    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8637}
8638
8639/// Incremental push with both local-policy adoption and an optional exact
8640/// permissioned bulk-preview confirmation.
8641pub fn sync_push_incremental_with_options(
8642    cfg: &HubConfig,
8643    brain: &str,
8644    store: &Store,
8645    resume_local_policy: bool,
8646    bulk_confirmation: Option<&V2BulkConfirmation>,
8647) -> LinkResult<Value> {
8648    sync_push_incremental_with_controls(
8649        cfg,
8650        brain,
8651        store,
8652        resume_local_policy,
8653        bulk_confirmation,
8654        &[],
8655        None,
8656    )
8657}
8658
8659/// Incremental push with explicit permissioned current-host withdrawal.
8660pub fn sync_push_incremental_with_controls(
8661    cfg: &HubConfig,
8662    brain: &str,
8663    store: &Store,
8664    resume_local_policy: bool,
8665    bulk_confirmation: Option<&V2BulkConfirmation>,
8666    withdrawal_paths: &[String],
8667    withdrawal_reason: Option<&str>,
8668) -> LinkResult<Value> {
8669    require_safe_ref(brain)?;
8670    if let Some(head) = v2_verified_head(cfg, brain)? {
8671        return v2_sync_push(
8672            cfg,
8673            brain,
8674            store,
8675            head,
8676            V2SyncPushOptions {
8677                resume_local_policy,
8678                bulk_confirmation,
8679                resolution: None,
8680                pulled: None,
8681                withdrawal_paths,
8682                withdrawal_reason,
8683            },
8684        );
8685    }
8686    if !withdrawal_paths.is_empty() {
8687        return Err(LinkError::InvalidPack {
8688            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8689        });
8690    }
8691    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8692}
8693
8694/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
8695/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
8696/// a damaged `DB.md` makes strict store-open fail.
8697pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8698    require_safe_ref(brain)?;
8699    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8700}
8701
8702#[cfg(windows)]
8703fn legacy_sync_push_incremental(
8704    _cfg: &HubConfig,
8705    _brain: &str,
8706    _store: &Store,
8707    _resume_local_policy: bool,
8708    _bulk_confirmation: Option<&V2BulkConfirmation>,
8709) -> LinkResult<Value> {
8710    Err(LinkError::UnsupportedPlatform {
8711        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8712    })
8713}
8714
8715#[cfg(not(windows))]
8716fn legacy_sync_push_incremental(
8717    cfg: &HubConfig,
8718    brain: &str,
8719    store: &Store,
8720    resume_local_policy: bool,
8721    bulk_confirmation: Option<&V2BulkConfirmation>,
8722) -> LinkResult<Value> {
8723    if resume_local_policy || bulk_confirmation.is_some() {
8724        return Err(LinkError::InvalidPack {
8725            message: "v2 sync options require a link.md v2 brain".to_string(),
8726        });
8727    }
8728    let files = collect_push_files(store)?;
8729    sync_push(cfg, brain, &files)
8730}
8731
8732/// One explicit resolution for a previously preserved private conflict bundle.
8733#[derive(Debug, Clone)]
8734pub enum V2ConflictChoice {
8735    KeepLocal,
8736    TakeRemote,
8737    From(PathBuf),
8738}
8739
8740fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8741    if !crate::ulid::is_ulid(bundle) {
8742        return Err(LinkError::InvalidPack {
8743            message: "conflict bundle must be a lowercase ULID".to_string(),
8744        });
8745    }
8746    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8747    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8748        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8749    if plan.v != 2
8750        || plan.class != "content_resolution_required"
8751        || plan.bundle != bundle
8752        || !crate::ulid::is_ulid(&plan.brain)
8753        || plan.files.is_empty()
8754        || plan.files.len() > 100
8755        || plan.files.iter().any(|file| {
8756            crate::linkmd_v2::normalize_path(&file.path).is_err()
8757                || [&file.base, &file.local, &file.remote]
8758                    .into_iter()
8759                    .any(|coordinate| {
8760                        coordinate
8761                            .sha256
8762                            .as_deref()
8763                            .is_some_and(|hash| !is_sha256(hash))
8764                            || coordinate.file.as_deref().is_some_and(|name| {
8765                                name.starts_with('/')
8766                                    || name
8767                                        .split('/')
8768                                        .any(|part| part.is_empty() || part == "." || part == "..")
8769                            })
8770                    })
8771        })
8772    {
8773        return Err(invalid_feed("private conflict plan failed validation"));
8774    }
8775    Ok(plan)
8776}
8777
8778/// Inspect or prune private conflict-control state. Ordinary pruning removes
8779/// only expired completed bundles and interrupted bundles with no `plan.json`;
8780/// corrupt completed plans fail closed. `all` is an explicit local discard of
8781/// every well-addressed bundle and never changes hosted brain data.
8782pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8783    require_hardened_filesystem("private conflict maintenance")?;
8784    if all && !prune {
8785        return Err(LinkError::InvalidPack {
8786            message: "discarding all conflict bundles requires prune=true".to_string(),
8787        });
8788    }
8789    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8790        message: format!("conflict checkout is not a valid db.md store: {error}"),
8791    })?;
8792    let _transaction = store.transaction()?;
8793    let root = Path::new(".dbmd/conflicts");
8794    let names = match store.directory_names(root) {
8795        Ok(names) => names,
8796        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8797        Err(error) => return Err(error.into()),
8798    };
8799    let now = SystemTime::now()
8800        .duration_since(UNIX_EPOCH)
8801        .unwrap_or_default()
8802        .as_secs();
8803    let mut bundles = Vec::new();
8804    let mut pruned = 0_u64;
8805    for name in names {
8806        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8807            continue;
8808        };
8809        let plan_path = v2_conflict_relative(bundle, "plan.json");
8810        let plan_exists = store.regular_file_exists(&plan_path)?;
8811        let expired = if plan_exists {
8812            match load_v2_conflict_plan(&store, bundle) {
8813                Ok(plan) => plan.expires_unix < now,
8814                Err(error) if all => {
8815                    let _ = error;
8816                    true
8817                }
8818                Err(error) => return Err(error),
8819            }
8820        } else {
8821            true
8822        };
8823        if prune && (all || expired) {
8824            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8825            pruned += 1;
8826            continue;
8827        }
8828        bundles.push(json!({
8829            "bundle": bundle,
8830            "complete": plan_exists,
8831            "expired": expired,
8832        }));
8833    }
8834    Ok(json!({
8835        "v": 2,
8836        "class": "private_conflict_state",
8837        "bundles": bundles.len(),
8838        "pruned": pruned,
8839        "items": bundles,
8840    }))
8841}
8842
8843/// Resolve one exact private conflict plan. Drift never lowers a precondition:
8844/// the remote head/view and every original local coordinate are rechecked
8845/// before either an explicit local install or a fresh normal commit.
8846pub fn sync_resolve_conflict(
8847    cfg: &HubConfig,
8848    checkout: &Path,
8849    bundle: &str,
8850    choice: V2ConflictChoice,
8851    bulk_confirmation: Option<&V2BulkConfirmation>,
8852) -> LinkResult<Value> {
8853    require_hardened_filesystem("conflict resolution")?;
8854    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8855        message: format!("conflict checkout is not a valid db.md store: {error}"),
8856    })?;
8857    let plan = load_v2_conflict_plan(&store, bundle)?;
8858    if plan.origin != normalized_origin(&cfg.hub)? {
8859        return Err(invalid_feed(
8860            "conflict bundle belongs to another hub origin",
8861        ));
8862    }
8863    let now = SystemTime::now()
8864        .duration_since(UNIX_EPOCH)
8865        .unwrap_or_default()
8866        .as_secs();
8867    if now > plan.expires_unix {
8868        return Err(LinkError::InvalidPack {
8869            message: "conflict bundle expired; rerun sync to obtain current coordinates"
8870                .to_string(),
8871        });
8872    }
8873    let head = v2_verified_head(cfg, &plan.brain)?
8874        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8875    let pointer = head.pointer.as_ref();
8876    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8877        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8878        || pointer.and_then(|value| value.content_root.as_deref())
8879            != plan.remote_content_root.as_deref()
8880        || head.view_kind != plan.view_kind
8881        || head.view_revision != plan.view_revision
8882    {
8883        return Err(LinkError::RemoteAdvancedDuringSync);
8884    }
8885
8886    // A resolution never overwrites a post-bundle local edit.
8887    for file in &plan.files {
8888        let actual = match store.regular_file_exists(Path::new(&file.path))? {
8889            true => Some(content_sha256(&store.read_bounded(
8890                Path::new(&file.path),
8891                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8892            )?)),
8893            false => None,
8894        };
8895        if actual.as_deref() != file.local.sha256.as_deref() {
8896            return Err(LinkError::InvalidPack {
8897                message: format!(
8898                    "local conflict path `{}` changed after the bundle was created",
8899                    file.path
8900                ),
8901            });
8902        }
8903    }
8904
8905    let from_source = match &choice {
8906        V2ConflictChoice::From(source) => Some(source.clone()),
8907        _ => None,
8908    };
8909    let result = match choice {
8910        V2ConflictChoice::TakeRemote => {
8911            if bulk_confirmation.is_some() {
8912                return Err(LinkError::InvalidPack {
8913                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8914                });
8915            }
8916            // Re-read the exact current manifest so every selected remote byte
8917            // retains its signed inclusion proof. The private bundle is useful
8918            // for inspection, but is never promoted into live state as trust.
8919            let current_remote =
8920                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8921            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
8922            let selected = plan
8923                .files
8924                .iter()
8925                .map(|file| file.path.clone())
8926                .collect::<std::collections::BTreeSet<_>>();
8927            serde_json::to_value(
8928                v2_sync_pull_with_resolution(
8929                    cfg,
8930                    &plan.brain,
8931                    head,
8932                    Some(checkout),
8933                    Some(&selected),
8934                )?
8935                .report,
8936            )
8937            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8938        }
8939        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8940            if let Some(source) = from_source.as_ref() {
8941                if plan.files.len() != 1 {
8942                    return Err(LinkError::InvalidPack {
8943                        message: "--from requires a bundle with exactly one conflict".to_string(),
8944                    });
8945                }
8946                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8947                if std::str::from_utf8(&candidate).is_err() {
8948                    return Err(LinkError::NotUtf8 {
8949                        path: source.display().to_string(),
8950                    });
8951                }
8952                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8953            }
8954            let refreshed_store =
8955                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8956                    message: format!("resolved checkout is not a valid db.md store: {error}"),
8957                })?;
8958            let mut overrides = std::collections::BTreeMap::new();
8959            for file in &plan.files {
8960                let selected_local = match refreshed_store
8961                    .regular_file_exists(Path::new(&file.path))?
8962                {
8963                    true => Some(content_sha256(
8964                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8965                    )),
8966                    false => None,
8967                };
8968                overrides.insert(
8969                    file.path.clone(),
8970                    V2ResolutionOverride {
8971                        expected_remote: file.remote.sha256.clone(),
8972                        selected_local,
8973                    },
8974                );
8975            }
8976            v2_sync_push(
8977                cfg,
8978                &plan.brain,
8979                &refreshed_store,
8980                head,
8981                V2SyncPushOptions {
8982                    resume_local_policy: true,
8983                    bulk_confirmation,
8984                    resolution: Some(&overrides),
8985                    pulled: None,
8986                    withdrawal_paths: &[],
8987                    withdrawal_reason: None,
8988                },
8989            )?
8990        }
8991    };
8992
8993    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
8994        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8995            message: format!("resolved checkout is not a valid db.md store: {error}"),
8996        })?;
8997        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8998    }
8999    Ok(json!({
9000        "v": 2,
9001        "class": "auto_converged",
9002        "bundle": bundle,
9003        "receipt": result,
9004    }))
9005}
9006
9007/// Converge one established permissioned-v2 checkout in both directions.
9008///
9009/// The pull half first performs the ordinary three-way conflict check and
9010/// crash-safe install against the accepted baseline. The push half then opens
9011/// that exact installed checkout, takes its local transaction guard, computes
9012/// a fresh three-way delta, and submits it with mandatory preconditions. A
9013/// remote movement between halves is therefore a normal rebase/conflict input,
9014/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
9015/// its whole-pack replacement semantics are never presented as granular
9016/// convergence.
9017pub fn sync_converge(
9018    cfg: &HubConfig,
9019    brain: &str,
9020    checkout: &Path,
9021    resume_local_policy: bool,
9022) -> LinkResult<Value> {
9023    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9024}
9025
9026/// Bidirectional convergence with optional exact bulk-preview confirmation.
9027pub fn sync_converge_with_options(
9028    cfg: &HubConfig,
9029    brain: &str,
9030    checkout: &Path,
9031    resume_local_policy: bool,
9032    bulk_confirmation: Option<&V2BulkConfirmation>,
9033) -> LinkResult<Value> {
9034    sync_converge_with_controls(
9035        cfg,
9036        brain,
9037        checkout,
9038        resume_local_policy,
9039        bulk_confirmation,
9040        &[],
9041        None,
9042    )
9043}
9044
9045/// Bidirectional convergence with optional exact withdrawal intents.
9046pub fn sync_converge_with_controls(
9047    cfg: &HubConfig,
9048    brain: &str,
9049    checkout: &Path,
9050    resume_local_policy: bool,
9051    bulk_confirmation: Option<&V2BulkConfirmation>,
9052    withdrawal_paths: &[String],
9053    withdrawal_reason: Option<&str>,
9054) -> LinkResult<Value> {
9055    require_hardened_filesystem("bidirectional sync")?;
9056    require_safe_ref(brain)?;
9057    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9058        message:
9059            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9060                .to_string(),
9061    })?;
9062    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9063    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9064        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9065    })?;
9066    let _transaction = store.transaction()?;
9067    let pulled_report = pulled.report.clone();
9068    let pulled_head = pulled.head.clone();
9069    let mut result = v2_sync_push(
9070        cfg,
9071        brain,
9072        &store,
9073        pulled_head,
9074        V2SyncPushOptions {
9075            resume_local_policy,
9076            bulk_confirmation,
9077            resolution: None,
9078            pulled: Some(pulled),
9079            withdrawal_paths,
9080            withdrawal_reason,
9081        },
9082    )?;
9083    if let Some(object) = result.as_object_mut() {
9084        object.insert("pulled_files".to_string(), json!(pulled_report.files));
9085        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9086        object.insert(
9087            "mode".to_string(),
9088            Value::String("bidirectional".to_string()),
9089        );
9090    }
9091    Ok(result)
9092}
9093
9094/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
9095/// exported path is safety-gated before it touches disk; files are written
9096/// atomically; nothing local is ever deleted (locals the export lacks are
9097/// *reported* in `extra_local` instead). Returns the report; rebuilding the
9098/// local index catalog afterwards is the caller's (cheap, optional) step.
9099pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9100    require_hardened_filesystem("sync pull")?;
9101    require_safe_ref(brain)?;
9102    if let Some(head) = v2_verified_head(cfg, brain)? {
9103        return v2_sync_pull(cfg, brain, head, out);
9104    }
9105    legacy_sync_pull(cfg, brain, out)
9106}
9107
9108#[cfg(windows)]
9109fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9110    Err(LinkError::UnsupportedPlatform {
9111        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9112    })
9113}
9114
9115#[cfg(not(windows))]
9116fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9117    let remote = verified_remote_head(cfg, brain, false)?;
9118    if !remote.head.verified {
9119        return Err(invalid_feed(
9120            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9121        ));
9122    }
9123    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9124    let path = format!(
9125        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9126        remote.head.seq
9127    );
9128    let body = ensure_ok(
9129        request(cfg, "GET", &path, None, Auth::Required)?,
9130        "sync pull",
9131    )?;
9132    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9133        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9134    {
9135        return Err(invalid_feed(
9136            "export response is not bound to the verified snapshot token",
9137        ));
9138    }
9139
9140    let remote_slug = body
9141        .get("slug")
9142        .and_then(Value::as_str)
9143        .filter(|slug| is_safe_slug(slug));
9144    let slug = remote_slug
9145        .or_else(|| is_safe_slug(brain).then_some(brain))
9146        .unwrap_or("brain")
9147        .to_string();
9148    let brain_id = body
9149        .get("brain")
9150        .and_then(Value::as_str)
9151        .unwrap_or(&remote.head.brain)
9152        .to_string();
9153    if brain_id != remote.head.brain {
9154        return Err(invalid_feed(
9155            "export response names a different brain than the verified head",
9156        ));
9157    }
9158    let head_seq = remote.head.seq;
9159    let dest: PathBuf = match out {
9160        Some(p) => p.to_path_buf(),
9161        None => PathBuf::from(&slug),
9162    };
9163    let entries = if head_seq == 0 {
9164        let files = body
9165            .get("files")
9166            .and_then(Value::as_array)
9167            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
9168        if !files.is_empty() || body.get("url").is_some() {
9169            return Err(invalid_feed(
9170                "empty signed feed cannot authorize non-empty exported content",
9171            ));
9172        }
9173        Vec::new()
9174    } else {
9175        let signed_head = remote
9176            .head_entry
9177            .as_ref()
9178            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
9179        let expected = &signed_head.entry.pack_sha256;
9180        if !is_sha256(expected) {
9181            return Err(invalid_feed(
9182                "signed head carries an invalid snapshot pack digest",
9183            ));
9184        }
9185        if let Some(url) = body.get("url").and_then(Value::as_str) {
9186            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
9187                return Err(invalid_feed(
9188                    "export pack digest does not match the signed head entry",
9189                ));
9190            }
9191            let bytes = get_presigned(cfg, url)?;
9192            let actual = format!("{:x}", Sha256::digest(&bytes));
9193            if actual != *expected {
9194                return Err(LinkError::InvalidPack {
9195                    message: "downloaded pack does not match the signed snapshot digest"
9196                        .to_string(),
9197                });
9198            }
9199            let entries = parse_store_pack(bytes)?;
9200            if signed_head.entry.kind == "push" {
9201                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9202            }
9203            entries
9204        } else {
9205            if signed_head.entry.kind != "push" {
9206                return Err(invalid_feed(
9207                    "delta snapshots must export the exact signed pack",
9208                ));
9209            }
9210            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
9211                invalid_feed("verified snapshot export carried neither a pack nor files")
9212            })?;
9213            let mut entries = Vec::with_capacity(files.len());
9214            for file in files {
9215                let path = file
9216                    .get("path")
9217                    .and_then(Value::as_str)
9218                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
9219                let content = file
9220                    .get("content")
9221                    .and_then(Value::as_str)
9222                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
9223                entries.push((path.to_string(), content.as_bytes().to_vec()));
9224            }
9225            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9226            entries
9227        }
9228    };
9229
9230    // Gate the complete manifest before the first filesystem mutation.
9231    let mut seen = std::collections::HashSet::new();
9232    for (path, _) in &entries {
9233        if !safe_store_rel_path(path) {
9234            return Err(LinkError::UnsafePath { path: path.clone() });
9235        }
9236        if !seen.insert(path) {
9237            return Err(LinkError::InvalidPack {
9238                message: format!("duplicate path `{path}`"),
9239            });
9240        }
9241    }
9242    // Compute divergence against the still-live tree. The staged clone keeps
9243    // these extra locals byte-for-byte while overlaying the signed snapshot.
9244    let pulled: std::collections::BTreeSet<&str> =
9245        entries.iter().map(|(p, _)| p.as_str()).collect();
9246    let mut extra_local = Vec::new();
9247    if let Ok(store) = Store::open(&dest) {
9248        if let Ok(walked) = store.walk() {
9249            for rel in walked {
9250                let rel_str = rel.to_string_lossy().replace('\\', "/");
9251                if !pulled.contains(rel_str.as_str()) {
9252                    extra_local.push(rel_str);
9253                }
9254            }
9255        }
9256    }
9257    #[cfg(unix)]
9258    install_pulled_snapshot(&dest, &entries)?;
9259
9260    Ok(PullReport {
9261        brain: brain_id,
9262        slug,
9263        head_seq,
9264        files: entries.len(),
9265        dest: dest.to_string_lossy().into_owned(),
9266        extra_local,
9267        sync_status: "synced".to_string(),
9268    })
9269}
9270
9271#[cfg(unix)]
9272fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
9273    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
9274        path: display.to_string(),
9275    })
9276}
9277
9278#[cfg(unix)]
9279fn open_dir_at(
9280    parent: std::os::fd::RawFd,
9281    name: &std::ffi::CStr,
9282    display: &str,
9283) -> LinkResult<std::fs::File> {
9284    use std::os::fd::FromRawFd as _;
9285    let fd = unsafe {
9286        libc::openat(
9287            parent,
9288            name.as_ptr(),
9289            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9290        )
9291    };
9292    if fd < 0 {
9293        return Err(LinkError::UnsafePath {
9294            path: display.to_string(),
9295        });
9296    }
9297    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9298}
9299
9300/// Open (and, where absent, create) a directory path without following a
9301/// symlink in any component. The returned directory capability remains bound
9302/// to the opened inode even if an attacker renames or replaces an ancestor.
9303#[cfg(unix)]
9304fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9305    use std::os::fd::AsRawFd as _;
9306
9307    // macOS exposes a few system-owned compatibility symlinks at the root.
9308    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
9309    // caller-controlled ancestor.
9310    #[cfg(target_os = "macos")]
9311    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9312        .into_iter()
9313        .find_map(|(alias, real)| {
9314            path.strip_prefix(alias)
9315                .ok()
9316                .map(|rest| Path::new(real).join(rest))
9317        })
9318        .unwrap_or_else(|| path.to_path_buf());
9319    #[cfg(not(target_os = "macos"))]
9320    let normalized = path.to_path_buf();
9321
9322    let start = if normalized.is_absolute() {
9323        std::fs::File::open("/")?
9324    } else {
9325        std::fs::File::open(".")?
9326    };
9327    let mut directory = start;
9328    for component in normalized.components() {
9329        use std::path::Component;
9330        let name = match component {
9331            Component::RootDir | Component::CurDir => continue,
9332            Component::Normal(name) => name,
9333            Component::ParentDir | Component::Prefix(_) => {
9334                return Err(LinkError::UnsafePath {
9335                    path: path.display().to_string(),
9336                });
9337            }
9338        };
9339        use std::os::unix::ffi::OsStrExt as _;
9340        let name = c_name(name.as_bytes(), &path.display().to_string())?;
9341        if create {
9342            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9343            if made != 0 {
9344                let error = std::io::Error::last_os_error();
9345                if error.raw_os_error() != Some(libc::EEXIST) {
9346                    return Err(error.into());
9347                }
9348            }
9349        }
9350        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9351    }
9352    Ok(directory)
9353}
9354
9355#[cfg(unix)]
9356fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9357    open_dir_path_nofollow(path, true)
9358}
9359
9360#[cfg(unix)]
9361fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9362    open_dir_path_nofollow(path, false)
9363}
9364
9365#[cfg(unix)]
9366fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9367    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9368    let result =
9369        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9370    if result == 0 {
9371        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9372    }
9373    let error = std::io::Error::last_os_error();
9374    if error.kind() == std::io::ErrorKind::NotFound {
9375        Ok(None)
9376    } else {
9377        Err(error.into())
9378    }
9379}
9380
9381#[cfg(unix)]
9382fn create_dir_exclusive_at(
9383    parent: std::os::fd::RawFd,
9384    name: &std::ffi::CStr,
9385    display: &str,
9386) -> LinkResult<std::fs::File> {
9387    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9388    if made != 0 {
9389        return Err(LinkError::UnsafePath {
9390            path: display.to_string(),
9391        });
9392    }
9393    open_dir_at(parent, name, display)
9394}
9395
9396#[cfg(unix)]
9397fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9398    use std::os::fd::AsRawFd as _;
9399
9400    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9401    if duplicate < 0 {
9402        return Err(std::io::Error::last_os_error().into());
9403    }
9404    let stream = unsafe { libc::fdopendir(duplicate) };
9405    if stream.is_null() {
9406        let error = std::io::Error::last_os_error();
9407        unsafe {
9408            libc::close(duplicate);
9409        }
9410        return Err(error.into());
9411    }
9412    let mut names = Vec::new();
9413    loop {
9414        let entry = unsafe { libc::readdir(stream) };
9415        if entry.is_null() {
9416            break;
9417        }
9418        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9419        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9420            names.push(raw.to_owned());
9421        }
9422    }
9423    if unsafe { libc::closedir(stream) } != 0 {
9424        return Err(std::io::Error::last_os_error().into());
9425    }
9426    Ok(names)
9427}
9428
9429/// Remove an entry tree relative to a held directory capability. Symlinks are
9430/// unlinked, never traversed, even when an old mirror contains hostile names.
9431#[cfg(unix)]
9432fn remove_tree_at(
9433    parent: std::os::fd::RawFd,
9434    name: &std::ffi::CStr,
9435    display: &str,
9436) -> LinkResult<()> {
9437    use std::os::fd::AsRawFd as _;
9438
9439    match entry_is_dir_at(parent, name)? {
9440        None => return Ok(()),
9441        Some(false) => {
9442            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9443                return Err(std::io::Error::last_os_error().into());
9444            }
9445        }
9446        Some(true) => {
9447            let directory = open_dir_at(parent, name, display)?;
9448            for child in directory_entry_names(&directory)? {
9449                let child_display =
9450                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9451                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9452            }
9453            drop(directory);
9454            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9455                return Err(std::io::Error::last_os_error().into());
9456            }
9457        }
9458    }
9459    Ok(())
9460}
9461
9462/// Clone a live destination into a private sibling stage without following
9463/// any symlink. Regular files are streamed between held descriptors; symlinks
9464/// are reproduced as links, never opened. Special files are refused.
9465#[cfg(unix)]
9466fn clone_tree_contents(
9467    source: &std::fs::File,
9468    destination: &std::fs::File,
9469    display: &str,
9470) -> LinkResult<()> {
9471    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9472
9473    for name in directory_entry_names(source)? {
9474        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9475        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9476        if unsafe {
9477            libc::fstatat(
9478                source.as_raw_fd(),
9479                name.as_ptr(),
9480                &mut stat,
9481                libc::AT_SYMLINK_NOFOLLOW,
9482            )
9483        } != 0
9484        {
9485            return Err(std::io::Error::last_os_error().into());
9486        }
9487        match stat.st_mode & libc::S_IFMT {
9488            libc::S_IFDIR => {
9489                if unsafe {
9490                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9491                } != 0
9492                {
9493                    return Err(std::io::Error::last_os_error().into());
9494                }
9495                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9496                let destination_child =
9497                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9498                clone_tree_contents(&source_child, &destination_child, &child_display)?;
9499                destination_child.sync_all()?;
9500            }
9501            libc::S_IFREG => {
9502                let source_fd = unsafe {
9503                    libc::openat(
9504                        source.as_raw_fd(),
9505                        name.as_ptr(),
9506                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9507                    )
9508                };
9509                if source_fd < 0 {
9510                    return Err(std::io::Error::last_os_error().into());
9511                }
9512                let destination_fd = unsafe {
9513                    libc::openat(
9514                        destination.as_raw_fd(),
9515                        name.as_ptr(),
9516                        libc::O_WRONLY
9517                            | libc::O_CREAT
9518                            | libc::O_EXCL
9519                            | libc::O_CLOEXEC
9520                            | libc::O_NOFOLLOW,
9521                        (stat.st_mode & 0o777) as libc::c_uint,
9522                    )
9523                };
9524                if destination_fd < 0 {
9525                    unsafe {
9526                        libc::close(source_fd);
9527                    }
9528                    return Err(std::io::Error::last_os_error().into());
9529                }
9530                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9531                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9532                std::io::copy(&mut input, &mut output)?;
9533                output.sync_all()?;
9534            }
9535            libc::S_IFLNK => {
9536                let mut target = vec![0_u8; 4097];
9537                let length = unsafe {
9538                    libc::readlinkat(
9539                        source.as_raw_fd(),
9540                        name.as_ptr(),
9541                        target.as_mut_ptr().cast(),
9542                        target.len(),
9543                    )
9544                };
9545                if length < 0 || length as usize >= target.len() {
9546                    return Err(LinkError::UnsafePath {
9547                        path: child_display,
9548                    });
9549                }
9550                target.truncate(length as usize);
9551                let target = c_name(&target, &child_display)?;
9552                if unsafe {
9553                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9554                } != 0
9555                {
9556                    return Err(std::io::Error::last_os_error().into());
9557                }
9558            }
9559            _ => {
9560                return Err(LinkError::UnsafePath {
9561                    path: child_display,
9562                });
9563            }
9564        }
9565    }
9566    destination.sync_all()?;
9567    Ok(())
9568}
9569
9570#[cfg(target_os = "linux")]
9571fn install_stage_at(
9572    parent: std::os::fd::RawFd,
9573    stage: &std::ffi::CStr,
9574    dest: &std::ffi::CStr,
9575    dest_exists: bool,
9576) -> LinkResult<()> {
9577    let flags = if dest_exists {
9578        libc::RENAME_EXCHANGE
9579    } else {
9580        libc::RENAME_NOREPLACE
9581    };
9582    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
9583    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
9584    // atomic exchange/no-replace boundary compiles for glibc and musl.
9585    let result = unsafe {
9586        libc::syscall(
9587            libc::SYS_renameat2,
9588            parent,
9589            stage.as_ptr(),
9590            parent,
9591            dest.as_ptr(),
9592            flags,
9593        )
9594    };
9595    if result == 0 {
9596        Ok(())
9597    } else {
9598        Err(std::io::Error::last_os_error().into())
9599    }
9600}
9601
9602#[cfg(target_os = "macos")]
9603fn install_stage_at(
9604    parent: std::os::fd::RawFd,
9605    stage: &std::ffi::CStr,
9606    dest: &std::ffi::CStr,
9607    dest_exists: bool,
9608) -> LinkResult<()> {
9609    let flags = if dest_exists {
9610        libc::RENAME_SWAP
9611    } else {
9612        libc::RENAME_EXCL
9613    };
9614    let result =
9615        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9616    if result == 0 {
9617        Ok(())
9618    } else {
9619        Err(std::io::Error::last_os_error().into())
9620    }
9621}
9622
9623#[cfg(unix)]
9624fn write_pull_entries_beneath_dir(
9625    root: &std::fs::File,
9626    entries: &[(String, Vec<u8>)],
9627) -> LinkResult<()> {
9628    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9629
9630    for (path, content) in entries {
9631        let components: Vec<&str> = path.split('/').collect();
9632        let (leaf, parents) = components
9633            .split_last()
9634            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9635        let mut directory = root.try_clone()?;
9636        for component in parents {
9637            let name = c_name(component.as_bytes(), path)?;
9638            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9639            if made != 0 {
9640                let error = std::io::Error::last_os_error();
9641                if error.raw_os_error() != Some(libc::EEXIST) {
9642                    return Err(error.into());
9643                }
9644            }
9645            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9646        }
9647
9648        let leaf_name = c_name(leaf.as_bytes(), path)?;
9649        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9650        let inspected = unsafe {
9651            libc::fstatat(
9652                directory.as_raw_fd(),
9653                leaf_name.as_ptr(),
9654                &mut existing,
9655                libc::AT_SYMLINK_NOFOLLOW,
9656            )
9657        };
9658        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9659            return Err(LinkError::UnsafePath { path: path.clone() });
9660        }
9661
9662        let nonce = std::time::SystemTime::now()
9663            .duration_since(std::time::UNIX_EPOCH)
9664            .unwrap_or_default()
9665            .as_nanos();
9666        let temp_name = format!(
9667            ".dbmd-pull-{}-{nonce}-{}",
9668            std::process::id(),
9669            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9670        );
9671        let temp = c_name(temp_name.as_bytes(), path)?;
9672        let fd = unsafe {
9673            libc::openat(
9674                directory.as_raw_fd(),
9675                temp.as_ptr(),
9676                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9677                0o600,
9678            )
9679        };
9680        if fd < 0 {
9681            return Err(std::io::Error::last_os_error().into());
9682        }
9683        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9684        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9685            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9686            return Err(error.into());
9687        }
9688        drop(file);
9689        let renamed = unsafe {
9690            libc::renameat(
9691                directory.as_raw_fd(),
9692                temp.as_ptr(),
9693                directory.as_raw_fd(),
9694                leaf_name.as_ptr(),
9695            )
9696        };
9697        if renamed != 0 {
9698            let error = std::io::Error::last_os_error();
9699            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9700            return Err(error.into());
9701        }
9702        directory.sync_all()?;
9703    }
9704    root.sync_all()?;
9705    Ok(())
9706}
9707
9708#[cfg(unix)]
9709fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9710    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9711
9712    let path = &entry.path;
9713    let components: Vec<&str> = path.split('/').collect();
9714    let (leaf, parents) = components
9715        .split_last()
9716        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9717    let mut directory = root.try_clone()?;
9718    for component in parents {
9719        let name = c_name(component.as_bytes(), path)?;
9720        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9721        if made != 0 {
9722            let error = std::io::Error::last_os_error();
9723            if error.raw_os_error() != Some(libc::EEXIST) {
9724                return Err(error.into());
9725            }
9726        }
9727        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9728    }
9729    let leaf_name = c_name(leaf.as_bytes(), path)?;
9730    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9731    if unsafe {
9732        libc::fstatat(
9733            directory.as_raw_fd(),
9734            leaf_name.as_ptr(),
9735            &mut existing,
9736            libc::AT_SYMLINK_NOFOLLOW,
9737        )
9738    } == 0
9739        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9740    {
9741        return Err(LinkError::UnsafePath { path: path.clone() });
9742    }
9743    let nonce = SystemTime::now()
9744        .duration_since(UNIX_EPOCH)
9745        .unwrap_or_default()
9746        .as_nanos();
9747    let temp_name = format!(
9748        ".dbmd-pull-{}-{nonce}-{}",
9749        std::process::id(),
9750        content_sha256(path.as_bytes())
9751    );
9752    let temp = c_name(temp_name.as_bytes(), path)?;
9753    let fd = unsafe {
9754        libc::openat(
9755            directory.as_raw_fd(),
9756            temp.as_ptr(),
9757            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9758            0o600,
9759        )
9760    };
9761    if fd < 0 {
9762        return Err(std::io::Error::last_os_error().into());
9763    }
9764    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9765    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9766    let mut digest = Sha256::new();
9767    let mut total = 0_u64;
9768    let mut buffer = [0_u8; 64 * 1024];
9769    let copied = (|| -> std::io::Result<()> {
9770        loop {
9771            let read = input.read(&mut buffer)?;
9772            if read == 0 {
9773                break;
9774            }
9775            total = total.saturating_add(read as u64);
9776            if total > entry.bytes {
9777                return Err(std::io::Error::new(
9778                    std::io::ErrorKind::InvalidData,
9779                    "staged sync source grew beyond its verified length",
9780                ));
9781            }
9782            digest.update(&buffer[..read]);
9783            output.write_all(&buffer[..read])?;
9784        }
9785        Ok(())
9786    })();
9787    if let Err(error) = copied {
9788        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9789        return Err(error.into());
9790    }
9791    drop(output);
9792    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9793        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9794        return Err(invalid_feed(
9795            "private staged sync source failed final integrity verification",
9796        ));
9797    }
9798    if unsafe {
9799        libc::renameat(
9800            directory.as_raw_fd(),
9801            temp.as_ptr(),
9802            directory.as_raw_fd(),
9803            leaf_name.as_ptr(),
9804        )
9805    } != 0
9806    {
9807        let error = std::io::Error::last_os_error();
9808        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9809        return Err(error.into());
9810    }
9811    Ok(())
9812}
9813
9814#[cfg(unix)]
9815fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9816    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9817
9818    let path = &entry.path;
9819    let components: Vec<&str> = path.split('/').collect();
9820    let (leaf, parents) = components
9821        .split_last()
9822        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9823    let mut directory = root.try_clone()?;
9824    for component in parents {
9825        directory = open_dir_at(
9826            directory.as_raw_fd(),
9827            &c_name(component.as_bytes(), path)?,
9828            path,
9829        )?;
9830    }
9831    let leaf = c_name(leaf.as_bytes(), path)?;
9832    let fd = unsafe {
9833        libc::openat(
9834            directory.as_raw_fd(),
9835            leaf.as_ptr(),
9836            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9837        )
9838    };
9839    if fd < 0 {
9840        return Err(std::io::Error::last_os_error().into());
9841    }
9842    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9843    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9844        return Err(invalid_feed(
9845            "private pull stage changed before its durability barrier",
9846        ));
9847    }
9848    file.sync_all()?;
9849    Ok(())
9850}
9851
9852#[cfg(unix)]
9853fn run_pull_source_workers(
9854    root: &std::fs::File,
9855    entries: &[V2StagedFile],
9856    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9857) -> LinkResult<()> {
9858    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9859
9860    let next = AtomicUsize::new(0);
9861    let failed = AtomicBool::new(false);
9862    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9863    let mut first_error = None;
9864    std::thread::scope(|scope| {
9865        let (sender, receiver) = std::sync::mpsc::channel();
9866        for _ in 0..worker_count {
9867            let sender = sender.clone();
9868            let next = &next;
9869            let failed = &failed;
9870            scope.spawn(move || {
9871                while !failed.load(Ordering::Acquire) {
9872                    let index = next.fetch_add(1, Ordering::Relaxed);
9873                    let Some(entry) = entries.get(index) else {
9874                        break;
9875                    };
9876                    let result = operation(root, entry);
9877                    if result.is_err() {
9878                        failed.store(true, Ordering::Release);
9879                    }
9880                    if sender.send(result).is_err() {
9881                        break;
9882                    }
9883                }
9884            });
9885        }
9886        drop(sender);
9887        for result in receiver {
9888            if let Err(error) = result {
9889                if first_error.is_none() {
9890                    first_error = Some(error);
9891                }
9892            }
9893        }
9894    });
9895    if let Some(error) = first_error {
9896        return Err(error);
9897    }
9898    if next.load(Ordering::Relaxed) < entries.len() {
9899        return Err(invalid_feed(
9900            "a bounded pull worker stopped before reporting every file",
9901        ));
9902    }
9903    Ok(())
9904}
9905
9906#[cfg(unix)]
9907fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9908    use std::os::fd::AsRawFd as _;
9909
9910    for name in directory_entry_names(root)? {
9911        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9912            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9913            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9914            sync_pull_directory_tree(&child, &child_display)?;
9915        }
9916    }
9917    root.sync_all()?;
9918    Ok(())
9919}
9920
9921#[cfg(unix)]
9922fn write_pull_sources_beneath_dir(
9923    root: &std::fs::File,
9924    entries: &[V2StagedFile],
9925) -> LinkResult<()> {
9926    // The stage is still private and unreachable as the checkout here. Write
9927    // and verify every leaf first so the filesystem can coalesce dirty data,
9928    // then re-open/re-hash/fsync leaves through the held root capability. Only
9929    // after every file barrier succeeds do we flush each directory once. This
9930    // preserves the same crash durability as file-fsync + directory-fsync per
9931    // path without serializing 50,000 redundant directory barriers.
9932    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9933    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9934    sync_pull_directory_tree(root, "v2 pull stage")
9935}
9936
9937#[cfg(unix)]
9938fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9939    use std::os::fd::AsRawFd as _;
9940    for path in paths {
9941        if !safe_store_rel_path(path) {
9942            return Err(LinkError::UnsafePath { path: path.clone() });
9943        }
9944        let components = path.split('/').collect::<Vec<_>>();
9945        let Some((leaf, parents)) = components.split_last() else {
9946            return Err(LinkError::UnsafePath { path: path.clone() });
9947        };
9948        let mut directory = root.try_clone()?;
9949        let mut missing = false;
9950        for component in parents {
9951            let name = c_name(component.as_bytes(), path)?;
9952            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9953                None => {
9954                    missing = true;
9955                    break;
9956                }
9957                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9958                Some(true) => {
9959                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9960                }
9961            }
9962        }
9963        if missing {
9964            continue;
9965        }
9966        let leaf = c_name(leaf.as_bytes(), path)?;
9967        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9968            None => {}
9969            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9970            Some(false) => {
9971                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9972                    return Err(std::io::Error::last_os_error().into());
9973                }
9974                directory.sync_all()?;
9975            }
9976        }
9977    }
9978    Ok(())
9979}
9980
9981#[cfg(unix)]
9982fn install_pulled_delta(
9983    dest: &Path,
9984    entries: &[(String, Vec<u8>)],
9985    deleted: &[String],
9986    rebuild_indexes: bool,
9987) -> LinkResult<()> {
9988    use ring::rand::SecureRandom as _;
9989    use std::os::fd::AsRawFd as _;
9990    use std::os::unix::ffi::OsStrExt as _;
9991
9992    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9993    let name = dest
9994        .file_name()
9995        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9996        .ok_or_else(|| LinkError::UnsafePath {
9997            path: dest.display().to_string(),
9998        })?;
9999    let parent_dir = open_or_create_dir_nofollow(parent)?;
10000    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10001    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10002        None => false,
10003        Some(true) => true,
10004        Some(false) => {
10005            return Err(LinkError::UnsafePath {
10006                path: dest.display().to_string(),
10007            });
10008        }
10009    };
10010
10011    let mut nonce = [0_u8; 16];
10012    ring::rand::SystemRandom::new()
10013        .fill(&mut nonce)
10014        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10015    let stage_label = format!(
10016        ".{}.dbmd-pull-stage-{}",
10017        name.to_string_lossy(),
10018        URL_SAFE_NO_PAD.encode(nonce)
10019    );
10020    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10021    let stage_dir = create_dir_exclusive_at(
10022        parent_dir.as_raw_fd(),
10023        &stage_name,
10024        &dest.display().to_string(),
10025    )?;
10026
10027    let prepared = (|| -> LinkResult<()> {
10028        if dest_exists {
10029            let live = open_dir_at(
10030                parent_dir.as_raw_fd(),
10031                &dest_name,
10032                &dest.display().to_string(),
10033            )?;
10034            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10035        }
10036        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10037        write_pull_entries_beneath_dir(&stage_dir, entries)?;
10038        if rebuild_indexes {
10039            let stage_store =
10040                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10041                    .map_err(|error| LinkError::InvalidPack {
10042                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10043                    })?;
10044            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10045                LinkError::InvalidPack {
10046                    message: format!("could not materialize v2 local catalogs: {error}"),
10047                }
10048            })?;
10049        }
10050        stage_dir.sync_all()?;
10051        Ok(())
10052    })();
10053    if let Err(error) = prepared {
10054        let _ = remove_tree_at(
10055            parent_dir.as_raw_fd(),
10056            &stage_name,
10057            &dest.display().to_string(),
10058        );
10059        return Err(error);
10060    }
10061
10062    if let Err(error) =
10063        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10064    {
10065        let _ = remove_tree_at(
10066            parent_dir.as_raw_fd(),
10067            &stage_name,
10068            &dest.display().to_string(),
10069        );
10070        return Err(error);
10071    }
10072    parent_dir.sync_all()?;
10073    if dest_exists {
10074        // Commit already happened atomically. Cleanup is best-effort so a
10075        // failure cannot be reported as a failed pull after the live tree
10076        // changed; a crash may leave only this private old-tree sibling.
10077        let _ = remove_tree_at(
10078            parent_dir.as_raw_fd(),
10079            &stage_name,
10080            &dest.display().to_string(),
10081        );
10082        let _ = parent_dir.sync_all();
10083    }
10084    Ok(())
10085}
10086
10087#[cfg(unix)]
10088fn install_pulled_delta_sources(
10089    dest: &Path,
10090    entries: &[V2StagedFile],
10091    deleted: &[String],
10092    rebuild_indexes: bool,
10093    _previous: Option<&V2SyncBaseline>,
10094    _next: &V2VerifiedHead,
10095) -> LinkResult<()> {
10096    use ring::rand::SecureRandom as _;
10097    use std::os::fd::AsRawFd as _;
10098    use std::os::unix::ffi::OsStrExt as _;
10099
10100    // An established checkout is updated only at the changed coordinates
10101    // through the durable pull journal shared with Windows. Initial clones
10102    // still use a private complete stage and one atomic directory install.
10103    if let Ok(store) = Store::open_strict(dest) {
10104        return install_established_v2_delta(
10105            store,
10106            entries,
10107            deleted,
10108            rebuild_indexes,
10109            _previous,
10110            _next,
10111        );
10112    }
10113
10114    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10115    let name = dest
10116        .file_name()
10117        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10118        .ok_or_else(|| LinkError::UnsafePath {
10119            path: dest.display().to_string(),
10120        })?;
10121    let parent_dir = open_or_create_dir_nofollow(parent)?;
10122    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10123    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10124        None => false,
10125        Some(true) => true,
10126        Some(false) => {
10127            return Err(LinkError::UnsafePath {
10128                path: dest.display().to_string(),
10129            })
10130        }
10131    };
10132    let mut nonce = [0_u8; 16];
10133    ring::rand::SystemRandom::new()
10134        .fill(&mut nonce)
10135        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10136    let stage_label = format!(
10137        ".{}.dbmd-pull-stage-{}",
10138        name.to_string_lossy(),
10139        URL_SAFE_NO_PAD.encode(nonce)
10140    );
10141    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10142    let stage_dir = create_dir_exclusive_at(
10143        parent_dir.as_raw_fd(),
10144        &stage_name,
10145        &dest.display().to_string(),
10146    )?;
10147    let prepared = (|| -> LinkResult<()> {
10148        if dest_exists {
10149            let live = open_dir_at(
10150                parent_dir.as_raw_fd(),
10151                &dest_name,
10152                &dest.display().to_string(),
10153            )?;
10154            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10155        }
10156        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10157        write_pull_sources_beneath_dir(&stage_dir, entries)?;
10158        if rebuild_indexes {
10159            let stage_store =
10160                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10161                    .map_err(|error| LinkError::InvalidPack {
10162                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10163                    })?;
10164            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10165                LinkError::InvalidPack {
10166                    message: format!("could not materialize v2 local catalogs: {error}"),
10167                }
10168            })?;
10169        }
10170        stage_dir.sync_all()?;
10171        Ok(())
10172    })();
10173    if let Err(error) = prepared {
10174        let _ = remove_tree_at(
10175            parent_dir.as_raw_fd(),
10176            &stage_name,
10177            &dest.display().to_string(),
10178        );
10179        return Err(error);
10180    }
10181    if let Err(error) =
10182        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10183    {
10184        let _ = remove_tree_at(
10185            parent_dir.as_raw_fd(),
10186            &stage_name,
10187            &dest.display().to_string(),
10188        );
10189        return Err(error);
10190    }
10191    parent_dir.sync_all()?;
10192    if dest_exists {
10193        let _ = remove_tree_at(
10194            parent_dir.as_raw_fd(),
10195            &stage_name,
10196            &dest.display().to_string(),
10197        );
10198        let _ = parent_dir.sync_all();
10199    }
10200    Ok(())
10201}
10202
10203#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10204struct V2PullCoordinate {
10205    head_seq: Option<u64>,
10206    commit_hash: Option<String>,
10207    view_kind: Option<String>,
10208    view_revision: Option<String>,
10209}
10210
10211#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10212struct V2PullFileCoordinate {
10213    sha256: String,
10214    bytes: u64,
10215}
10216
10217#[derive(Debug, Clone, Deserialize, Serialize)]
10218struct V2PullJournalEntry {
10219    path: String,
10220    old: Option<V2PullFileCoordinate>,
10221    new: Option<V2PullFileCoordinate>,
10222    backup: Option<String>,
10223}
10224
10225#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10226#[serde(rename_all = "snake_case")]
10227enum V2PullPhase {
10228    Preparing,
10229    Ready,
10230}
10231
10232#[derive(Debug, Clone, Deserialize, Serialize)]
10233struct V2PullJournal {
10234    v: u8,
10235    phase: V2PullPhase,
10236    brain: String,
10237    previous: V2PullCoordinate,
10238    next: V2PullCoordinate,
10239    backup_dir: String,
10240    entries: Vec<V2PullJournalEntry>,
10241}
10242
10243const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
10244
10245fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
10246    V2PullCoordinate {
10247        head_seq: baseline.and_then(|value| value.head_seq),
10248        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
10249        view_kind: baseline.and_then(|value| value.view_kind.clone()),
10250        view_revision: baseline.and_then(|value| value.view_revision.clone()),
10251    }
10252}
10253
10254fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
10255    V2PullCoordinate {
10256        head_seq: head.pointer.as_ref().map(|value| value.seq),
10257        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
10258        view_kind: Some(head.view_kind.clone()),
10259        view_revision: Some(head.view_revision.clone()),
10260    }
10261}
10262
10263fn v2_pull_file_coordinate(
10264    store: &Store,
10265    path: &str,
10266    limit: u64,
10267) -> LinkResult<Option<V2PullFileCoordinate>> {
10268    let file = match store.open_regular(Path::new(path)) {
10269        Ok(file) => file,
10270        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10271        Err(error) => return Err(error.into()),
10272    };
10273    let bytes = file.metadata()?.len();
10274    if bytes > limit || bytes > MAX_STORE_BYTES {
10275        return Err(invalid_feed(
10276            "pull transaction file exceeds its declared bound",
10277        ));
10278    }
10279    Ok(Some(V2PullFileCoordinate {
10280        sha256: content_sha256_reader(file)?,
10281        bytes,
10282    }))
10283}
10284
10285fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
10286    let mut bytes = serde_json::to_vec_pretty(journal)
10287        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
10288    bytes.push(b'\n');
10289    Ok(bytes)
10290}
10291
10292fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10293    let backup_prefix = ".dbmd/pull-backup-";
10294    let suffix = journal
10295        .backup_dir
10296        .strip_prefix(backup_prefix)
10297        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10298    let mut paths = std::collections::BTreeSet::new();
10299    if journal.v != 1
10300        || !crate::ulid::is_ulid(&journal.brain)
10301        || !crate::ulid::is_ulid(suffix)
10302        || journal.entries.is_empty()
10303        || journal.entries.len() > MAX_PUSH_FILES + 4
10304        || journal.previous == journal.next
10305    {
10306        return Err(invalid_feed("v2 pull journal failed validation"));
10307    }
10308    for (index, entry) in journal.entries.iter().enumerate() {
10309        if !safe_store_rel_path(&entry.path)
10310            || entry.path == V2_PULL_JOURNAL
10311            || entry.path.starts_with(backup_prefix)
10312            || !paths.insert(entry.path.clone())
10313            || (entry.old.is_none() && entry.new.is_none())
10314            || entry
10315                .old
10316                .iter()
10317                .chain(entry.new.iter())
10318                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10319            || entry.backup.as_deref()
10320                != entry
10321                    .old
10322                    .as_ref()
10323                    .map(|_| format!("{index:08x}"))
10324                    .as_deref()
10325        {
10326            return Err(invalid_feed("v2 pull journal entry failed validation"));
10327        }
10328    }
10329    Ok(())
10330}
10331
10332fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10333    #[cfg(unix)]
10334    {
10335        use std::os::unix::fs::PermissionsExt as _;
10336        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10337            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10338                return Err(invalid_feed(
10339                    "v2 pull journal is accessible to group/other; set mode 0600",
10340                ));
10341            }
10342            Ok(_) => {}
10343            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10344            Err(error) => return Err(error.into()),
10345        }
10346    }
10347    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10348        Ok(bytes) => bytes,
10349        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10350        Err(error) => return Err(error.into()),
10351    };
10352    let journal: V2PullJournal =
10353        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10354    validate_v2_pull_journal(&journal)?;
10355    Ok(Some(journal))
10356}
10357
10358fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10359    // The journal is the only recovery authority. Remove it first only after
10360    // rollback or baseline commit is durable; an orphan private backup is safe
10361    // and can be pruned, while a journal without its backups is not recoverable.
10362    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10363        Ok(()) => {}
10364        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10365        Err(error) => return Err(error.into()),
10366    }
10367    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10368        Ok(()) => Ok(()),
10369        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10370        Err(error) => Err(error.into()),
10371    }
10372}
10373
10374fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10375    let names = match store.directory_names(Path::new(".dbmd")) {
10376        Ok(names) => names,
10377        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10378        Err(error) => return Err(error.into()),
10379    };
10380    for name in names {
10381        let Some(name) = name.to_str() else {
10382            continue;
10383        };
10384        let Some(suffix) = name.strip_prefix("pull-backup-") else {
10385            continue;
10386        };
10387        if crate::ulid::is_ulid(suffix) {
10388            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10389        }
10390    }
10391    Ok(())
10392}
10393
10394fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10395    // Validate every live coordinate and every backup before the first restore.
10396    for entry in &journal.entries {
10397        let limit = entry
10398            .old
10399            .as_ref()
10400            .into_iter()
10401            .chain(entry.new.iter())
10402            .map(|value| value.bytes)
10403            .max()
10404            .unwrap_or(0);
10405        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10406        if current != entry.old && current != entry.new {
10407            return Err(LinkError::InvalidPack {
10408                message: format!(
10409                    "cannot recover interrupted pull because `{}` changed afterward",
10410                    entry.path
10411                ),
10412            });
10413        }
10414        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10415            let path = Path::new(&journal.backup_dir).join(backup);
10416            let file = store.open_regular(&path)?;
10417            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10418                return Err(invalid_feed("v2 pull recovery backup failed verification"));
10419            }
10420        }
10421    }
10422    for entry in journal.entries.iter().rev() {
10423        match (&entry.old, &entry.backup) {
10424            (Some(old), Some(backup)) => {
10425                let bytes =
10426                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10427                store.write_atomic(Path::new(&entry.path), &bytes)?;
10428            }
10429            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10430                store.remove_file(Path::new(&entry.path))?;
10431            }
10432            (None, None) => {}
10433            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10434        }
10435    }
10436    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10437        message: format!("could not rebuild catalogs after pull recovery: {error}"),
10438    })?;
10439    cleanup_v2_pull_journal(store, journal)
10440}
10441
10442fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10443    let Ok(store) = Store::open_strict(dest) else {
10444        return Ok(());
10445    };
10446    if let Some(journal) = load_v2_pull_journal(&store)? {
10447        if journal.brain != brain {
10448            return Err(invalid_feed("v2 pull journal belongs to another brain"));
10449        }
10450        if journal.phase == V2PullPhase::Preparing {
10451            cleanup_v2_pull_journal(&store, &journal)?;
10452        } else {
10453            let baseline = load_v2_baseline(cfg, brain, dest)?;
10454            let current = v2_pull_baseline_coordinate(baseline.as_ref());
10455            if current == journal.next {
10456                cleanup_v2_pull_journal(&store, &journal)?;
10457            } else {
10458                if current != journal.previous {
10459                    return Err(invalid_feed(
10460                        "cannot recover interrupted pull because its baseline changed afterward",
10461                    ));
10462                }
10463                rollback_v2_pull(&store, &journal)?;
10464            }
10465        }
10466    }
10467    // Cleanup removes the journal first after the state is durably old or new.
10468    // A hard kill in the tiny interval before deleting its backup directory can
10469    // therefore leave only an inert, private orphan. The per-brain operation
10470    // lock is held by the caller, so no live pull can own one here.
10471    prune_orphan_v2_pull_backups(&store)
10472}
10473
10474fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10475    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10476        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10477    })?;
10478    if let Some(journal) = load_v2_pull_journal(&store)? {
10479        cleanup_v2_pull_journal(&store, &journal)?;
10480    }
10481    Ok(())
10482}
10483
10484#[cfg(windows)]
10485fn install_windows_initial_sources(
10486    dest: &Path,
10487    entries: &[V2StagedFile],
10488    rebuild_indexes: bool,
10489) -> LinkResult<()> {
10490    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10491    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10492        path: dest.display().to_string(),
10493    })?;
10494    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10495    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10496        return Err(LinkError::UnsafePath {
10497            path: dest.display().to_string(),
10498        });
10499    }
10500    let stage_name = format!(
10501        ".{}.dbmd-pull-stage-{}",
10502        name.to_string_lossy(),
10503        crate::ulid::mint()
10504    );
10505    let stage_path = parent.join(&stage_name);
10506    let stage_capability =
10507        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10508    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10509    let prepared = (|| -> LinkResult<()> {
10510        for entry in entries {
10511            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10512            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10513                return Err(invalid_feed(
10514                    "private staged sync source failed final integrity verification",
10515                ));
10516            }
10517            stage.write_atomic(Path::new(&entry.path), &bytes)?;
10518        }
10519        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10520            .map_err(|error| LinkError::InvalidPack {
10521                message: format!("v2 staging tree is not a valid db.md store: {error}"),
10522            })?;
10523        if rebuild_indexes {
10524            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10525                message: format!("could not materialize v2 local catalogs: {error}"),
10526            })?;
10527        }
10528        Ok(())
10529    })();
10530    if let Err(error) = prepared {
10531        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10532        return Err(error);
10533    }
10534    crate::fsx::rename_directory_beneath(
10535        &parent_capability,
10536        Path::new(&stage_name),
10537        Path::new(name),
10538    )?;
10539    Ok(())
10540}
10541
10542fn install_established_v2_delta(
10543    store: Store,
10544    entries: &[V2StagedFile],
10545    deleted: &[String],
10546    rebuild_indexes: bool,
10547    previous: Option<&V2SyncBaseline>,
10548    next: &V2VerifiedHead,
10549) -> LinkResult<()> {
10550    if load_v2_pull_journal(&store)?.is_some() {
10551        return Err(invalid_feed(
10552            "an interrupted pull must be recovered before installing",
10553        ));
10554    }
10555    let mut sources = std::collections::BTreeMap::new();
10556    for entry in entries {
10557        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10558            return Err(invalid_feed("pull mutation repeats a path"));
10559        }
10560    }
10561    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10562    paths.extend(deleted.iter().cloned());
10563    paths.sort();
10564    paths.dedup();
10565    if paths.is_empty() {
10566        return Ok(());
10567    }
10568    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10569    let mut journal = V2PullJournal {
10570        v: 1,
10571        phase: V2PullPhase::Preparing,
10572        brain: next.brain_id.clone(),
10573        previous: v2_pull_baseline_coordinate(previous),
10574        next: v2_pull_head_coordinate(next),
10575        backup_dir: backup_dir.clone(),
10576        entries: Vec::with_capacity(paths.len()),
10577    };
10578    for path in &paths {
10579        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10580        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10581            sha256: entry.sha256.clone(),
10582            bytes: entry.bytes,
10583        });
10584        if old == new {
10585            continue;
10586        }
10587        let index = journal.entries.len();
10588        journal.entries.push(V2PullJournalEntry {
10589            path: path.clone(),
10590            backup: old.as_ref().map(|_| format!("{index:08x}")),
10591            old,
10592            new,
10593        });
10594    }
10595    if journal.entries.is_empty() {
10596        return Ok(());
10597    }
10598    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10599        entry
10600            .old
10601            .as_ref()
10602            .map_or(Some(total), |old| total.checked_add(old.bytes))
10603    });
10604    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10605        return Err(LinkError::InvalidPack {
10606            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10607        });
10608    }
10609    validate_v2_pull_journal(&journal)?;
10610    store.write_private_atomic_new(
10611        Path::new(V2_PULL_JOURNAL),
10612        &v2_pull_journal_bytes(&journal)?,
10613    )?;
10614    let prepared = (|| -> LinkResult<()> {
10615        store.create_private_dir_all(Path::new(&backup_dir))?;
10616        for entry in &journal.entries {
10617            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10618                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10619                if content_sha256(&bytes) != old.sha256 {
10620                    return Err(invalid_feed("live pull source changed during backup"));
10621                }
10622                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10623            }
10624        }
10625        journal.phase = V2PullPhase::Ready;
10626        store.write_private_atomic(
10627            Path::new(V2_PULL_JOURNAL),
10628            &v2_pull_journal_bytes(&journal)?,
10629        )?;
10630        Ok(())
10631    })();
10632    if let Err(error) = prepared {
10633        let cleanup = cleanup_v2_pull_journal(&store, &journal);
10634        return match cleanup {
10635            Ok(()) => Err(error),
10636            Err(cleanup) => Err(LinkError::InvalidPack {
10637                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10638            }),
10639        };
10640    }
10641    let installed = (|| -> LinkResult<()> {
10642        for entry in &journal.entries {
10643            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10644                return Err(LinkError::InvalidPack {
10645                    message: format!("local path `{}` changed during pull", entry.path),
10646                });
10647            }
10648            if let Some(source) = sources.get(&entry.path) {
10649                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10650                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10651                    return Err(invalid_feed(
10652                        "private staged sync source failed final integrity verification",
10653                    ));
10654                }
10655                store.write_atomic(Path::new(&entry.path), &bytes)?;
10656            } else if entry.old.is_some() {
10657                store.remove_file(Path::new(&entry.path))?;
10658            }
10659        }
10660        if rebuild_indexes {
10661            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10662                message: format!("could not materialize v2 local catalogs: {error}"),
10663            })?;
10664        }
10665        Ok(())
10666    })();
10667    if let Err(error) = installed {
10668        return match rollback_v2_pull(&store, &journal) {
10669            Ok(()) => Err(error),
10670            Err(rollback) => Err(LinkError::InvalidPack {
10671                message: format!("{error}; durable pull rollback also failed: {rollback}"),
10672            }),
10673        };
10674    }
10675    Ok(())
10676}
10677
10678#[cfg(windows)]
10679fn install_pulled_delta_sources(
10680    dest: &Path,
10681    entries: &[V2StagedFile],
10682    deleted: &[String],
10683    rebuild_indexes: bool,
10684    previous: Option<&V2SyncBaseline>,
10685    next: &V2VerifiedHead,
10686) -> LinkResult<()> {
10687    match Store::open_strict(dest) {
10688        Ok(store) => {
10689            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10690        }
10691        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10692    }
10693}
10694
10695#[cfg(not(any(unix, windows)))]
10696fn install_pulled_delta_sources(
10697    _dest: &Path,
10698    _entries: &[V2StagedFile],
10699    _deleted: &[String],
10700    _rebuild_indexes: bool,
10701    _previous: Option<&V2SyncBaseline>,
10702    _next: &V2VerifiedHead,
10703) -> LinkResult<()> {
10704    Err(LinkError::UnsupportedPlatform {
10705        operation: "atomic v2 pull install",
10706    })
10707}
10708
10709#[cfg(unix)]
10710fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10711    install_pulled_delta(dest, entries, &[], false)
10712}
10713
10714#[cfg(not(windows))]
10715fn is_safe_slug(slug: &str) -> bool {
10716    !slug.is_empty()
10717        && slug.len() <= 63
10718        && !slug.starts_with('-')
10719        && !slug.ends_with('-')
10720        && slug
10721            .bytes()
10722            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10723}
10724
10725fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10726    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10727}
10728
10729fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10730    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10731}
10732
10733fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10734    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10735}
10736
10737fn preflight_zip_central_directory(
10738    bytes: &[u8],
10739    offset: usize,
10740    size: usize,
10741    count: u64,
10742) -> LinkResult<()> {
10743    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10744    let end = offset
10745        .checked_add(size)
10746        .filter(|end| *end <= bytes.len())
10747        .ok_or_else(|| LinkError::InvalidPack {
10748            message: "ZIP central directory is out of bounds".to_string(),
10749        })?;
10750    let mut cursor = offset;
10751    for _ in 0..count {
10752        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10753            return Err(LinkError::InvalidPack {
10754                message: "ZIP central directory entry count is inconsistent".to_string(),
10755            });
10756        }
10757        if le_u16(bytes, cursor + 34) != Some(0) {
10758            return Err(LinkError::InvalidPack {
10759                message: "multi-disk ZIP archives are not supported".to_string(),
10760            });
10761        }
10762        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10763            total.checked_add(le_u16(bytes, cursor + at)? as usize)
10764        });
10765        cursor = cursor
10766            .checked_add(46)
10767            .and_then(|fixed| fixed.checked_add(variable?))
10768            .filter(|cursor| *cursor <= end)
10769            .ok_or_else(|| LinkError::InvalidPack {
10770                message: "ZIP central directory entry is truncated".to_string(),
10771            })?;
10772    }
10773    if cursor != end {
10774        return Err(LinkError::InvalidPack {
10775            message: "ZIP central directory size is inconsistent".to_string(),
10776        });
10777    }
10778    Ok(())
10779}
10780
10781/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
10782/// metadata object per central-directory entry. Supports ordinary EOCD and the
10783/// Zip64 locator/record emitted for >65,535-entry archives.
10784fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10785    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10786    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10787    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10788    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10789    let eocd = bytes[search_start..]
10790        .windows(4)
10791        .rposition(|window| window == EOCD_SIG)
10792        .map(|offset| search_start + offset)
10793        .ok_or_else(|| LinkError::InvalidPack {
10794            message: "ZIP has no end-of-central-directory record".to_string(),
10795        })?;
10796    let invalid_end = || LinkError::InvalidPack {
10797        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10798    };
10799    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10800    if eocd
10801        .checked_add(22)
10802        .and_then(|end| end.checked_add(comment_len))
10803        != Some(bytes.len())
10804    {
10805        // Do not fall back to an earlier signature. ZipArchive does, and a
10806        // fake low-count EOCD appended after a real Zip64 entry-count bomb
10807        // would otherwise bypass this allocation preflight.
10808        return Err(invalid_end());
10809    }
10810    let disk = le_u16(bytes, eocd + 4);
10811    let central_disk = le_u16(bytes, eocd + 6);
10812    if disk != Some(0) || central_disk != Some(0) {
10813        return Err(LinkError::InvalidPack {
10814            message: "multi-disk ZIP archives are not supported".to_string(),
10815        });
10816    }
10817    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10818    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10819    if entries_on_disk != ordinary {
10820        return Err(LinkError::InvalidPack {
10821            message: "multi-disk ZIP archives are not supported".to_string(),
10822        });
10823    }
10824    let zip64_locator = eocd
10825        .checked_sub(20)
10826        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10827    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10828        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10829        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10830        if central_offset
10831            .checked_add(central_size)
10832            .filter(|end| *end == eocd)
10833            .is_none()
10834        {
10835            return Err(invalid_end());
10836        }
10837        (ordinary as u64, central_offset, central_size)
10838    } else {
10839        let Some(locator) = zip64_locator else {
10840            return Err(invalid_end());
10841        };
10842        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10843            return Err(LinkError::InvalidPack {
10844                message: "multi-disk ZIP64 archives are not supported".to_string(),
10845            });
10846        }
10847        let record = le_u64(bytes, locator + 8)
10848            .and_then(|offset| usize::try_from(offset).ok())
10849            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10850            .ok_or_else(|| LinkError::InvalidPack {
10851                message: "ZIP64 archive has an invalid end record".to_string(),
10852            })?;
10853        let record_size = le_u64(bytes, record + 4)
10854            .and_then(|size| usize::try_from(size).ok())
10855            .filter(|size| *size >= 44)
10856            .ok_or_else(invalid_end)?;
10857        if record
10858            .checked_add(12)
10859            .and_then(|end| end.checked_add(record_size))
10860            != Some(locator)
10861            || le_u32(bytes, record + 16) != Some(0)
10862            || le_u32(bytes, record + 20) != Some(0)
10863        {
10864            return Err(invalid_end());
10865        }
10866        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10867        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10868        let central_size = le_u64(bytes, record + 40)
10869            .and_then(|size| usize::try_from(size).ok())
10870            .ok_or_else(invalid_end)?;
10871        let central_offset = le_u64(bytes, record + 48)
10872            .and_then(|offset| usize::try_from(offset).ok())
10873            .ok_or_else(invalid_end)?;
10874        if zip64_on_disk != zip64_total
10875            || central_offset
10876                .checked_add(central_size)
10877                .filter(|end| *end == record)
10878                .is_none()
10879        {
10880            return Err(invalid_end());
10881        }
10882        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10883        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10884        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10885            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10886        {
10887            return Err(invalid_end());
10888        }
10889        (zip64_total, central_offset, central_size)
10890    };
10891    if count == 0 || count > max_entries as u64 {
10892        return Err(LinkError::InvalidPack {
10893            message: format!("invalid file count {count}"),
10894        });
10895    }
10896    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10897    Ok(())
10898}
10899
10900fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10901    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10902    let mut archive =
10903        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10904            message: format!("ZIP parse failed: {err}"),
10905        })?;
10906    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10907        return Err(LinkError::InvalidPack {
10908            message: format!("invalid file count {}", archive.len()),
10909        });
10910    }
10911    let mut total = 0u64;
10912    let mut seen = std::collections::HashSet::new();
10913    let mut entries = Vec::with_capacity(archive.len());
10914    for index in 0..archive.len() {
10915        let mut file = archive
10916            .by_index(index)
10917            .map_err(|err| LinkError::InvalidPack {
10918                message: format!("ZIP entry failed: {err}"),
10919            })?;
10920        if file.is_dir() {
10921            continue;
10922        }
10923        let path = file.name().to_string();
10924        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10925            return Err(LinkError::UnsafePath { path });
10926        }
10927        if file
10928            .unix_mode()
10929            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10930        {
10931            return Err(LinkError::InvalidPack {
10932                message: format!("non-file entry `{path}`"),
10933            });
10934        }
10935        if !seen.insert(path.clone()) {
10936            return Err(LinkError::InvalidPack {
10937                message: format!("duplicate path `{path}`"),
10938            });
10939        }
10940        let remaining = MAX_STORE_BYTES.saturating_sub(total);
10941        if file.size() > remaining {
10942            return Err(LinkError::InvalidPack {
10943                message: "expanded content exceeds the 512 MB limit".to_string(),
10944            });
10945        }
10946        let mut content = Vec::new();
10947        (&mut file)
10948            .take(remaining + 1)
10949            .read_to_end(&mut content)
10950            .map_err(|err| LinkError::InvalidPack {
10951                message: format!("could not decompress `{path}`: {err}"),
10952            })?;
10953        if content.len() as u64 > remaining {
10954            return Err(LinkError::InvalidPack {
10955                message: "expanded content exceeds the 512 MB limit".to_string(),
10956            });
10957        }
10958        if content.len() as u64 != file.size() {
10959            return Err(LinkError::InvalidPack {
10960                message: format!("length mismatch for `{path}`"),
10961            });
10962        }
10963        total += content.len() as u64;
10964        entries.push((path, content));
10965    }
10966    if entries.is_empty() {
10967        return Err(LinkError::InvalidPack {
10968            message: "pack contains no files".to_string(),
10969        });
10970    }
10971    Ok(entries)
10972}
10973
10974fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
10975    let mut expected = std::collections::BTreeMap::new();
10976    for file in signed {
10977        if !safe_store_rel_path(&file.path) {
10978            return Err(LinkError::UnsafePath {
10979                path: file.path.clone(),
10980            });
10981        }
10982        if !is_sha256(&file.sha256)
10983            || expected
10984                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10985                .is_some()
10986        {
10987            return Err(invalid_feed(
10988                "signed snapshot manifest contains an invalid or duplicate file",
10989            ));
10990        }
10991    }
10992    if expected.len() != entries.len() {
10993        return Err(invalid_feed(
10994            "downloaded pack file set differs from the signed snapshot manifest",
10995        ));
10996    }
10997    for (path, bytes) in entries {
10998        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
10999            return Err(invalid_feed(format!(
11000                "downloaded pack contains unsigned path `{path}`"
11001            )));
11002        };
11003        if *declared_bytes != bytes.len() as u64
11004            || *sha256 != format!("{:x}", Sha256::digest(bytes))
11005        {
11006            return Err(invalid_feed(format!(
11007                "downloaded file `{path}` differs from its signed manifest"
11008            )));
11009        }
11010    }
11011    Ok(())
11012}
11013
11014/// Collect the files a push sends: the store's owned text — `DB.md`,
11015/// `assets.jsonl` when present, and every content `.md` under `records/` and
11016/// `sources/` (the store walk, which already excludes hidden dirs like
11017/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
11018/// derives its own index, and local history stays local). Returns
11019/// `(store-relative path, content)` pairs, path-sorted.
11020pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11021    require_hardened_filesystem("sync push")?;
11022    preflight_push_ownership(store)?;
11023    let mut out: Vec<(String, String)> = Vec::new();
11024    let mut total = 0u64;
11025
11026    let mut read_text = |rel: &str| -> LinkResult<String> {
11027        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11028        total = total
11029            .checked_add(bytes.len() as u64)
11030            .ok_or_else(|| LinkError::PushTooLarge {
11031                detail: "uncompressed byte count overflow".to_string(),
11032            })?;
11033        if total > MAX_STORE_BYTES {
11034            return Err(LinkError::PushTooLarge {
11035                detail: format!("{total} uncompressed bytes"),
11036            });
11037        }
11038        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11039            path: rel.to_string(),
11040        })
11041    };
11042
11043    out.push(("DB.md".to_string(), read_text("DB.md")?));
11044    if store
11045        .regular_file_exists(Path::new("assets.jsonl"))
11046        .unwrap_or(false)
11047    {
11048        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11049    }
11050
11051    for rel in store.walk()? {
11052        let rel_str = rel.to_string_lossy().replace('\\', "/");
11053        if !safe_store_rel_path(&rel_str) {
11054            // A locally-legal name outside the hub's portable charset cannot
11055            // travel this wire; refusing beats silently dropping it.
11056            return Err(LinkError::UnsafePath { path: rel_str });
11057        }
11058        let content = read_text(&rel_str)?;
11059        out.push((rel_str, content));
11060    }
11061
11062    out.sort_by(|a, b| a.0.cmp(&b.0));
11063    Ok(out)
11064}
11065
11066/// Refuse to build a destructive whole-store snapshot from an ambiguous local
11067/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
11068/// silently omitted them could delete the hosted copies of those paths.
11069fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11070    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11071        return Err(LinkError::from(std::io::Error::new(
11072            std::io::ErrorKind::PermissionDenied,
11073            format!("cannot push: nested db.md store at {}", nested.display()),
11074        )));
11075    }
11076
11077    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11078        return Err(LinkError::from(std::io::Error::new(
11079            std::io::ErrorKind::PermissionDenied,
11080            format!(
11081                "cannot push: {} is a symlink outside the store ownership model",
11082                symlink.display()
11083            ),
11084        )));
11085    }
11086    Ok(())
11087}
11088
11089/// Push `files` to `brain` as a whole-store snapshot — the hub's push
11090/// semantics: the hosted copy becomes exactly this set (pull first if the
11091/// hosted side may have records the local copy lacks). Client-side caps
11092/// mirror the hub's JSON-path limits so an oversized push fails before the
11093/// upload.
11094pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11095    require_safe_ref(brain)?;
11096    let remote = verified_remote_head(cfg, brain, false)?;
11097    if files.len() > MAX_PUSH_FILES {
11098        return Err(LinkError::PushTooLarge {
11099            detail: format!("{} files", files.len()),
11100        });
11101    }
11102    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11103    if raw_total > MAX_STORE_BYTES {
11104        return Err(LinkError::PushTooLarge {
11105            detail: format!("{raw_total} uncompressed bytes"),
11106        });
11107    }
11108
11109    // Self-custody (a brain key is configured): the JSON fast path is
11110    // hub-signed by construction, so every push goes through the pack flow
11111    // with a locally signed entry — the hub verifies and can never sign.
11112    if cfg.brain_key.is_none() {
11113        let body = json!({
11114            "files": files
11115                .iter()
11116                .map(|(p, c)| json!({ "path": p, "content": c }))
11117                .collect::<Vec<_>>(),
11118        });
11119        if body.to_string().len() <= MAX_PUSH_BYTES {
11120            let path = format!("/api/hub/brains/{brain}/push");
11121            let pushed = ensure_ok(
11122                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11123                "sync push",
11124            )?;
11125            return Ok(pushed);
11126        }
11127    }
11128
11129    let pack = build_store_pack(files)?;
11130    if pack.len() as u64 > MAX_PACK_BYTES {
11131        return Err(LinkError::PushTooLarge {
11132            detail: format!("{} pack bytes", pack.len()),
11133        });
11134    }
11135    let sha256 = format!("{:x}", Sha256::digest(&pack));
11136    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11137    if let Some(key) = &cfg.brain_key {
11138        if !remote.head.verified {
11139            return Err(invalid_feed(
11140                "self-custody push requires a fully verified, unscoped feed head",
11141            ));
11142        }
11143        let identity = remote
11144            .identity
11145            .as_ref()
11146            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
11147        let current_multikey = format!("ed25519:{}", identity.fingerprint);
11148        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
11149            return Err(invalid_feed(
11150                "configured brain key is not the verified current brain identity",
11151            ));
11152        }
11153        // The verified state pins seq + prev; a concurrent writer surfaces as
11154        // the hub's 422 on commit (re-run to retry against the new head).
11155        let next_seq = remote
11156            .head
11157            .seq
11158            .checked_add(1)
11159            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
11160        let mut manifest: Vec<WireFeedFile> = files
11161            .iter()
11162            .map(|(path, content)| WireFeedFile {
11163                path: path.clone(),
11164                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
11165                bytes: content.len() as u64,
11166            })
11167            .collect();
11168        manifest.sort_by(|a, b| a.path.cmp(&b.path));
11169        let ts = crate::now()
11170            .with_timezone(&chrono::Utc)
11171            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11172            .to_string();
11173        let entry = self_custody_entry(
11174            key,
11175            next_seq,
11176            ts,
11177            &sha256,
11178            &manifest,
11179            remote.head.feed_hash.as_deref(),
11180        )?;
11181        meta["entry"] = Value::String(entry);
11182    }
11183    let presigned = ensure_ok(
11184        request(
11185            cfg,
11186            "POST",
11187            &format!("/api/hub/brains/{brain}/packs/presign"),
11188            Some(&meta),
11189            Auth::Required,
11190        )?,
11191        "prepare pack upload",
11192    )?;
11193    let url = presigned
11194        .get("url")
11195        .and_then(Value::as_str)
11196        .ok_or_else(|| LinkError::InvalidPack {
11197            message: "the hub returned no upload URL".to_string(),
11198        })?;
11199    put_presigned(
11200        cfg,
11201        url,
11202        presigned.get("headers").unwrap_or(&Value::Null),
11203        &pack,
11204    )?;
11205    let committed = ensure_ok(
11206        request(
11207            cfg,
11208            "POST",
11209            &format!("/api/hub/brains/{brain}/packs/commit"),
11210            Some(&meta),
11211            Auth::Required,
11212        )?,
11213        "commit pack",
11214    )?;
11215    Ok(committed)
11216}
11217
11218fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
11219    const LOCAL_HEADER: u32 = 0x0403_4b50;
11220    const CENTRAL_HEADER: u32 = 0x0201_4b50;
11221    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
11222    const VERSION_20: u16 = 20;
11223    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
11224    const UTF8_FLAG: u16 = 1 << 11;
11225    const STORED: u16 = 0;
11226    const DOS_TIME_MIDNIGHT: u16 = 0;
11227    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
11228    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
11229
11230    struct CentralEntry<'a> {
11231        name: &'a [u8],
11232        crc32: u32,
11233        size: u32,
11234        local_offset: u32,
11235    }
11236
11237    fn push_u16(out: &mut Vec<u8>, value: u16) {
11238        out.extend_from_slice(&value.to_le_bytes());
11239    }
11240
11241    fn push_u32(out: &mut Vec<u8>, value: u32) {
11242        out.extend_from_slice(&value.to_le_bytes());
11243    }
11244
11245    if files.is_empty() {
11246        return Err(LinkError::InvalidPack {
11247            message: "cannot create an empty snapshot pack".to_string(),
11248        });
11249    }
11250    if files.len() > u16::MAX as usize {
11251        return Err(LinkError::PushTooLarge {
11252            detail: format!(
11253                "{} files (canonical ZIP32 packs cap at {})",
11254                files.len(),
11255                u16::MAX
11256            ),
11257        });
11258    }
11259
11260    let mut sorted: Vec<_> = files.iter().collect();
11261    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
11262    let mut previous: Option<&str> = None;
11263    for (path, content) in &sorted {
11264        if !safe_store_rel_path(path) {
11265            return Err(LinkError::UnsafePath {
11266                path: (*path).clone(),
11267            });
11268        }
11269        if previous == Some(path.as_str()) {
11270            return Err(LinkError::InvalidPack {
11271                message: format!("duplicate path `{path}`"),
11272            });
11273        }
11274        previous = Some(path.as_str());
11275        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
11276            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11277        })?;
11278    }
11279
11280    let mut out = Vec::new();
11281    let mut central = Vec::with_capacity(sorted.len());
11282    for (path, content) in sorted {
11283        let name = path.as_bytes();
11284        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
11285            message: format!("ZIP entry name is too long: `{path}`"),
11286        })?;
11287        let bytes = content.as_bytes();
11288        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
11289            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11290        })?;
11291        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11292            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11293        })?;
11294        let crc32 = crc32fast::hash(bytes);
11295
11296        // Canonical local header: sizes and CRC are known up front. Bit 3 is
11297        // deliberately clear, so there is no trailing data descriptor.
11298        push_u32(&mut out, LOCAL_HEADER);
11299        push_u16(&mut out, VERSION_20);
11300        push_u16(&mut out, UTF8_FLAG);
11301        push_u16(&mut out, STORED);
11302        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11303        push_u16(&mut out, DOS_DATE_1980_01_01);
11304        push_u32(&mut out, crc32);
11305        push_u32(&mut out, size);
11306        push_u32(&mut out, size);
11307        push_u16(&mut out, name_len);
11308        push_u16(&mut out, 0); // no local extra data
11309        out.extend_from_slice(name);
11310        out.extend_from_slice(bytes);
11311
11312        central.push(CentralEntry {
11313            name,
11314            crc32,
11315            size,
11316            local_offset,
11317        });
11318    }
11319
11320    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11321        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11322    })?;
11323    for entry in &central {
11324        push_u32(&mut out, CENTRAL_HEADER);
11325        push_u16(&mut out, MADE_BY_UNIX_20);
11326        push_u16(&mut out, VERSION_20);
11327        push_u16(&mut out, UTF8_FLAG);
11328        push_u16(&mut out, STORED);
11329        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11330        push_u16(&mut out, DOS_DATE_1980_01_01);
11331        push_u32(&mut out, entry.crc32);
11332        push_u32(&mut out, entry.size);
11333        push_u32(&mut out, entry.size);
11334        push_u16(&mut out, entry.name.len() as u16);
11335        push_u16(&mut out, 0); // no central extra data
11336        push_u16(&mut out, 0); // no file comment
11337        push_u16(&mut out, 0); // disk number
11338        push_u16(&mut out, 0); // internal attributes
11339        push_u32(&mut out, UNIX_REGULAR_0600);
11340        push_u32(&mut out, entry.local_offset);
11341        out.extend_from_slice(entry.name);
11342    }
11343    let central_size = u32::try_from(out.len())
11344        .ok()
11345        .and_then(|end| end.checked_sub(central_offset))
11346        .ok_or_else(|| LinkError::PushTooLarge {
11347            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11348        })?;
11349    let entry_count = central.len() as u16;
11350
11351    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11352    push_u16(&mut out, 0); // this disk
11353    push_u16(&mut out, 0); // central directory disk
11354    push_u16(&mut out, entry_count);
11355    push_u16(&mut out, entry_count);
11356    push_u32(&mut out, central_size);
11357    push_u32(&mut out, central_offset);
11358    push_u16(&mut out, 0); // no archive comment
11359
11360    if out.len() > u32::MAX as usize {
11361        return Err(LinkError::PushTooLarge {
11362            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11363        });
11364    }
11365    Ok(out)
11366}
11367
11368// ─────────────────────────────────────────────────────────────────────────────
11369// grant — issue / list / revoke capabilities (owner-side)
11370// ─────────────────────────────────────────────────────────────────────────────
11371
11372/// The two capabilities a v0 hub enforces.
11373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11374pub enum Capability {
11375    /// Read the granted slice.
11376    Read,
11377    /// Read and push (whole-store; a path-scoped grant is read-only).
11378    Write,
11379}
11380
11381impl Capability {
11382    /// The wire form.
11383    pub fn as_str(self) -> &'static str {
11384        match self {
11385            Capability::Read => "read",
11386            Capability::Write => "write",
11387        }
11388    }
11389}
11390
11391/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
11392/// by email in v0 (the protocol's near-term simplification; key-named
11393/// grantees arrive with the signing layer). `scope` is a store-path prefix
11394/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
11395/// revoked.
11396pub fn grant_issue(
11397    cfg: &HubConfig,
11398    brain: &str,
11399    grantee: &str,
11400    can: Capability,
11401    scope: Option<&str>,
11402    until: Option<&str>,
11403) -> LinkResult<Value> {
11404    require_safe_ref(brain)?;
11405    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
11406    // multikey holder (link.md §6 cross-party keys — no hub account; the
11407    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
11408    // hub principal named by email.
11409    let is_key_grantee = URL_SAFE_NO_PAD
11410        .decode(grantee)
11411        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11412        .unwrap_or(false);
11413    if let Some(head) = v2_verified_head(cfg, brain)? {
11414        if is_key_grantee {
11415            let scope = scope.unwrap_or("");
11416            let preset = match can {
11417                Capability::Read => "viewer",
11418                Capability::Write => "editor",
11419            };
11420            let entropy = format!(
11421                "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11422                normalized_origin(&cfg.hub)?,
11423                head.brain_id,
11424                head.control_revision,
11425                grantee,
11426                preset,
11427                scope,
11428                until.unwrap_or("")
11429            );
11430            let mut body = json!({
11431                "context": "external",
11432                "expected_control_revision": head.control_revision,
11433                "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11434                "preset": preset,
11435                "principal_kind": "key",
11436                "public_key": grantee,
11437                "scope": scope,
11438                "scope_kind": "prefix",
11439            });
11440            if let Some(value) = until {
11441                body["expires_at"] = json!(value);
11442            }
11443            let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11444            let response = ensure_ok(
11445                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11446                "v2 grant issue",
11447            )?;
11448            let expected_fingerprint = identity_fingerprint(grantee)?;
11449            if response.get("v").and_then(Value::as_u64) != Some(2)
11450                || response
11451                    .get("id")
11452                    .and_then(Value::as_str)
11453                    .is_none_or(|id| !crate::ulid::is_ulid(id))
11454                || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11455                || response.get("principal_id").and_then(Value::as_str)
11456                    != Some(expected_fingerprint.as_str())
11457                || response
11458                    .get("control_revision")
11459                    .and_then(Value::as_str)
11460                    .is_none_or(|value| !is_sha256(value))
11461            {
11462                return Err(invalid_feed(
11463                    "v2 grant issue response is not authority-bound",
11464                ));
11465            }
11466            return Ok(response);
11467        }
11468        // Account invitations remain a product workflow. The compatibility
11469        // endpoint resolves email/account identity, then commits a native v2
11470        // grant with the same control-plane recovery boundary. The v2 head
11471        // above is already the authenticated authority checkpoint; never
11472        // fall through to the retired v1 feed verifier for this request.
11473        let mut body = json!({ "email": grantee, "capability": can.as_str() });
11474        if let Some(value) = scope {
11475            body["scopePrefix"] = json!(value);
11476        }
11477        if let Some(value) = until {
11478            body["expiresAt"] = json!(value);
11479        }
11480        let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11481        return ensure_ok(
11482            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11483            "account grant issue",
11484        );
11485    }
11486    let _ = verified_remote_head(cfg, brain, false)?;
11487    let mut body = if is_key_grantee {
11488        json!({ "keySpki": grantee, "capability": can.as_str() })
11489    } else {
11490        json!({ "email": grantee, "capability": can.as_str() })
11491    };
11492    if let Some(s) = scope {
11493        body["scopePrefix"] = json!(s);
11494    }
11495    if let Some(u) = until {
11496        body["expiresAt"] = json!(u);
11497    }
11498    let path = format!("/api/hub/brains/{brain}/grants");
11499    ensure_ok(
11500        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11501        "grant issue",
11502    )
11503}
11504
11505/// List the active grants (and pending invites) on `brain`. Owner-side.
11506pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11507    require_safe_ref(brain)?;
11508    if let Some(head) = v2_verified_head(cfg, brain)? {
11509        let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11510        let response = ensure_ok(
11511            request(cfg, "GET", &path, None, Auth::Required)?,
11512            "v2 grant list",
11513        )?;
11514        if response.get("v").and_then(Value::as_u64) != Some(2)
11515            || response.get("control_revision").and_then(Value::as_str)
11516                != Some(head.control_revision.as_str())
11517            || !response.get("grants").is_some_and(Value::is_array)
11518        {
11519            return Err(invalid_feed(
11520                "v2 grant list is not bound to the verified authority",
11521            ));
11522        }
11523        return Ok(response);
11524    }
11525    let _ = verified_remote_head(cfg, brain, false)?;
11526    let path = format!("/api/hub/brains/{brain}/grants");
11527    ensure_ok(
11528        request(cfg, "GET", &path, None, Auth::Required)?,
11529        "grant list",
11530    )
11531}
11532
11533/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
11534/// is soft on the hub (the audit trail survives).
11535pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11536    require_safe_ref(brain)?;
11537    require_safe_grant_id(grant_id)?;
11538    if let Some(head) = v2_verified_head(cfg, brain)? {
11539        let entropy = format!(
11540            "{}\0{}\0{}\0{}",
11541            normalized_origin(&cfg.hub)?,
11542            head.brain_id,
11543            head.control_revision,
11544            grant_id
11545        );
11546        let body = json!({
11547            "expected_control_revision": head.control_revision,
11548            "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11549        });
11550        let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11551        let response = ensure_ok(
11552            request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11553            "v2 grant revoke",
11554        )?;
11555        if response.get("v").and_then(Value::as_u64) != Some(2)
11556            || response.get("id").and_then(Value::as_str) != Some(grant_id)
11557            || response.get("revoked").and_then(Value::as_bool) != Some(true)
11558            || response
11559                .get("control_revision")
11560                .and_then(Value::as_str)
11561                .is_none_or(|value| !is_sha256(value))
11562        {
11563            return Err(invalid_feed(
11564                "v2 grant revocation response is not authority-bound",
11565            ));
11566        }
11567        return Ok(response);
11568    }
11569    let _ = verified_remote_head(cfg, brain, false)?;
11570    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11571    ensure_ok(
11572        request(cfg, "DELETE", &path, None, Auth::Required)?,
11573        "grant revoke",
11574    )
11575}
11576
11577// ─────────────────────────────────────────────────────────────────────────────
11578// permissioned v2 proposals — inspect, accept, reject
11579// ─────────────────────────────────────────────────────────────────────────────
11580
11581#[derive(Debug)]
11582struct VerifiedV2Proposal {
11583    value: Value,
11584    changes: Value,
11585    blobs: Vec<(String, u64, String)>,
11586}
11587
11588fn require_proposal_id(id: &str) -> LinkResult<()> {
11589    if crate::ulid::is_ulid(id) {
11590        Ok(())
11591    } else {
11592        Err(invalid_feed("proposal id is not a lowercase ULID"))
11593    }
11594}
11595
11596fn verified_v2_proposal(
11597    cfg: &HubConfig,
11598    head: &V2VerifiedHead,
11599    proposal_id: &str,
11600) -> LinkResult<VerifiedV2Proposal> {
11601    require_proposal_id(proposal_id)?;
11602    if head.view_kind != "full" {
11603        return Err(invalid_feed(
11604            "proposal review requires a full readable view",
11605        ));
11606    }
11607    let path = format!(
11608        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11609        head.brain_id
11610    );
11611    let value = ensure_ok(
11612        request_capped(
11613            cfg,
11614            "GET",
11615            &path,
11616            None,
11617            Auth::Required,
11618            MAX_FEED_RESPONSE_BYTES,
11619        )?,
11620        "v2 proposal",
11621    )?;
11622    verify_v2_proposal_value(head, proposal_id, value)
11623}
11624
11625fn verify_v2_proposal_value(
11626    head: &V2VerifiedHead,
11627    proposal_id: &str,
11628    value: Value,
11629) -> LinkResult<VerifiedV2Proposal> {
11630    if value.get("v").and_then(Value::as_u64) != Some(2) {
11631        return Err(invalid_feed("proposal response has an invalid version"));
11632    }
11633    let proposal = value
11634        .get("proposal")
11635        .and_then(Value::as_object)
11636        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11637    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11638        return Err(invalid_feed("proposal response changed its id"));
11639    }
11640    let payload_hash = proposal
11641        .get("payload_sha256")
11642        .and_then(Value::as_str)
11643        .filter(|hash| is_sha256(hash))
11644        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11645    let clear_hash = proposal
11646        .get("clear_sha256")
11647        .and_then(Value::as_str)
11648        .filter(|hash| is_sha256(hash))
11649        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11650    let submission_hash = proposal
11651        .get("submission_claim_sha256")
11652        .and_then(Value::as_str)
11653        .filter(|hash| is_sha256(hash))
11654        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11655    let submission = STANDARD
11656        .decode(
11657            proposal
11658                .get("submission_claim_base64")
11659                .and_then(Value::as_str)
11660                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11661        )
11662        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11663    let submission_value: Value = serde_json::from_slice(&submission)
11664        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11665    if crate::linkmd_v2::canonical_bytes(&submission_value)
11666        .map_err(|error| invalid_feed(error.to_string()))?
11667        != submission
11668        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11669            .map_err(|error| invalid_feed(error.to_string()))?
11670            != submission_hash
11671    {
11672        return Err(invalid_feed(
11673            "proposal submission claim is not canonical or addressed",
11674        ));
11675    }
11676    let envelope = submission_value
11677        .as_object()
11678        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11679    let claim = envelope
11680        .get("claim")
11681        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11682    let claim_object = claim
11683        .as_object()
11684        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11685    let actor_root = claim_object
11686        .get("actor_root")
11687        .and_then(Value::as_object)
11688        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11689    let public_key = envelope
11690        .get("public_key")
11691        .and_then(Value::as_str)
11692        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11693    let fingerprint = envelope
11694        .get("fingerprint")
11695        .and_then(Value::as_str)
11696        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11697    let signature = envelope
11698        .get("sig")
11699        .and_then(Value::as_str)
11700        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11701    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11702        .map_err(|error| invalid_feed(error.to_string()))?;
11703    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11704    let signer = format!("{fingerprint}:{public_key}");
11705    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11706    let grants = actor_root.get("grants").and_then(Value::as_array);
11707    let grants_are_canonical = grants.is_some_and(|items| {
11708        let mut prior: Option<&str> = None;
11709        items.iter().all(|item| {
11710            let Some(grant) = item.as_str() else {
11711                return false;
11712            };
11713            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11714                return false;
11715            }
11716            prior = Some(grant);
11717            true
11718        })
11719    });
11720    let optional_actor_field = |name: &str| {
11721        actor_root.get(name).is_some_and(|value| {
11722            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11723        })
11724    };
11725    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11726    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11727        || format!("{:x}", Sha256::digest(&der)) != fingerprint
11728        || head
11729            .trust
11730            .hub_signer
11731            .as_ref()
11732            .is_some_and(|known| known != &signer)
11733        || !matches!(
11734            actor_class,
11735            Some(
11736                "user"
11737                    | "owned_agent"
11738                    | "foreign_key"
11739                    | "curation"
11740                    | "inbox"
11741                    | "restore"
11742                    | "migration"
11743                    | "operator_recovery"
11744            )
11745        )
11746        || actor_root
11747            .get("principal")
11748            .and_then(Value::as_str)
11749            .is_none_or(|value| value.is_empty())
11750        || actor_root
11751            .get("credential")
11752            .and_then(Value::as_str)
11753            .is_none_or(|value| value.is_empty())
11754        || !optional_actor_field("organization")
11755        || !optional_actor_field("role")
11756        || !grants_are_canonical
11757        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11758        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11759        || !claim_object
11760            .get("mutation_id")
11761            .and_then(Value::as_str)
11762            .is_some_and(|value| {
11763                !value.is_empty()
11764                    && value.len() <= 128
11765                    && value.chars().enumerate().all(|(index, char)| {
11766                        char.is_ascii_alphanumeric()
11767                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11768                    })
11769            })
11770        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11771        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11772        || !claim_object
11773            .get("control_revision")
11774            .and_then(Value::as_str)
11775            .is_some_and(is_sha256)
11776        || submitted_at.is_none_or(|value| {
11777            chrono::DateTime::parse_from_rfc3339(value).is_err()
11778                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11779        })
11780        || !proposal
11781            .get("state")
11782            .and_then(Value::as_str)
11783            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11784        || proposal
11785            .get("expires_at")
11786            .and_then(Value::as_str)
11787            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11788        || proposal
11789            .get("proposer")
11790            .and_then(Value::as_object)
11791            .and_then(|value| value.get("class"))
11792            .and_then(Value::as_str)
11793            != actor_class
11794    {
11795        return Err(invalid_feed(
11796            "proposal submission claim does not bind the verified proposal",
11797        ));
11798    }
11799    let changes_b64 = proposal
11800        .get("changes_base64")
11801        .and_then(Value::as_str)
11802        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11803    let changes_bytes = STANDARD
11804        .decode(changes_b64)
11805        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11806    let changes: Value = serde_json::from_slice(&changes_bytes)
11807        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11808    if crate::linkmd_v2::canonical_bytes(&changes)
11809        .map_err(|error| invalid_feed(error.to_string()))?
11810        != changes_bytes
11811        || changes.get("v").and_then(Value::as_u64) != Some(2)
11812        || !changes.get("operations").is_some_and(Value::is_array)
11813    {
11814        return Err(invalid_feed("proposal changeset is not canonical v2"));
11815    }
11816    let blob_values = proposal
11817        .get("blobs")
11818        .and_then(Value::as_array)
11819        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11820    let mut blobs = Vec::with_capacity(blob_values.len());
11821    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11822    let mut prior_hash: Option<String> = None;
11823    for item in blob_values {
11824        let hash = item
11825            .get("sha256")
11826            .and_then(Value::as_str)
11827            .filter(|hash| is_sha256(hash))
11828            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11829        let bytes = item
11830            .get("bytes")
11831            .and_then(Value::as_u64)
11832            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11833            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11834        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11835            return Err(invalid_feed(
11836                "proposal blob declarations are not unique and sorted",
11837            ));
11838        }
11839        prior_hash = Some(hash.to_string());
11840        let endpoint = item
11841            .get("endpoint")
11842            .and_then(Value::as_str)
11843            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11844        let expected_endpoint = format!(
11845            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11846            head.brain_id
11847        );
11848        if endpoint != expected_endpoint {
11849            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11850        }
11851        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11852        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11853    }
11854    let descriptor = json!({
11855        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11856        "blobs": descriptor_blobs,
11857        "changes_base64": changes_b64,
11858        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11859        "v": 2,
11860    });
11861    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11862        .map_err(|error| invalid_feed(error.to_string()))?;
11863    if content_sha256(&descriptor_bytes) != clear_hash {
11864        return Err(invalid_feed(
11865            "proposal clear payload differs from its signed submission claim",
11866        ));
11867    }
11868    Ok(VerifiedV2Proposal {
11869        value,
11870        changes,
11871        blobs,
11872    })
11873}
11874
11875pub fn proposal_list(
11876    cfg: &HubConfig,
11877    brain: &str,
11878    state: &str,
11879    after: Option<&str>,
11880    limit: usize,
11881) -> LinkResult<Value> {
11882    require_safe_ref(brain)?;
11883    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11884        return Err(invalid_feed("proposal state is invalid"));
11885    }
11886    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11887        return Err(invalid_feed("proposal cursor is invalid"));
11888    }
11889    let head = v2_verified_head(cfg, brain)?
11890        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11891    let path = format!(
11892        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11893        head.brain_id,
11894        limit.clamp(1, 100),
11895        after.map_or_else(String::new, |value| format!("&after={value}"))
11896    );
11897    ensure_ok(
11898        request_capped(
11899            cfg,
11900            "GET",
11901            &path,
11902            None,
11903            Auth::Required,
11904            MAX_FEED_RESPONSE_BYTES,
11905        )?,
11906        "v2 proposal list",
11907    )
11908}
11909
11910pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11911    require_safe_ref(brain)?;
11912    let head = v2_verified_head(cfg, brain)?
11913        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11914    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11915}
11916
11917pub fn proposal_reject(
11918    cfg: &HubConfig,
11919    brain: &str,
11920    proposal_id: &str,
11921    mutation_id: &str,
11922    reason: &str,
11923) -> LinkResult<Value> {
11924    require_safe_ref(brain)?;
11925    require_proposal_id(proposal_id)?;
11926    let head = v2_verified_head(cfg, brain)?
11927        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11928    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11929    let body = json!({
11930        "mutation_id": mutation_id,
11931        "control_revision": head.control_revision,
11932        "reason": reason,
11933    });
11934    let path = format!(
11935        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11936        head.brain_id
11937    );
11938    ensure_ok(
11939        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11940        "v2 proposal rejection",
11941    )
11942}
11943
11944pub fn proposal_accept_exact(
11945    cfg: &HubConfig,
11946    brain: &str,
11947    proposal_id: &str,
11948    mutation_id: &str,
11949    reason: &str,
11950) -> LinkResult<Value> {
11951    require_safe_ref(brain)?;
11952    require_proposal_id(proposal_id)?;
11953    let head = v2_verified_head(cfg, brain)?
11954        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11955    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11956    let operations = proposal
11957        .changes
11958        .get("operations")
11959        .and_then(Value::as_array)
11960        .cloned()
11961        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11962    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11963        return Err(invalid_feed("proposal operation count is invalid"));
11964    }
11965    let mut downloaded = std::collections::BTreeMap::new();
11966    for (hash, bytes, endpoint) in &proposal.blobs {
11967        let body = ensure_raw_ok(
11968            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11969            "v2 proposal blob",
11970        )?;
11971        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11972            return Err(invalid_feed("proposal blob does not match its declaration"));
11973        }
11974        downloaded.insert(hash.clone(), body);
11975    }
11976    let remote = files_for_v2_view(
11977        &head,
11978        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
11979    );
11980    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
11981    let mut expected_candidate = remote.clone();
11982    let mut expected_candidate_assets = remote_assets;
11983    for operation in &operations {
11984        let op = operation
11985            .get("op")
11986            .and_then(Value::as_str)
11987            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
11988        match op {
11989            "put" | "restore" => {
11990                let path = operation
11991                    .get("path")
11992                    .and_then(Value::as_str)
11993                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
11994                crate::linkmd_v2::normalize_path(path)
11995                    .map_err(|error| invalid_feed(error.to_string()))?;
11996                let hash = operation
11997                    .get("blob")
11998                    .and_then(Value::as_str)
11999                    .filter(|hash| is_sha256(hash))
12000                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12001                let bytes = operation
12002                    .get("bytes")
12003                    .and_then(Value::as_u64)
12004                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12005                expected_candidate.insert(
12006                    path.to_string(),
12007                    V2BaselineFile {
12008                        sha256: hash.to_string(),
12009                        bytes,
12010                        proof: None,
12011                    },
12012                );
12013            }
12014            "delete" | "withdraw_from_hosting" => {
12015                let path = operation
12016                    .get("path")
12017                    .and_then(Value::as_str)
12018                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12019                crate::linkmd_v2::normalize_path(path)
12020                    .map_err(|error| invalid_feed(error.to_string()))?;
12021                expected_candidate.remove(path);
12022            }
12023            "rename" => {
12024                let from = operation
12025                    .get("from")
12026                    .and_then(Value::as_str)
12027                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12028                let to = operation
12029                    .get("to")
12030                    .and_then(Value::as_str)
12031                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12032                crate::linkmd_v2::normalize_path(from)
12033                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
12034                    .map_err(|error| invalid_feed(error.to_string()))?;
12035                let hash = operation
12036                    .get("blob")
12037                    .and_then(Value::as_str)
12038                    .filter(|hash| is_sha256(hash))
12039                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12040                let bytes = operation
12041                    .get("bytes")
12042                    .and_then(Value::as_u64)
12043                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12044                expected_candidate.remove(from);
12045                expected_candidate.insert(
12046                    to.to_string(),
12047                    V2BaselineFile {
12048                        sha256: hash.to_string(),
12049                        bytes,
12050                        proof: None,
12051                    },
12052                );
12053            }
12054            "asset_delete" => {
12055                let path = operation
12056                    .get("path")
12057                    .and_then(Value::as_str)
12058                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12059                expected_candidate_assets.remove(path);
12060            }
12061            "asset_withdraw" => {
12062                let path = operation
12063                    .get("path")
12064                    .and_then(Value::as_str)
12065                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12066                let asset = expected_candidate_assets
12067                    .get_mut(path)
12068                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
12069                asset.disposition = "withheld".to_string();
12070                asset.leaf_hash.clear();
12071            }
12072            "asset_put" | "asset_resume" => {
12073                let path = operation
12074                    .get("path")
12075                    .and_then(Value::as_str)
12076                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12077                let asset = operation
12078                    .get("asset")
12079                    .and_then(Value::as_object)
12080                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12081                let blob_sha256 = asset
12082                    .get("blob_sha256")
12083                    .and_then(Value::as_str)
12084                    .filter(|hash| is_sha256(hash))
12085                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12086                let bytes = asset
12087                    .get("bytes")
12088                    .and_then(Value::as_u64)
12089                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12090                let media_type = asset
12091                    .get("media_type")
12092                    .and_then(Value::as_str)
12093                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12094                let wrappers = asset
12095                    .get("wrappers")
12096                    .and_then(Value::as_array)
12097                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12098                    .iter()
12099                    .map(|wrapper| {
12100                        wrapper
12101                            .as_str()
12102                            .map(str::to_string)
12103                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12104                    })
12105                    .collect::<LinkResult<Vec<_>>>()?;
12106                let required = asset
12107                    .get("required")
12108                    .and_then(Value::as_bool)
12109                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12110                let disposition = asset
12111                    .get("disposition")
12112                    .and_then(Value::as_str)
12113                    .filter(|value| matches!(*value, "hosted" | "withheld"))
12114                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
12115                expected_candidate_assets.insert(
12116                    path.to_string(),
12117                    V2BaselineAsset {
12118                        blob_sha256: blob_sha256.to_string(),
12119                        bytes,
12120                        media_type: media_type.to_string(),
12121                        wrappers,
12122                        required,
12123                        disposition: disposition.to_string(),
12124                        leaf_hash: String::new(),
12125                    },
12126                );
12127            }
12128            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
12129        }
12130    }
12131    let base = head.pointer.as_ref().map(|pointer| {
12132        json!({
12133            "seq": pointer.seq,
12134            "commit_hash": pointer.commit_hash,
12135            "content_root": pointer.content_root,
12136            "asset_root": pointer.asset_root,
12137        })
12138    });
12139    let mut body = json!({
12140        "mutation_id": mutation_id,
12141        "base": base,
12142        "rebase": "strict",
12143        "reason": reason,
12144        "operations": operations,
12145        "blobs": downloaded
12146            .iter()
12147            .map(|(sha256, bytes)| json!({
12148                "sha256": sha256,
12149                "bytes": bytes.len(),
12150                "content_base64": STANDARD.encode(bytes),
12151            }))
12152            .collect::<Vec<_>>(),
12153        "proposal_id": proposal_id,
12154        "proposal_mode": "exact",
12155    });
12156    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
12157        total
12158            .checked_add(bytes.len())
12159            .ok_or_else(|| LinkError::PushTooLarge {
12160                detail: "proposal changed-byte total overflow".to_string(),
12161            })
12162    })?;
12163    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
12164        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12165        for operation in &operations {
12166            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
12167                return Err(invalid_feed("proposal upload operation has no kind"));
12168            };
12169            let hash = match kind {
12170                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
12171                "asset_put" | "asset_resume" => operation
12172                    .get("asset")
12173                    .and_then(|asset| asset.get("blob_sha256"))
12174                    .and_then(Value::as_str),
12175                _ => None,
12176            };
12177            let Some(hash) = hash else { continue };
12178            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
12179            if kind == "rename" {
12180                for field in ["from", "to"] {
12181                    coordinates.insert(
12182                        operation
12183                            .get(field)
12184                            .and_then(Value::as_str)
12185                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
12186                            .to_string(),
12187                    );
12188                }
12189            } else {
12190                let path = operation
12191                    .get("path")
12192                    .and_then(Value::as_str)
12193                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
12194                coordinates.insert(if kind.starts_with("asset_") {
12195                    format!("assets/{path}")
12196                } else {
12197                    path.to_string()
12198                });
12199            }
12200        }
12201        let declarations = downloaded
12202            .iter()
12203            .map(|(sha256, bytes)| {
12204                json!({
12205                    "sha256": sha256,
12206                    "bytes": bytes.len(),
12207                    "coordinates": coordinates_by_hash
12208                        .get(sha256)
12209                        .into_iter()
12210                        .flatten()
12211                        .collect::<Vec<_>>(),
12212                })
12213            })
12214            .collect::<Vec<_>>();
12215        let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
12216        for batch in batch_upload_declarations(declarations) {
12217            let reserved = reserve_upload_window(
12218                cfg,
12219                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
12220                &json!({ "blobs": batch }),
12221                "prepare proposal blob transport",
12222            )?;
12223            let reserved_items = reserved
12224                .get("uploads")
12225                .and_then(Value::as_array)
12226                .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
12227            items.extend(reserved_items.iter().cloned());
12228        }
12229        if items.len() != downloaded.len() {
12230            return Err(invalid_feed("proposal upload reservation changed the set"));
12231        }
12232        let mut references = Vec::with_capacity(items.len());
12233        for item in items {
12234            let hash = item
12235                .get("sha256")
12236                .and_then(Value::as_str)
12237                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
12238            let bytes = downloaded
12239                .get(hash)
12240                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
12241            let reservation_id = item
12242                .get("reservation_id")
12243                .and_then(Value::as_str)
12244                .filter(|id| crate::ulid::is_ulid(id))
12245                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
12246            let expected_coordinates = coordinates_by_hash
12247                .get(hash)
12248                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
12249            let returned_coordinates = item
12250                .get("coordinates")
12251                .and_then(Value::as_array)
12252                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
12253            if returned_coordinates.len() != expected_coordinates.len()
12254                || returned_coordinates
12255                    .iter()
12256                    .zip(expected_coordinates)
12257                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
12258            {
12259                return Err(invalid_feed(
12260                    "proposal upload reservation changed its coordinates",
12261                ));
12262            }
12263            match item.get("status").and_then(Value::as_str) {
12264                Some("upload") => put_presigned(
12265                    cfg,
12266                    item.get("url")
12267                        .and_then(Value::as_str)
12268                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
12269                    item.get("headers").unwrap_or(&Value::Null),
12270                    bytes,
12271                )?,
12272                Some("already_present") => {}
12273                _ => return Err(invalid_feed("proposal upload status is invalid")),
12274            }
12275            references.push(json!({
12276                "sha256": hash,
12277                "bytes": bytes.len(),
12278                "reservation_id": reservation_id,
12279            }));
12280        }
12281        body["blobs"] = Value::Array(references);
12282    }
12283    // A proposal can be queued through the staged lane, so acceptance has to be
12284    // able to restate it. Capping only this side would make a large proposal
12285    // permanently unacceptable.
12286    stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
12287    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
12288    let mut result = ensure_ok(
12289        request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
12290        "exact proposal acceptance",
12291    )?;
12292    let mut candidate_hub_signer = None;
12293    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12294        let request_id = result
12295            .get("request_id")
12296            .and_then(Value::as_str)
12297            .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12298            .to_string();
12299        let challenge = result
12300            .get("signing_challenge")
12301            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12302        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12303            cfg,
12304            &head,
12305            &expected_candidate,
12306            &expected_candidate_assets,
12307            mutation_id,
12308            &v2_signed_request_view(&body, &operations),
12309            challenge,
12310        )?;
12311        body["signing_challenge_id"] = Value::String(challenge_id);
12312        body["signature_base64url"] = Value::String(signature);
12313        candidate_hub_signer = Some(actor_signer);
12314        result = ensure_ok(
12315            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12316            "signed exact proposal acceptance",
12317        )?;
12318    }
12319    let refreshed = v2_verified_head(cfg, brain)?
12320        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12321    if candidate_hub_signer
12322        .as_ref()
12323        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12324        || refreshed
12325            .pointer
12326            .as_ref()
12327            .map(|pointer| pointer.commit_hash.as_str())
12328            != result.get("commit_hash").and_then(Value::as_str)
12329        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12330        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12331    {
12332        return Err(LinkError::RemoteAdvancedDuringSync);
12333    }
12334    accept_v2_head(cfg, &refreshed)?;
12335    Ok(result)
12336}
12337
12338// ─────────────────────────────────────────────────────────────────────────────
12339// propose — write without trust: evidence into the owner's inbox
12340// ─────────────────────────────────────────────────────────────────────────────
12341
12342/// Submit `body` to the published site `handle`, addressed to its app page
12343/// `app` (a page that declares the `write-inbox` capability). Deliberately
12344/// unauthenticated — this is the cross-party door; the submission lands as
12345/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
12346/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
12347/// receipt.
12348pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12349    require_valid_handle(handle)?;
12350    if body.len() as u64 > MAX_PROPOSE_BYTES {
12351        return Err(LinkError::ProposeTooLarge {
12352            bytes: body.len() as u64,
12353        });
12354    }
12355    let payload = json!({ "app": app, "body": body });
12356    // A ULID-shaped target is a bare brain address (link.md §7.4's
12357    // generalization): the brain inbox door, open on public brains, where a
12358    // configured credential earns a bigger actor-class budget. Anything else
12359    // is a published-site handle: that door is unauthenticated by design.
12360    let (path, auth) = if crate::ulid::is_ulid(handle) {
12361        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12362    } else {
12363        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12364    };
12365    ensure_ok(
12366        request(cfg, "POST", &path, Some(&payload), auth)?,
12367        "propose",
12368    )
12369}
12370
12371// ─────────────────────────────────────────────────────────────────────────────
12372// subscribe — follow feed-head movement
12373// ─────────────────────────────────────────────────────────────────────────────
12374
12375/// One observation of a brain's feed head.
12376#[derive(Debug, serde::Serialize)]
12377pub struct Head {
12378    /// The brain id.
12379    pub brain: String,
12380    /// The hub's durable feed cursor — advances on every accepted write.
12381    pub seq: u64,
12382    /// The hub's `updatedAt` for the brain, when present.
12383    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12384    pub updated_at: Option<String>,
12385    /// SHA-256 of the exact signed head entry.
12386    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12387    pub feed_hash: Option<String>,
12388    /// Whether the head entry's content hash, identity, and Ed25519 signature
12389    /// were verified locally. Path-scoped grants get head movement only.
12390    pub verified: bool,
12391}
12392
12393struct BoundedVecVisitor<T, const MAX: usize> {
12394    label: &'static str,
12395    marker: std::marker::PhantomData<T>,
12396}
12397
12398impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12399where
12400    T: Deserialize<'de>,
12401{
12402    type Value = Vec<T>;
12403
12404    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12405        write!(formatter, "at most {MAX} {}", self.label)
12406    }
12407
12408    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12409    where
12410        A: serde::de::SeqAccess<'de>,
12411    {
12412        if sequence.size_hint().is_some_and(|size| size > MAX) {
12413            return Err(serde::de::Error::custom(format!(
12414                "{} exceeds the {MAX}-item limit",
12415                self.label
12416            )));
12417        }
12418        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12419        while let Some(value) = sequence.next_element()? {
12420            if values.len() == MAX {
12421                return Err(serde::de::Error::custom(format!(
12422                    "{} exceeds the {MAX}-item limit",
12423                    self.label
12424                )));
12425            }
12426            values.push(value);
12427        }
12428        Ok(values)
12429    }
12430}
12431
12432fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12433    deserializer: D,
12434    label: &'static str,
12435) -> Result<Vec<T>, D::Error>
12436where
12437    D: serde::Deserializer<'de>,
12438    T: Deserialize<'de>,
12439{
12440    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12441        label,
12442        marker: std::marker::PhantomData,
12443    })
12444}
12445
12446fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12447where
12448    D: serde::Deserializer<'de>,
12449{
12450    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12451}
12452
12453fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12454where
12455    D: serde::Deserializer<'de>,
12456{
12457    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12458}
12459
12460fn deserialize_previous_identities<'de, D>(
12461    deserializer: D,
12462) -> Result<Vec<PreviousIdentity>, D::Error>
12463where
12464    D: serde::Deserializer<'de>,
12465{
12466    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12467        deserializer,
12468        "previous identities",
12469    )
12470}
12471
12472fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12473where
12474    D: serde::Deserializer<'de>,
12475{
12476    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12477        deserializer,
12478        "rotation statements",
12479    )
12480}
12481
12482fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12483where
12484    D: serde::Deserializer<'de>,
12485{
12486    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12487}
12488
12489#[derive(Debug, Clone, Deserialize, Serialize)]
12490struct FeedFile {
12491    path: String,
12492    sha256: String,
12493    bytes: u64,
12494}
12495
12496#[cfg(test)]
12497#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12498enum V1DisclosureError {
12499    DuplicateFile,
12500    DuplicateRemoved,
12501    PushManifestMismatch,
12502    EditMissingChange,
12503    EditFalseFile,
12504    RemovedMismatch,
12505}
12506
12507/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
12508/// the exact previous and resulting pack manifests. `edit` accepts the
12509/// historical superset form while still requiring every actual changed path.
12510#[cfg(test)]
12511fn verify_v1_manifest_disclosure(
12512    kind: &str,
12513    previous: &[FeedFile],
12514    resulting: &[FeedFile],
12515    files: &[FeedFile],
12516    removed: &[String],
12517) -> Result<(), V1DisclosureError> {
12518    fn as_map(
12519        files: &[FeedFile],
12520    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12521        let mut result = std::collections::BTreeMap::new();
12522        for file in files {
12523            if result
12524                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12525                .is_some()
12526            {
12527                return Err(V1DisclosureError::DuplicateFile);
12528            }
12529        }
12530        Ok(result)
12531    }
12532    let previous = as_map(previous)?;
12533    let resulting = as_map(resulting)?;
12534    let disclosed = as_map(files)?;
12535    let removed_set: std::collections::BTreeSet<&str> =
12536        removed.iter().map(String::as_str).collect();
12537    if removed_set.len() != removed.len() {
12538        return Err(V1DisclosureError::DuplicateRemoved);
12539    }
12540    let expected_removed: std::collections::BTreeSet<&str> = previous
12541        .keys()
12542        .copied()
12543        .filter(|path| !resulting.contains_key(path))
12544        .collect();
12545    if removed_set != expected_removed {
12546        return Err(V1DisclosureError::RemovedMismatch);
12547    }
12548    if kind == "push" {
12549        return if disclosed == resulting {
12550            Ok(())
12551        } else {
12552            Err(V1DisclosureError::PushManifestMismatch)
12553        };
12554    }
12555    if kind != "edit" {
12556        return Err(V1DisclosureError::EditFalseFile);
12557    }
12558    if disclosed
12559        .iter()
12560        .any(|(path, value)| resulting.get(path) != Some(value))
12561    {
12562        return Err(V1DisclosureError::EditFalseFile);
12563    }
12564    for (path, value) in &resulting {
12565        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12566            return Err(V1DisclosureError::EditMissingChange);
12567        }
12568    }
12569    Ok(())
12570}
12571
12572#[derive(Debug, Clone, Deserialize, Serialize)]
12573struct FeedEntry {
12574    v: u8,
12575    seq: u64,
12576    ts: String,
12577    brain: String,
12578    public_key: String,
12579    kind: String,
12580    op: String,
12581    pack_sha256: String,
12582    #[serde(deserialize_with = "deserialize_feed_files")]
12583    files: Vec<FeedFile>,
12584    #[serde(deserialize_with = "deserialize_removed_paths")]
12585    removed: Vec<String>,
12586    prev_entry_hash: Option<String>,
12587    sig: String,
12588}
12589
12590#[derive(Serialize)]
12591struct UnsignedFeedEntry<'a> {
12592    v: u8,
12593    seq: u64,
12594    ts: &'a str,
12595    brain: &'a str,
12596    public_key: &'a str,
12597    kind: &'a str,
12598    op: &'a str,
12599    pack_sha256: &'a str,
12600    files: &'a [FeedFile],
12601    removed: &'a [String],
12602    prev_entry_hash: &'a Option<String>,
12603}
12604
12605#[derive(Debug, Clone, Deserialize, Serialize)]
12606struct FeedItem {
12607    hash: String,
12608    entry: FeedEntry,
12609}
12610
12611#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12612struct FeedIdentity {
12613    fingerprint: String,
12614    #[serde(rename = "publicKeySpki")]
12615    public_key_spki: String,
12616    /// Rotation history (link.md §9.1): identities this brain previously
12617    /// signed as. Entries verify against current OR previous — rotation
12618    /// never invalidates history.
12619    #[serde(default, deserialize_with = "deserialize_previous_identities")]
12620    previous: Vec<PreviousIdentity>,
12621    /// Exact normative rotation statements, oldest first. A list of previous
12622    /// public keys without these old-key signatures is not a trust chain.
12623    #[serde(default, deserialize_with = "deserialize_rotations")]
12624    rotations: Vec<String>,
12625}
12626
12627#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12628struct PreviousIdentity {
12629    fingerprint: String,
12630    #[serde(rename = "publicKeySpki")]
12631    public_key_spki: String,
12632}
12633
12634#[derive(Debug, Deserialize)]
12635struct FeedResponse {
12636    #[serde(rename = "headSeq")]
12637    head_seq: u64,
12638    #[serde(rename = "feedHash")]
12639    feed_hash: Option<String>,
12640    identity: Option<FeedIdentity>,
12641    #[serde(deserialize_with = "deserialize_feed_items")]
12642    entries: Vec<FeedItem>,
12643    #[serde(rename = "scopeLimited")]
12644    scope_limited: bool,
12645}
12646
12647#[derive(Debug, Deserialize, Serialize)]
12648#[serde(deny_unknown_fields)]
12649struct RotationStatement {
12650    v: u8,
12651    op: String,
12652    brain: String,
12653    public_key: String,
12654    new_brain: String,
12655    new_public_key: String,
12656    prior_head_seq: u64,
12657    prior_feed_hash: Option<String>,
12658    ts: String,
12659    sig: String,
12660}
12661
12662#[derive(Debug, Clone, Deserialize, Serialize)]
12663struct TrustState {
12664    v: u8,
12665    origin: String,
12666    /// The exact caller-visible ref whose resolution this checkpoint pins.
12667    /// Slugs/handles are mutable names at the hub; once observed, they may not
12668    /// silently resolve to a different canonical brain.
12669    #[serde(default)]
12670    requested: String,
12671    /// Canonical hub brain id returned for `requested`.
12672    brain: String,
12673    /// Federation home origin when this ref was learned from a registry.
12674    /// Once observed, the registry cannot silently relocate the same handle.
12675    #[serde(default, skip_serializing_if = "Option::is_none")]
12676    home: Option<String>,
12677    anchor: String,
12678    current: String,
12679    #[serde(rename = "headSeq")]
12680    head_seq: u64,
12681    #[serde(rename = "feedHash")]
12682    feed_hash: Option<String>,
12683    /// Exact accepted old-key-signed rotation statements, oldest first. v2
12684    /// checkpoints require this vector to be an immutable prefix of every
12685    /// subsequently served identity chain.
12686    #[serde(default)]
12687    rotations: Vec<String>,
12688    /// Hub pointer-signing identity pinned on first v2 observation. This is
12689    /// separate from the brain key, which signs commits.
12690    #[serde(default, skip_serializing_if = "Option::is_none")]
12691    hub_signer: Option<String>,
12692    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
12693    /// are recognized by their pinned hub signer during one-way migration.
12694    #[serde(default, skip_serializing_if = "Option::is_none")]
12695    protocol_profile: Option<String>,
12696}
12697
12698fn accepted_as_v2(state: &TrustState) -> bool {
12699    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12700}
12701
12702fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12703    let directory = open_trust_dir(cfg)?;
12704    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12705        return Ok(true);
12706    }
12707    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12708        return Ok(false);
12709    };
12710    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12711}
12712
12713#[derive(Debug, Clone, Deserialize, Serialize)]
12714struct AliasBinding {
12715    v: u8,
12716    origin: String,
12717    requested: String,
12718    brain: String,
12719    #[serde(default, skip_serializing_if = "Option::is_none")]
12720    home: Option<String>,
12721}
12722
12723struct VerifiedRemote {
12724    head: Head,
12725    identity: Option<FeedIdentity>,
12726    head_entry: Option<FeedItem>,
12727    /// Present when the caller requested and verified the complete chain.
12728    entries: Vec<FeedItem>,
12729    anchor: Option<String>,
12730}
12731
12732fn invalid_feed(message: impl Into<String>) -> LinkError {
12733    LinkError::InvalidFeed {
12734        message: message.into(),
12735    }
12736}
12737
12738fn is_sha256(value: &str) -> bool {
12739    value.len() == 64
12740        && value
12741            .bytes()
12742            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12743}
12744
12745fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12746    let der = URL_SAFE_NO_PAD
12747        .decode(public_key_spki)
12748        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12749    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12750        return Err(invalid_feed(
12751            "identity public key is not a valid Ed25519 SPKI",
12752        ));
12753    }
12754    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12755}
12756
12757/// Verify the hub's current identity and every old-key-signed rotation that
12758/// leads from the original TOFU anchor to it. `previous` alone is metadata,
12759/// never authority.
12760fn verify_identity_chain(
12761    identity: &FeedIdentity,
12762    pinned: Option<&TrustState>,
12763) -> LinkResult<String> {
12764    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12765        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12766    {
12767        return Err(invalid_feed(
12768            "identity rotation history exceeds the client cap",
12769        ));
12770    }
12771    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12772        return Err(invalid_feed(
12773            "current identity fingerprint does not match its public key",
12774        ));
12775    }
12776    for previous in &identity.previous {
12777        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12778            return Err(invalid_feed(
12779                "previous identity fingerprint does not match its public key",
12780            ));
12781        }
12782    }
12783    if identity.rotations.len() != identity.previous.len() {
12784        return Err(invalid_feed(
12785            "identity history is missing an old-key-signed rotation statement",
12786        ));
12787    }
12788
12789    // The HTTP identity lists prior identities newest first. Rotation
12790    // statements are chronological, so verification walks the reversed list
12791    // and ends at the current identity.
12792    let mut chain: Vec<(&str, &str)> = identity
12793        .previous
12794        .iter()
12795        .rev()
12796        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12797        .collect();
12798    chain.push((&identity.fingerprint, &identity.public_key_spki));
12799
12800    for (index, raw) in identity.rotations.iter().enumerate() {
12801        let statement: RotationStatement = serde_json::from_str(raw)
12802            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12803        let (old_fingerprint, old_spki) = chain[index];
12804        let (new_fingerprint, new_spki) = chain[index + 1];
12805        if statement.v != 1
12806            || statement.op != "rotate"
12807            || statement.brain != format!("ed25519:{old_fingerprint}")
12808            || statement.public_key != old_spki
12809            || statement.new_brain != format!("ed25519:{new_fingerprint}")
12810            || statement.new_public_key != new_spki
12811            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12812            || (statement.prior_head_seq > 0
12813                && statement
12814                    .prior_feed_hash
12815                    .as_deref()
12816                    .is_none_or(|hash| !is_sha256(hash)))
12817        {
12818            return Err(invalid_feed(
12819                "rotation statement does not connect adjacent identities",
12820            ));
12821        }
12822        let unsigned = serde_json::to_string(&UnsignedRotation {
12823            v: statement.v,
12824            op: &statement.op,
12825            brain: &statement.brain,
12826            public_key: &statement.public_key,
12827            new_brain: &statement.new_brain,
12828            new_public_key: &statement.new_public_key,
12829            prior_head_seq: statement.prior_head_seq,
12830            prior_feed_hash: statement.prior_feed_hash.as_deref(),
12831            ts: statement.ts.clone(),
12832        })
12833        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12834        let exact = format!(
12835            "{},\"sig\":\"{}\"}}",
12836            &unsigned[..unsigned.len() - 1],
12837            statement.sig
12838        );
12839        if exact != *raw {
12840            return Err(invalid_feed(
12841                "rotation statement is not in normative serialization",
12842            ));
12843        }
12844        let der = URL_SAFE_NO_PAD
12845            .decode(old_spki)
12846            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12847        let signature = URL_SAFE_NO_PAD
12848            .decode(&statement.sig)
12849            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12850        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12851            .verify(unsigned.as_bytes(), &signature)
12852            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12853        if index > 0 {
12854            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12855                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12856            if statement.prior_head_seq < prior.prior_head_seq {
12857                return Err(invalid_feed("rotation feed boundaries move backward"));
12858            }
12859        }
12860    }
12861
12862    let anchor = format!("ed25519:{}", chain[0].0);
12863    let current = format!("ed25519:{}", identity.fingerprint);
12864    if let Some(pin) = pinned {
12865        if pin.anchor != anchor {
12866            return Err(invalid_feed(
12867                "served identity chain does not descend from the pinned anchor",
12868            ));
12869        }
12870        if !chain
12871            .iter()
12872            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12873        {
12874            return Err(invalid_feed(
12875                "served identity chain forked away from the last pinned identity",
12876            ));
12877        }
12878        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12879            return Err(invalid_feed("served identity discarded its rotation chain"));
12880        }
12881        if pin.v >= 2
12882            && (identity.rotations.len() < pin.rotations.len()
12883                || identity.rotations[..pin.rotations.len()] != pin.rotations)
12884        {
12885            return Err(invalid_feed(
12886                "served identity rewrote the locally accepted rotation history",
12887            ));
12888        }
12889    }
12890    Ok(anchor)
12891}
12892
12893fn verify_rotation_feed_boundaries(
12894    identity: &FeedIdentity,
12895    pinned: Option<&TrustState>,
12896    observed: &[FeedItem],
12897    advertised_seq: u64,
12898) -> LinkResult<()> {
12899    let mut chain: Vec<String> = identity
12900        .previous
12901        .iter()
12902        .rev()
12903        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12904        .collect();
12905    chain.push(format!("ed25519:{}", identity.fingerprint));
12906    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12907
12908    for (index, raw) in identity.rotations.iter().enumerate() {
12909        let rotation: RotationStatement = serde_json::from_str(raw)
12910            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12911        if rotation.prior_head_seq > advertised_seq {
12912            return Err(invalid_feed(
12913                "rotation claims a feed boundary beyond the advertised head",
12914            ));
12915        }
12916        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12917            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12918                return Err(invalid_feed(
12919                    "newly disclosed rotation predates the local feed checkpoint",
12920                ));
12921            }
12922        }
12923        let actual = if rotation.prior_head_seq == 0 {
12924            None
12925        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12926            pinned.and_then(|pin| pin.feed_hash.as_deref())
12927        } else {
12928            observed
12929                .iter()
12930                .find(|item| item.entry.seq == rotation.prior_head_seq)
12931                .map(|item| item.hash.as_str())
12932        };
12933        if let Some(actual) = actual {
12934            if rotation.prior_feed_hash.as_deref() != Some(actual) {
12935                return Err(invalid_feed(
12936                    "rotation statement does not commit the verified feed boundary",
12937                ));
12938            }
12939        } else if rotation.prior_head_seq == 0 {
12940            // The empty-feed boundary is represented by (0, null); there is
12941            // no entry hash to find in `observed`.
12942        } else if pinned.is_some_and(|pin| {
12943            pinned_index.is_some_and(|pin_index| index >= pin_index)
12944                || rotation.prior_head_seq >= pin.head_seq
12945        }) {
12946            return Err(invalid_feed(
12947                "rotation feed boundary was not present in the verified chain",
12948            ));
12949        }
12950    }
12951    Ok(())
12952}
12953
12954/// Once a client has checkpointed identity K, identities retired before K may
12955/// verify old history but can never regain authority over a later sequence.
12956/// This is also the safe migration rule for legacy v1 checkpoints that did not
12957/// persist the exact accepted rotation statements.
12958fn reject_retired_signer_after_checkpoint(
12959    identity: &FeedIdentity,
12960    pinned: Option<&TrustState>,
12961    item: &FeedItem,
12962) -> LinkResult<()> {
12963    let Some(pin) = pinned else {
12964        return Ok(());
12965    };
12966    if item.entry.seq <= pin.head_seq {
12967        return Ok(());
12968    }
12969    let mut chain: Vec<String> = identity
12970        .previous
12971        .iter()
12972        .rev()
12973        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12974        .collect();
12975    chain.push(format!("ed25519:{}", identity.fingerprint));
12976    let pinned_index = chain
12977        .iter()
12978        .position(|key| key == &pin.current)
12979        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
12980    let signer_index = chain
12981        .iter()
12982        .position(|key| key == &item.entry.brain)
12983        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
12984    if signer_index < pinned_index {
12985        return Err(invalid_feed(
12986            "a retired identity attempted to sign after the local checkpoint",
12987        ));
12988    }
12989    Ok(())
12990}
12991
12992fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
12993    let origin = normalized_origin(&cfg.hub)?;
12994    let key = format!(
12995        "{:x}",
12996        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
12997    );
12998    Ok(format!("{key}.json"))
12999}
13000
13001fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13002    let origin = normalized_origin(&cfg.hub)?;
13003    let key = format!(
13004        "{:x}",
13005        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13006    );
13007    Ok(format!("alias-{key}.json"))
13008}
13009
13010#[cfg(any(unix, windows))]
13011struct TrustLock {
13012    _file: std::fs::File,
13013}
13014
13015#[cfg(unix)]
13016fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13017    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13018
13019    let lock_string = format!(".{state_name}.lock");
13020    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13021    let fd = unsafe {
13022        libc::openat(
13023            directory.as_raw_fd(),
13024            lock_name.as_ptr(),
13025            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13026            0o600,
13027        )
13028    };
13029    if fd < 0 {
13030        return Err(std::io::Error::last_os_error().into());
13031    }
13032    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13033    if !file.metadata()?.is_file() {
13034        return Err(LinkError::UnsafePath { path: lock_string });
13035    }
13036    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13037        return Err(std::io::Error::last_os_error().into());
13038    }
13039    Ok(TrustLock { _file: file })
13040}
13041
13042#[cfg(windows)]
13043fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13044    let lock_name = format!(".{state_name}.lock");
13045    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13046    Ok(TrustLock { _file: file })
13047}
13048
13049#[cfg(any(unix, windows))]
13050fn lock_trust_many(
13051    cfg: &HubConfig,
13052    directory: &std::fs::File,
13053    refs: &[&str],
13054) -> LinkResult<Vec<TrustLock>> {
13055    let mut names = refs
13056        .iter()
13057        .map(|reference| trust_file_name(cfg, reference))
13058        .collect::<LinkResult<Vec<_>>>()?;
13059    names.sort();
13060    names.dedup();
13061    names
13062        .iter()
13063        .map(|name| lock_trust_name(directory, name))
13064        .collect()
13065}
13066
13067#[cfg(not(any(unix, windows)))]
13068fn lock_trust_many(
13069    _cfg: &HubConfig,
13070    _directory: &TrustDirectory,
13071    _refs: &[&str],
13072) -> LinkResult<Vec<()>> {
13073    Err(LinkError::UnsupportedPlatform {
13074        operation: "verified link.md state",
13075    })
13076}
13077
13078#[cfg(any(unix, windows))]
13079type TrustDirectory = std::fs::File;
13080
13081#[cfg(not(any(unix, windows)))]
13082struct TrustDirectory;
13083
13084#[cfg(unix)]
13085fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13086    use std::os::fd::AsRawFd as _;
13087
13088    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
13089    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
13090        return Err(std::io::Error::last_os_error().into());
13091    }
13092    directory.sync_all()?;
13093    Ok(directory)
13094}
13095
13096#[cfg(windows)]
13097fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13098    let marker = cfg.state_dir.join("trust").join(".directory");
13099    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
13100    Ok(crate::fsx::open_directory_nofollow(
13101        marker.parent().expect("trust marker has a parent"),
13102    )?)
13103}
13104
13105#[cfg(not(any(unix, windows)))]
13106fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13107    Err(LinkError::UnsupportedPlatform {
13108        operation: "verified link.md state",
13109    })
13110}
13111
13112#[cfg(unix)]
13113fn load_trust_in(
13114    cfg: &HubConfig,
13115    directory: &TrustDirectory,
13116    requested: &str,
13117) -> LinkResult<Option<TrustState>> {
13118    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13119
13120    let name_string = trust_file_name(cfg, requested)?;
13121    let name = c_name(name_string.as_bytes(), &name_string)?;
13122    let fd = unsafe {
13123        libc::openat(
13124            directory.as_raw_fd(),
13125            name.as_ptr(),
13126            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13127        )
13128    };
13129    if fd < 0 {
13130        let error = std::io::Error::last_os_error();
13131        if error.kind() == std::io::ErrorKind::NotFound {
13132            return Ok(None);
13133        }
13134        return Err(LinkError::UnsafePath { path: name_string });
13135    }
13136    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13137    if !file.metadata()?.is_file() {
13138        return Err(LinkError::UnsafePath { path: name_string });
13139    }
13140    let mut bytes = Vec::new();
13141    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
13142    if bytes.len() > 1024 * 1024 {
13143        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
13144    }
13145    let mut state: TrustState = serde_json::from_slice(&bytes)
13146        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13147    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13148        return Err(invalid_feed(
13149            "local identity/feed checkpoint does not match this hub and brain",
13150        ));
13151    }
13152    if state.v == 1 {
13153        // Legacy files were keyed by canonical brain id. They can migrate only
13154        // when the caller used that exact id; old slug lookups had no durable
13155        // alias binding and therefore cannot be guessed safely.
13156        if state.brain != requested {
13157            return Err(invalid_feed(
13158                "legacy checkpoint is not bound to the requested brain id",
13159            ));
13160        }
13161        state.requested = requested.to_string();
13162    } else if state.requested != requested {
13163        return Err(invalid_feed(
13164            "local identity/feed checkpoint is bound to a different requested ref",
13165        ));
13166    }
13167    Ok(Some(state))
13168}
13169
13170#[cfg(windows)]
13171fn load_trust_in(
13172    cfg: &HubConfig,
13173    directory: &TrustDirectory,
13174    requested: &str,
13175) -> LinkResult<Option<TrustState>> {
13176    let name = trust_file_name(cfg, requested)?;
13177    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13178    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
13179        Ok(bytes) => bytes,
13180        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13181        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13182    };
13183    let mut state: TrustState = serde_json::from_slice(&bytes)
13184        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13185    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13186        return Err(invalid_feed(
13187            "local identity/feed checkpoint does not match this hub and brain",
13188        ));
13189    }
13190    if state.v == 1 {
13191        if state.brain != requested {
13192            return Err(invalid_feed(
13193                "legacy checkpoint is not bound to the requested brain id",
13194            ));
13195        }
13196        state.requested = requested.to_string();
13197    } else if state.requested != requested {
13198        return Err(invalid_feed(
13199            "local identity/feed checkpoint is bound to a different requested ref",
13200        ));
13201    }
13202    Ok(Some(state))
13203}
13204
13205#[cfg(not(any(unix, windows)))]
13206fn load_trust_in(
13207    _cfg: &HubConfig,
13208    _directory: &TrustDirectory,
13209    _brain: &str,
13210) -> LinkResult<Option<TrustState>> {
13211    Err(LinkError::UnsupportedPlatform {
13212        operation: "verified link.md state",
13213    })
13214}
13215
13216#[cfg(all(test, any(unix, windows)))]
13217fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
13218    let directory = open_trust_dir(cfg)?;
13219    load_trust_in(cfg, &directory, requested)
13220}
13221
13222#[cfg(unix)]
13223fn save_trust_in(
13224    cfg: &HubConfig,
13225    directory: &TrustDirectory,
13226    state: &TrustState,
13227) -> LinkResult<()> {
13228    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13229
13230    let name_string = trust_file_name(cfg, &state.requested)?;
13231    let name = c_name(name_string.as_bytes(), &name_string)?;
13232    let mut bytes = serde_json::to_vec(state)
13233        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13234    bytes.push(b'\n');
13235
13236    let nonce = std::time::SystemTime::now()
13237        .duration_since(std::time::UNIX_EPOCH)
13238        .unwrap_or_default()
13239        .as_nanos();
13240    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13241    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13242    let fd = unsafe {
13243        libc::openat(
13244            directory.as_raw_fd(),
13245            temp.as_ptr(),
13246            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13247            0o600,
13248        )
13249    };
13250    if fd < 0 {
13251        return Err(std::io::Error::last_os_error().into());
13252    }
13253    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13254    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13255        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13256        return Err(error.into());
13257    }
13258    drop(file);
13259    if unsafe {
13260        libc::renameat(
13261            directory.as_raw_fd(),
13262            temp.as_ptr(),
13263            directory.as_raw_fd(),
13264            name.as_ptr(),
13265        )
13266    } != 0
13267    {
13268        let error = std::io::Error::last_os_error();
13269        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13270        return Err(error.into());
13271    }
13272    directory.sync_all()?;
13273    Ok(())
13274}
13275
13276#[cfg(windows)]
13277fn save_trust_in(
13278    cfg: &HubConfig,
13279    directory: &TrustDirectory,
13280    state: &TrustState,
13281) -> LinkResult<()> {
13282    let name = trust_file_name(cfg, &state.requested)?;
13283    let mut bytes = serde_json::to_vec(state)
13284        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13285    bytes.push(b'\n');
13286    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13287    Ok(())
13288}
13289
13290#[cfg(not(any(unix, windows)))]
13291fn save_trust_in(
13292    _cfg: &HubConfig,
13293    _directory: &TrustDirectory,
13294    _state: &TrustState,
13295) -> LinkResult<()> {
13296    Err(LinkError::UnsupportedPlatform {
13297        operation: "verified link.md state",
13298    })
13299}
13300
13301#[cfg(unix)]
13302fn load_alias_in(
13303    cfg: &HubConfig,
13304    directory: &TrustDirectory,
13305    requested: &str,
13306) -> LinkResult<Option<AliasBinding>> {
13307    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13308
13309    let name_string = alias_file_name(cfg, requested)?;
13310    let name = c_name(name_string.as_bytes(), &name_string)?;
13311    let fd = unsafe {
13312        libc::openat(
13313            directory.as_raw_fd(),
13314            name.as_ptr(),
13315            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13316        )
13317    };
13318    if fd < 0 {
13319        let error = std::io::Error::last_os_error();
13320        if error.kind() == std::io::ErrorKind::NotFound {
13321            return Ok(None);
13322        }
13323        return Err(LinkError::UnsafePath { path: name_string });
13324    }
13325    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13326    if !file.metadata()?.is_file() {
13327        return Err(LinkError::UnsafePath { path: name_string });
13328    }
13329    let mut bytes = Vec::new();
13330    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13331    if bytes.len() > 64 * 1024 {
13332        return Err(invalid_feed("local alias binding is oversized"));
13333    }
13334    let alias: AliasBinding = serde_json::from_slice(&bytes)
13335        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13336    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13337    {
13338        return Err(invalid_feed(
13339            "local alias binding does not match this hub and requested ref",
13340        ));
13341    }
13342    Ok(Some(alias))
13343}
13344
13345#[cfg(windows)]
13346fn load_alias_in(
13347    cfg: &HubConfig,
13348    directory: &TrustDirectory,
13349    requested: &str,
13350) -> LinkResult<Option<AliasBinding>> {
13351    let name = alias_file_name(cfg, requested)?;
13352    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13353    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13354        Ok(bytes) => bytes,
13355        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13356        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13357    };
13358    let alias: AliasBinding = serde_json::from_slice(&bytes)
13359        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13360    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13361    {
13362        return Err(invalid_feed(
13363            "local alias binding does not match this hub and requested ref",
13364        ));
13365    }
13366    Ok(Some(alias))
13367}
13368
13369#[cfg(not(any(unix, windows)))]
13370fn load_alias_in(
13371    _cfg: &HubConfig,
13372    _directory: &TrustDirectory,
13373    _requested: &str,
13374) -> LinkResult<Option<AliasBinding>> {
13375    Err(LinkError::UnsupportedPlatform {
13376        operation: "verified link.md state",
13377    })
13378}
13379
13380#[cfg(unix)]
13381fn save_alias_in(
13382    cfg: &HubConfig,
13383    directory: &TrustDirectory,
13384    alias: &AliasBinding,
13385) -> LinkResult<()> {
13386    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13387
13388    let name_string = alias_file_name(cfg, &alias.requested)?;
13389    let name = c_name(name_string.as_bytes(), &name_string)?;
13390    let mut bytes = serde_json::to_vec(alias)
13391        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13392    bytes.push(b'\n');
13393    let nonce = std::time::SystemTime::now()
13394        .duration_since(std::time::UNIX_EPOCH)
13395        .unwrap_or_default()
13396        .as_nanos();
13397    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13398    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13399    let fd = unsafe {
13400        libc::openat(
13401            directory.as_raw_fd(),
13402            temp.as_ptr(),
13403            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13404            0o600,
13405        )
13406    };
13407    if fd < 0 {
13408        return Err(std::io::Error::last_os_error().into());
13409    }
13410    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13411    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13412        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13413        return Err(error.into());
13414    }
13415    drop(file);
13416    if unsafe {
13417        libc::renameat(
13418            directory.as_raw_fd(),
13419            temp.as_ptr(),
13420            directory.as_raw_fd(),
13421            name.as_ptr(),
13422        )
13423    } != 0
13424    {
13425        let error = std::io::Error::last_os_error();
13426        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13427        return Err(error.into());
13428    }
13429    directory.sync_all()?;
13430    Ok(())
13431}
13432
13433#[cfg(windows)]
13434fn save_alias_in(
13435    cfg: &HubConfig,
13436    directory: &TrustDirectory,
13437    alias: &AliasBinding,
13438) -> LinkResult<()> {
13439    let name = alias_file_name(cfg, &alias.requested)?;
13440    let mut bytes = serde_json::to_vec(alias)
13441        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13442    bytes.push(b'\n');
13443    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13444    Ok(())
13445}
13446
13447#[cfg(not(any(unix, windows)))]
13448fn save_alias_in(
13449    _cfg: &HubConfig,
13450    _directory: &TrustDirectory,
13451    _alias: &AliasBinding,
13452) -> LinkResult<()> {
13453    Err(LinkError::UnsupportedPlatform {
13454        operation: "verified link.md state",
13455    })
13456}
13457
13458/// Load the canonical checkpoint shared by every spelling of one brain and
13459/// the separate alias binding. This also performs the one-way migration from
13460/// pre-v3 checkpoints that stored a full trust state under the alias itself.
13461/// Callers hold both alias and canonical locks before entering.
13462fn load_canonical_pin(
13463    cfg: &HubConfig,
13464    directory: &TrustDirectory,
13465    requested: &str,
13466    resolved_brain: &str,
13467) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13468    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13469    if requested == resolved_brain {
13470        return Ok((canonical, None));
13471    }
13472
13473    let mut alias = load_alias_in(cfg, directory, requested)?;
13474    if let Some(binding) = &alias {
13475        if binding.brain != resolved_brain {
13476            return Err(LinkError::AliasRebindRequired {
13477                alias: requested.to_string(),
13478                from: binding.brain.clone(),
13479                to: resolved_brain.to_string(),
13480            });
13481        }
13482        return Ok((canonical, alias));
13483    }
13484
13485    // A v2 build stored the complete checkpoint under the requested slug.
13486    // Promote it to the canonical ULID key before creating the lightweight
13487    // alias binding. Never silently merge two independently advanced states.
13488    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13489        if legacy.brain != resolved_brain {
13490            return Err(invalid_feed(
13491                "legacy alias checkpoint names a different canonical brain",
13492            ));
13493        }
13494        if let Some(existing) = &canonical {
13495            if existing.brain != legacy.brain
13496                || existing.anchor != legacy.anchor
13497                || existing.current != legacy.current
13498                || existing.head_seq != legacy.head_seq
13499                || existing.feed_hash != legacy.feed_hash
13500                || existing.rotations != legacy.rotations
13501            {
13502                return Err(invalid_feed(
13503                    "legacy alias checkpoint conflicts with the canonical checkpoint",
13504                ));
13505            }
13506        } else {
13507            let mut promoted = legacy.clone();
13508            promoted.requested = resolved_brain.to_string();
13509            promoted.home = None;
13510            save_trust_in(cfg, directory, &promoted)?;
13511            canonical = Some(promoted);
13512        }
13513        alias = Some(AliasBinding {
13514            v: 1,
13515            origin: normalized_origin(&cfg.hub)?,
13516            requested: requested.to_string(),
13517            brain: resolved_brain.to_string(),
13518            home: legacy.home,
13519        });
13520        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13521    }
13522    Ok((canonical, alias))
13523}
13524
13525/// Explicitly replace one mutable alias binding after the operator reviews the
13526/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
13527/// or rewritten, so old history remains pinned and the new brain establishes
13528/// its own independently verified trust chain.
13529pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13530    require_hardened_filesystem("verified alias rebind")?;
13531    require_safe_ref(alias)?;
13532    require_safe_ref(from)?;
13533    require_safe_ref(to)?;
13534    if crate::ulid::is_ulid(alias)
13535        || !crate::ulid::is_ulid(from)
13536        || !crate::ulid::is_ulid(to)
13537        || from == to
13538    {
13539        return Err(LinkError::InvalidPack {
13540            message:
13541                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13542                    .to_string(),
13543        });
13544    }
13545
13546    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13547        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13548    })?;
13549    accept_v2_head(cfg, &verified)?;
13550
13551    let alias_response = ensure_ok(
13552        request(
13553            cfg,
13554            "GET",
13555            &format!("/api/hub/brains/{alias}/v2/head"),
13556            None,
13557            Auth::Required,
13558        )?,
13559        "resolve alias for explicit rebind",
13560    )?;
13561    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13562        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13563    if resolved.v != 2 || resolved.brain_id != to {
13564        return Err(LinkError::RemoteAdvancedDuringSync);
13565    }
13566
13567    let directory = open_trust_dir(cfg)?;
13568    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13569    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13570        message: "the requested alias has no existing local binding to replace".to_string(),
13571    })?;
13572    if binding.brain != from {
13573        return Err(LinkError::AliasRebindRequired {
13574            alias: alias.to_string(),
13575            from: binding.brain,
13576            to: to.to_string(),
13577        });
13578    }
13579    save_alias_in(
13580        cfg,
13581        &directory,
13582        &AliasBinding {
13583            v: 1,
13584            origin: normalized_origin(&cfg.hub)?,
13585            requested: alias.to_string(),
13586            brain: to.to_string(),
13587            home: binding.home,
13588        },
13589    )?;
13590    Ok(json!({
13591        "v": 2,
13592        "alias": alias,
13593        "from": from,
13594        "to": to,
13595        "outcome": "alias_rebound",
13596    }))
13597}
13598
13599fn save_canonical_pin_and_alias(
13600    cfg: &HubConfig,
13601    directory: &TrustDirectory,
13602    requested: &str,
13603    resolved_brain: &str,
13604    mut state: TrustState,
13605    existing_alias: Option<&AliasBinding>,
13606) -> LinkResult<()> {
13607    state.requested = resolved_brain.to_string();
13608    state.brain = resolved_brain.to_string();
13609    state.home = None;
13610    save_trust_in(cfg, directory, &state)?;
13611    if requested != resolved_brain {
13612        save_alias_in(
13613            cfg,
13614            directory,
13615            &AliasBinding {
13616                v: 1,
13617                origin: normalized_origin(&cfg.hub)?,
13618                requested: requested.to_string(),
13619                brain: resolved_brain.to_string(),
13620                home: existing_alias.and_then(|alias| alias.home.clone()),
13621            },
13622        )?;
13623    }
13624    Ok(())
13625}
13626
13627fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13628    const ED25519_SPKI_PREFIX: &[u8] = &[
13629        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13630    ];
13631    let entry = &item.entry;
13632    let public_der = URL_SAFE_NO_PAD
13633        .decode(&entry.public_key)
13634        .map_err(|_| invalid_feed("public key is not base64url"))?;
13635    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13636        || !public_der.starts_with(ED25519_SPKI_PREFIX)
13637    {
13638        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13639    }
13640    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13641    if entry.brain != format!("ed25519:{fingerprint}") {
13642        return Err(invalid_feed(
13643            "brain fingerprint does not match its public key",
13644        ));
13645    }
13646    // Verify the complete rotation authority before using any previous key.
13647    let _ = verify_identity_chain(identity, None)?;
13648    let mut chain: Vec<(&str, &str)> = identity
13649        .previous
13650        .iter()
13651        .rev()
13652        .map(|previous| {
13653            (
13654                previous.fingerprint.as_str(),
13655                previous.public_key_spki.as_str(),
13656            )
13657        })
13658        .collect();
13659    chain.push((&identity.fingerprint, &identity.public_key_spki));
13660    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13661        *known_fingerprint == fingerprint && *spki == entry.public_key
13662    });
13663    let Some(signer_index) = signer_index else {
13664        return Err(invalid_feed(
13665            "entry signer is not this brain's identity (current or rotated-from)",
13666        ));
13667    };
13668    let lower_boundary = if signer_index == 0 {
13669        None
13670    } else {
13671        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13672            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13673        Some(prior.prior_head_seq)
13674    };
13675    let upper_boundary = if signer_index == identity.rotations.len() {
13676        None
13677    } else {
13678        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13679            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13680        Some(next.prior_head_seq)
13681    };
13682    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13683        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13684    {
13685        return Err(invalid_feed(
13686            "entry signer is outside its authenticated rotation epoch",
13687        ));
13688    }
13689    let unsigned = UnsignedFeedEntry {
13690        v: entry.v,
13691        seq: entry.seq,
13692        ts: &entry.ts,
13693        brain: &entry.brain,
13694        public_key: &entry.public_key,
13695        kind: &entry.kind,
13696        op: &entry.op,
13697        pack_sha256: &entry.pack_sha256,
13698        files: &entry.files,
13699        removed: &entry.removed,
13700        prev_entry_hash: &entry.prev_entry_hash,
13701    };
13702    let message =
13703        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13704    let signature = URL_SAFE_NO_PAD
13705        .decode(&entry.sig)
13706        .map_err(|_| invalid_feed("signature is not base64url"))?;
13707    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13708        .verify(&message, &signature)
13709        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13710
13711    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13712    exact.push(b'\n');
13713    let actual_hash = format!("{:x}", Sha256::digest(&exact));
13714    if actual_hash != item.hash {
13715        return Err(invalid_feed("entry SHA-256 does not match"));
13716    }
13717    Ok(())
13718}
13719
13720// ─────────────────────────────────────────────────────────────────────────────
13721// key rotation — link.md §9.1: the new key, signed by the old one
13722// ─────────────────────────────────────────────────────────────────────────────
13723
13724/// The unsigned rotation statement in its normative field order.
13725#[derive(Serialize)]
13726struct UnsignedRotation<'a> {
13727    v: u8,
13728    op: &'a str,
13729    brain: &'a str,
13730    public_key: &'a str,
13731    new_brain: &'a str,
13732    new_public_key: &'a str,
13733    prior_head_seq: u64,
13734    prior_feed_hash: Option<&'a str>,
13735    ts: String,
13736}
13737
13738/// Durable intent for an in-flight key rotation. The hub's recovery contract
13739/// identifies an ambiguous retry by the exact statement bytes, so the
13740/// statement cannot be reconstructed from the key and feed boundary later:
13741/// its timestamp and signature would differ.
13742#[derive(Debug, Deserialize, Serialize)]
13743#[serde(deny_unknown_fields)]
13744struct RotationJournal {
13745    v: u8,
13746    origin: String,
13747    brain: String,
13748    old_brain: String,
13749    new_brain: String,
13750    prior_head_seq: u64,
13751    prior_feed_hash: Option<String>,
13752    statement: String,
13753}
13754
13755fn rotation_journal_path(key_path: &Path) -> PathBuf {
13756    let mut path = key_path.as_os_str().to_os_string();
13757    path.push(".rotation.json");
13758    PathBuf::from(path)
13759}
13760
13761fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13762    #[cfg(unix)]
13763    let file = {
13764        use std::os::fd::{AsRawFd as _, FromRawFd as _};
13765        use std::os::unix::ffi::OsStrExt as _;
13766        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13767            .map_err(|error| {
13768                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13769            })?;
13770        let leaf_name = path
13771            .file_name()
13772            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13773        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13774        let fd = unsafe {
13775            libc::openat(
13776                parent.as_raw_fd(),
13777                leaf.as_ptr(),
13778                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13779            )
13780        };
13781        if fd < 0 {
13782            return Err(bad_agent_key(
13783                "the rotation journal must be an existing regular file without symlink ancestors",
13784            ));
13785        }
13786        unsafe { std::fs::File::from_raw_fd(fd) }
13787    };
13788    #[cfg(not(unix))]
13789    let file = std::fs::File::open(path)
13790        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13791    let metadata = file
13792        .metadata()
13793        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13794    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13795        return Err(bad_agent_key(
13796            "the rotation journal must be a bounded regular file",
13797        ));
13798    }
13799    #[cfg(unix)]
13800    {
13801        use std::os::unix::fs::PermissionsExt as _;
13802        if metadata.permissions().mode() & 0o077 != 0 {
13803            return Err(bad_agent_key(
13804                "the rotation journal is accessible to group/other; set mode 0600",
13805            ));
13806        }
13807    }
13808    serde_json::from_reader(file)
13809        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13810}
13811
13812fn remove_rotation_journal(path: &Path) {
13813    #[cfg(unix)]
13814    {
13815        use std::os::fd::AsRawFd as _;
13816        use std::os::unix::ffi::OsStrExt as _;
13817        let Ok(parent) =
13818            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13819        else {
13820            return;
13821        };
13822        let Some(leaf_name) = path.file_name() else {
13823            return;
13824        };
13825        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13826            return;
13827        };
13828        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13829            let _ = parent.sync_all();
13830        }
13831    }
13832    #[cfg(not(unix))]
13833    {
13834        let _ = std::fs::remove_file(path);
13835    }
13836}
13837
13838fn validate_rotation_journal(
13839    journal: &RotationJournal,
13840    cfg: &HubConfig,
13841    canonical_brain: &str,
13842    old_key: &AgentSigningKey,
13843    new_key: &AgentSigningKey,
13844    head: &Head,
13845) -> LinkResult<()> {
13846    if journal.v != 1
13847        || journal.origin != normalized_origin(&cfg.hub)?
13848        || journal.brain != canonical_brain
13849        || journal.old_brain != old_key.multikey
13850        || journal.new_brain != new_key.multikey
13851        || journal.prior_head_seq != head.seq
13852        || journal.prior_feed_hash != head.feed_hash
13853    {
13854        return Err(invalid_feed(
13855            "rotation journal does not match the verified key and feed boundary",
13856        ));
13857    }
13858    let statement: RotationStatement = serde_json::from_str(&journal.statement)
13859        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13860    if statement.prior_head_seq != journal.prior_head_seq
13861        || statement.prior_feed_hash != journal.prior_feed_hash
13862        || statement.brain != old_key.multikey
13863        || statement.public_key != old_key.public_key_spki
13864        || statement.new_brain != new_key.multikey
13865        || statement.new_public_key != new_key.public_key_spki
13866    {
13867        return Err(invalid_feed(
13868            "rotation journal statement does not match its durable intent",
13869        ));
13870    }
13871    let identity = FeedIdentity {
13872        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13873        public_key_spki: new_key.public_key_spki.clone(),
13874        previous: vec![PreviousIdentity {
13875            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13876            public_key_spki: old_key.public_key_spki.clone(),
13877        }],
13878        rotations: vec![journal.statement.clone()],
13879    };
13880    verify_identity_chain(&identity, None)?;
13881    Ok(())
13882}
13883
13884/// What `dbmd key rotate` returns.
13885#[derive(Debug, Serialize)]
13886pub struct RotationReport {
13887    /// The brain id rotated.
13888    pub brain: String,
13889    /// The NEW identity the hub now serves.
13890    pub multikey: String,
13891    /// Where the new PKCS#8 secret landed (0600).
13892    #[serde(rename = "keyFile")]
13893    pub key_file: String,
13894    /// Prior identities (newest first) the feed still verifies against.
13895    pub previous: Vec<String>,
13896}
13897
13898/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
13899/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
13900/// OLD key in normative serialization — and send it to the hub. The new secret
13901/// is durably created at 0600 before the POST; an existing output is reused for
13902/// idempotent retry/reconciliation. The old key is left untouched.
13903pub fn rotate_brain_key(
13904    cfg: &HubConfig,
13905    brain: &str,
13906    old_key: &AgentSigningKey,
13907    out: &Path,
13908) -> LinkResult<RotationReport> {
13909    require_hardened_filesystem("key rotation")?;
13910    require_safe_ref(brain)?;
13911    // The new private key must be durable *before* the hub can accept its
13912    // public half. An existing file is the retry/reconciliation path after an
13913    // ambiguous network failure: reuse it, never generate another identity.
13914    let new_key = if out.exists() {
13915        load_signing_key(out)?
13916    } else {
13917        let rng = ring::rand::SystemRandom::new();
13918        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13919            .map_err(|_| bad_agent_key("key generation failed"))?;
13920        let pair = agent_keypair(pkcs8.as_ref())?;
13921        let (public_key_spki, multikey) = public_identity_for(&pair);
13922        write_secret_new(
13923            out,
13924            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13925        )?;
13926        AgentSigningKey {
13927            pkcs8: pkcs8.as_ref().to_vec(),
13928            multikey,
13929            public_key_spki,
13930        }
13931    };
13932    let new_spki = new_key.public_key_spki.clone();
13933    let new_multikey = new_key.multikey.clone();
13934    let journal_path = rotation_journal_path(out);
13935    let before_v2 = v2_verified_head(cfg, brain)?;
13936    let (canonical_brain, served_identity, observed_head, v2_profile) =
13937        if let Some(head) = before_v2 {
13938            let observed = Head {
13939                brain: head.brain_id.clone(),
13940                seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13941                updated_at: head
13942                    .pointer
13943                    .as_ref()
13944                    .map(|pointer| pointer.signed_at.clone()),
13945                feed_hash: head
13946                    .pointer
13947                    .as_ref()
13948                    .map(|pointer| pointer.feed_hash.clone()),
13949                verified: true,
13950            };
13951            let identity = v2_identity(&head.identity);
13952            let canonical = head.brain_id.clone();
13953            accept_v2_head(cfg, &head)?;
13954            (canonical, identity, observed, true)
13955        } else {
13956            let remote = verified_remote_head(cfg, brain, false)?;
13957            let identity = remote
13958                .identity
13959                .clone()
13960                .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13961            (remote.head.brain.clone(), identity, remote.head, false)
13962        };
13963    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13964    let already_rotated = served_multikey == new_multikey;
13965    // A completed invocation removes the journal only after the server has
13966    // acknowledged its recovery boundary. If the verified identity advanced
13967    // but the journal remains, the prior response was ambiguous: replay the
13968    // exact signed statement so a v2 hub can repair/confirm its checkpoint.
13969    if already_rotated && !journal_path.exists() {
13970        remove_rotation_journal(&journal_path);
13971        return Ok(RotationReport {
13972            brain: brain.to_string(),
13973            multikey: new_multikey,
13974            key_file: out.display().to_string(),
13975            previous: served_identity
13976                .previous
13977                .iter()
13978                .map(|identity| format!("ed25519:{}", identity.fingerprint))
13979                .collect(),
13980        });
13981    }
13982    if !already_rotated && served_multikey != old_key.multikey {
13983        return Err(invalid_feed(
13984            "the supplied old key is not the brain's verified current identity",
13985        ));
13986    }
13987
13988    let journal = if journal_path.exists() {
13989        read_rotation_journal(&journal_path)?
13990    } else {
13991        let ts = crate::now()
13992            .with_timezone(&chrono::Utc)
13993            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
13994            .to_string();
13995        let unsigned = serde_json::to_string(&UnsignedRotation {
13996            v: 1,
13997            op: "rotate",
13998            brain: &old_key.multikey,
13999            public_key: &old_key.public_key_spki,
14000            new_brain: &new_multikey,
14001            new_public_key: &new_spki,
14002            prior_head_seq: observed_head.seq,
14003            prior_feed_hash: observed_head.feed_hash.as_deref(),
14004            ts,
14005        })
14006        .expect("serialize rotation");
14007        let old_pair = agent_keypair(&old_key.pkcs8)?;
14008        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14009        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14010        let journal = RotationJournal {
14011            v: 1,
14012            origin: normalized_origin(&cfg.hub)?,
14013            brain: canonical_brain.clone(),
14014            old_brain: old_key.multikey.clone(),
14015            new_brain: new_multikey.clone(),
14016            prior_head_seq: observed_head.seq,
14017            prior_feed_hash: observed_head.feed_hash.clone(),
14018            statement,
14019        };
14020        let mut exact = serde_json::to_vec(&journal)
14021            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14022        exact.push(b'\n');
14023        if write_secret_new(&journal_path, &exact).is_err() {
14024            // A concurrent retry may have won the O_EXCL race. Only an exact,
14025            // fully validated journal is allowed to recover that race.
14026            read_rotation_journal(&journal_path)?
14027        } else {
14028            journal
14029        }
14030    };
14031    validate_rotation_journal(
14032        &journal,
14033        cfg,
14034        &canonical_brain,
14035        old_key,
14036        &new_key,
14037        &observed_head,
14038    )?;
14039
14040    let body = json!({ "statement": journal.statement });
14041    let path = format!("/api/hub/brains/{brain}/rotate");
14042    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14043    let attempted_failure = match attempted {
14044        Ok(response) if (200..300).contains(&response.status) => None,
14045        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14046        Err(error) => Some(error),
14047    };
14048
14049    // A 2xx body is not authority, and a failed response may have followed a
14050    // committed mutation. In both cases success means the normal verifier sees
14051    // an append-only rotation chain ending at the durable new key.
14052    let identity = if v2_profile {
14053        match v2_verified_head(cfg, brain) {
14054            Ok(Some(after)) => {
14055                let identity = v2_identity(&after.identity);
14056                accept_v2_head(cfg, &after)?;
14057                identity
14058            }
14059            Ok(None) => {
14060                return Err(attempted_failure.unwrap_or_else(|| {
14061                    invalid_feed("rotated v2 brain no longer serves a v2 head")
14062                }));
14063            }
14064            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14065        }
14066    } else {
14067        match verified_remote_head(cfg, brain, false) {
14068            Ok(after) => after
14069                .identity
14070                .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14071            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14072        }
14073    };
14074    if format!("ed25519:{}", identity.fingerprint) != new_multikey
14075        || identity.public_key_spki != new_spki
14076    {
14077        return Err(attempted_failure.unwrap_or_else(|| {
14078            invalid_feed("hub acknowledged rotation without committing the verified new identity")
14079        }));
14080    }
14081    if v2_profile {
14082        if let Some(error) = attempted_failure {
14083            // Seeing the new public identity is insufficient: v2 rotation is
14084            // successful only after the endpoint has forced and acknowledged
14085            // the recovery checkpoint that contains it. Keep the journal for
14086            // an exact retry after any non-2xx or transport ambiguity.
14087            return Err(error);
14088        }
14089    }
14090    let previous = identity
14091        .previous
14092        .iter()
14093        .map(|prior| format!("ed25519:{}", prior.fingerprint))
14094        .collect();
14095    remove_rotation_journal(&journal_path);
14096
14097    Ok(RotationReport {
14098        brain: brain.to_string(),
14099        multikey: new_multikey,
14100        key_file: out.display().to_string(),
14101        previous,
14102    })
14103}
14104
14105// ─────────────────────────────────────────────────────────────────────────────
14106// mirror — verified replication: the whole feed + files, re-servable
14107// ─────────────────────────────────────────────────────────────────────────────
14108
14109/// What `dbmd mirror` materialized.
14110#[derive(Debug, Serialize)]
14111pub struct MirrorReport {
14112    /// The brain id.
14113    pub brain: String,
14114    /// The mirrored feed head.
14115    #[serde(rename = "headSeq")]
14116    pub head_seq: u64,
14117    /// The head entry hash (the feed's advertised converged state).
14118    #[serde(rename = "feedHash")]
14119    pub feed_hash: Option<String>,
14120    /// Signed feed entries verified and stored.
14121    pub entries: u64,
14122    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
14123    pub pinned: String,
14124    /// Store files materialized by the pull.
14125    pub files: usize,
14126}
14127
14128/// The mirror state directory, relative to the mirror root.
14129pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
14130
14131/// Fully re-verified mirror material suitable for a read-only re-server.
14132#[derive(Debug)]
14133pub struct VerifiedMirrorMaterial {
14134    pub brain: String,
14135    pub head_seq: u64,
14136    pub feed_hash: Option<String>,
14137    pub identity: serde_json::Value,
14138    /// Sequence, exact normative entry JSON without newline, and entry hash.
14139    pub entries: Vec<(u64, String, String)>,
14140    pub pack_sha256: Option<String>,
14141}
14142
14143#[derive(Deserialize)]
14144#[serde(deny_unknown_fields)]
14145struct StoredMirrorHead {
14146    brain: String,
14147    #[serde(rename = "headSeq")]
14148    head_seq: u64,
14149    #[serde(rename = "feedHash")]
14150    feed_hash: Option<String>,
14151}
14152
14153/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
14154/// exact snapshot pack before `dbmd serve` exposes any bytes.
14155pub fn verify_mirror_material(
14156    head_bytes: &[u8],
14157    identity_bytes: &[u8],
14158    feed_bytes: &[Vec<u8>],
14159    snapshot_pack: Option<&[u8]>,
14160    expected_anchor: &str,
14161) -> LinkResult<VerifiedMirrorMaterial> {
14162    let snapshot_hash = snapshot_pack
14163        .filter(|pack| !pack.is_empty())
14164        .map(content_sha256);
14165    verify_mirror_material_with_pack_hash(
14166        head_bytes,
14167        identity_bytes,
14168        feed_bytes,
14169        snapshot_hash.as_deref(),
14170        expected_anchor,
14171    )
14172}
14173
14174/// Re-verify mirror metadata against a snapshot digest computed from a held
14175/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
14176/// large pack without ever buffering the pack in process memory.
14177pub fn verify_mirror_material_with_pack_hash(
14178    head_bytes: &[u8],
14179    identity_bytes: &[u8],
14180    feed_bytes: &[Vec<u8>],
14181    snapshot_pack_sha256: Option<&str>,
14182    expected_anchor: &str,
14183) -> LinkResult<VerifiedMirrorMaterial> {
14184    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
14185        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
14186    require_safe_ref(&head.brain)?;
14187    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
14188        return Err(invalid_feed(
14189            "stored mirror feed count does not match its bounded head sequence",
14190        ));
14191    }
14192    let aggregate = feed_bytes
14193        .iter()
14194        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
14195        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
14196    if aggregate > MAX_FEED_REPLAY_BYTES {
14197        return Err(invalid_feed(
14198            "stored mirror feed metadata exceeds the aggregate limit",
14199        ));
14200    }
14201    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
14202        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
14203    let anchor = verify_identity_chain(&identity, None)?;
14204    if anchor != expected_anchor {
14205        return Err(invalid_feed(
14206            "stored mirror identity does not descend from the explicitly trusted anchor",
14207        ));
14208    }
14209
14210    let mut entries = Vec::with_capacity(feed_bytes.len());
14211    let mut items = Vec::with_capacity(feed_bytes.len());
14212    let mut previous_hash = None;
14213    let mut pack_sha256 = None;
14214    for (index, bytes) in feed_bytes.iter().enumerate() {
14215        let exact = bytes
14216            .strip_suffix(b"\n")
14217            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
14218        if exact.ends_with(b"\n") {
14219            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
14220        }
14221        let entry: FeedEntry = serde_json::from_slice(exact)
14222            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
14223        let expected_seq = index as u64 + 1;
14224        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
14225            return Err(invalid_feed(
14226                "stored mirror feed is not contiguous and hash-chained",
14227            ));
14228        }
14229        let canonical = serde_json::to_vec(&entry)
14230            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
14231        if canonical != exact {
14232            return Err(invalid_feed(
14233                "stored feed entry is not in normative serialization",
14234            ));
14235        }
14236        let hash = content_sha256(bytes);
14237        let item = FeedItem {
14238            hash: hash.clone(),
14239            entry,
14240        };
14241        verify_feed_item(&item, &identity)?;
14242        previous_hash = Some(hash.clone());
14243        if expected_seq == head.head_seq {
14244            pack_sha256 = Some(item.entry.pack_sha256.clone());
14245        }
14246        entries.push((
14247            expected_seq,
14248            std::str::from_utf8(exact)
14249                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
14250                .to_string(),
14251            hash,
14252        ));
14253        items.push(item);
14254    }
14255    if previous_hash != head.feed_hash {
14256        return Err(invalid_feed(
14257            "stored mirror feed does not converge on its advertised head",
14258        ));
14259    }
14260    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
14261    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
14262        (0, None, None) => {}
14263        (_, Some(actual), Some(expected)) if actual == expected => {}
14264        _ => {
14265            return Err(LinkError::InvalidPack {
14266                message: "stored snapshot pack does not match the signed head digest".to_string(),
14267            });
14268        }
14269    }
14270    let identity_value = serde_json::to_value(&identity)
14271        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
14272    Ok(VerifiedMirrorMaterial {
14273        brain: head.brain,
14274        head_seq: head.head_seq,
14275        feed_hash: head.feed_hash,
14276        identity: identity_value,
14277        entries,
14278        pack_sha256,
14279    })
14280}
14281
14282/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
14283/// entry hash every consumer recomputes (SPEC §5.3).
14284pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
14285    format!(
14286        "{:x}",
14287        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
14288    )
14289}
14290
14291/// SHA-256 hex for content a signed manifest names. Exposed for the thin
14292/// `dbmd serve` adapter; cryptographic verification remains centralized here.
14293pub fn content_sha256(bytes: &[u8]) -> String {
14294    format!("{:x}", Sha256::digest(bytes))
14295}
14296
14297/// SHA-256 a stream with a fixed-size working buffer.
14298pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14299    let mut digest = Sha256::new();
14300    let mut buffer = [0u8; 64 * 1024];
14301    loop {
14302        let read = reader.read(&mut buffer)?;
14303        if read == 0 {
14304            break;
14305        }
14306        digest.update(&buffer[..read]);
14307    }
14308    Ok(format!("{:x}", digest.finalize()))
14309}
14310
14311/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
14312/// chain, not just the head): every entry's signature, hash, sequence
14313/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
14314/// checked in a sibling staging directory. Only then is the old mirror swapped
14315/// out through an atomic directory exchange. Every stage, install, and cleanup
14316/// operation is relative to one held no-follow parent-directory capability, so
14317/// renaming an ancestor cannot redirect any write or deletion.
14318#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14319pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14320    require_hardened_filesystem("mirror")?;
14321    require_safe_ref(brain)?;
14322    #[cfg(windows)]
14323    {
14324        let _ = (cfg, dest);
14325        return Err(LinkError::UnsupportedPlatform {
14326            operation: "atomic whole-mirror replacement on Windows",
14327        });
14328    }
14329    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14330    let name = dest
14331        .file_name()
14332        .and_then(|name| name.to_str())
14333        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14334        .ok_or_else(|| LinkError::UnsafePath {
14335            path: dest.display().to_string(),
14336        })?;
14337    #[cfg(unix)]
14338    let parent_dir = open_or_create_dir_nofollow(parent)?;
14339    #[cfg(unix)]
14340    use std::os::fd::AsRawFd as _;
14341    #[cfg(unix)]
14342    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14343    #[cfg(unix)]
14344    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14345        None => false,
14346        Some(true) => true,
14347        Some(false) => {
14348            return Err(LinkError::UnsafePath {
14349                path: dest.display().to_string(),
14350            });
14351        }
14352    };
14353
14354    // A fixed backup was used by pre-hardening builds. Never interpret or
14355    // delete an attacker-planted entry at that name; require manual recovery.
14356    #[cfg(unix)]
14357    let legacy_backup_name = c_name(
14358        format!(".{name}.dbmd-backup").as_bytes(),
14359        &dest.display().to_string(),
14360    )?;
14361    #[cfg(unix)]
14362    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14363        return Err(LinkError::UnsafePath {
14364            path: parent
14365                .join(format!(".{name}.dbmd-backup"))
14366                .display()
14367                .to_string(),
14368        });
14369    }
14370
14371    let nonce = std::time::SystemTime::now()
14372        .duration_since(std::time::UNIX_EPOCH)
14373        .unwrap_or_default()
14374        .as_nanos();
14375    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14376    #[cfg(unix)]
14377    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14378    #[cfg(unix)]
14379    let stage_dir = create_dir_exclusive_at(
14380        parent_dir.as_raw_fd(),
14381        &stage_name,
14382        &dest.display().to_string(),
14383    )?;
14384
14385    let assembled = (|| -> LinkResult<MirrorReport> {
14386        let remote = verified_remote_head(cfg, brain, true)?;
14387        let brain_id = remote.head.brain.clone();
14388        let identity = remote
14389            .identity
14390            .as_ref()
14391            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14392        let anchor = remote
14393            .anchor
14394            .clone()
14395            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14396        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14397        let snapshot_entries = parse_store_pack(pack.clone())?;
14398        let snapshot_count = snapshot_entries.len();
14399        let mut staged_entries = snapshot_entries;
14400        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14401        for item in &remote.entries {
14402            let mut exact = serde_json::to_vec(&item.entry)
14403                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14404            exact.push(b'\n');
14405            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14406                return Err(invalid_feed(
14407                    "serialized mirror entry differs from its verified hash",
14408                ));
14409            }
14410            staged_entries.push((
14411                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14412                exact,
14413            ));
14414        }
14415        let mut identity_bytes = serde_json::to_vec(identity)
14416            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14417        identity_bytes.push(b'\n');
14418        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14419        let mut head_bytes = serde_json::to_vec(&json!({
14420            "brain": brain_id,
14421            "headSeq": remote.head.seq,
14422            "feedHash": remote.head.feed_hash,
14423        }))
14424        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14425        head_bytes.push(b'\n');
14426        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14427        staged_entries.push((
14428            CONFIG_REL_PATH.to_string(),
14429            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14430        ));
14431        #[cfg(unix)]
14432        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14433
14434        Ok(MirrorReport {
14435            brain: brain_id,
14436            head_seq: remote.head.seq,
14437            feed_hash: remote.head.feed_hash,
14438            entries: remote.entries.len() as u64,
14439            pinned: anchor,
14440            files: snapshot_count,
14441        })
14442    })();
14443
14444    let report = match assembled {
14445        Ok(report) => report,
14446        Err(error) => {
14447            #[cfg(unix)]
14448            let _ = remove_tree_at(
14449                parent_dir.as_raw_fd(),
14450                &stage_name,
14451                &dest.display().to_string(),
14452            );
14453            return Err(error);
14454        }
14455    };
14456
14457    #[cfg(unix)]
14458    if let Err(error) =
14459        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14460    {
14461        let _ = remove_tree_at(
14462            parent_dir.as_raw_fd(),
14463            &stage_name,
14464            &dest.display().to_string(),
14465        );
14466        return Err(error);
14467    }
14468    // An exchange leaves the old mirror at the unique stage name. Cleanup is
14469    // capability-relative and never follows symlinks in hostile old content.
14470    #[cfg(unix)]
14471    if dest_exists {
14472        remove_tree_at(
14473            parent_dir.as_raw_fd(),
14474            &stage_name,
14475            &dest.display().to_string(),
14476        )?;
14477    }
14478    #[cfg(unix)]
14479    parent_dir.sync_all()?;
14480    Ok(report)
14481}
14482
14483fn verified_remote_head(
14484    cfg: &HubConfig,
14485    brain: &str,
14486    require_full_chain: bool,
14487) -> LinkResult<VerifiedRemote> {
14488    require_hardened_filesystem("verified link.md state")?;
14489    require_safe_ref(brain)?;
14490    // Refuse an unsafe state root before sending credentials or consulting an
14491    // untrusted card, and retain this exact directory inode for the complete
14492    // checkpoint transaction.
14493    let trust_directory = open_trust_dir(cfg)?;
14494    let path = format!("/api/hub/brains/{brain}");
14495    let body = ensure_ok(
14496        request(cfg, "GET", &path, None, Auth::Required)?,
14497        "subscribe",
14498    )?;
14499    let resolved_brain = body
14500        .get("id")
14501        .and_then(Value::as_str)
14502        .filter(|id| crate::ulid::is_ulid(id))
14503        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14504        .to_string();
14505    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14506        return Err(invalid_feed(
14507            "brain card id differs from the explicitly requested brain id",
14508        ));
14509    }
14510    // The card supplies the canonical ULID. Lock alias + canonical keys in
14511    // deterministic filename order, then hold both through verify + save.
14512    // Concurrent aliases for the same brain therefore converge on one
14513    // checkpoint instead of establishing independent TOFU universes.
14514    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14515    let (pinned, alias_binding) =
14516        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14517    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14518    let advertised_hash = body
14519        .get("feedHash")
14520        .and_then(Value::as_str)
14521        .map(str::to_string);
14522    let updated_at = body
14523        .get("updatedAt")
14524        .and_then(Value::as_str)
14525        .map(str::to_string);
14526    if let Some(pin) = &pinned {
14527        if seq < pin.head_seq {
14528            return Err(invalid_feed(format!(
14529                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14530                pin.head_seq
14531            )));
14532        }
14533        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14534            return Err(invalid_feed(
14535                "feed equivocation: the checkpoint sequence now has a different hash",
14536            ));
14537        }
14538    }
14539    if seq == 0 {
14540        if advertised_hash.is_some() {
14541            return Err(invalid_feed("an empty feed advertised a head hash"));
14542        }
14543        let identity: FeedIdentity = serde_json::from_value(
14544            body.get("identity")
14545                .cloned()
14546                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14547        )
14548        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14549        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14550        // A valid old-key-signed rotation is still inconsistent if it commits
14551        // to history that the same card now claims never existed. Run the
14552        // identical feed-boundary proof used by non-empty heads before this
14553        // identity can become a durable TOFU checkpoint.
14554        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14555        save_canonical_pin_and_alias(
14556            cfg,
14557            &trust_directory,
14558            brain,
14559            &resolved_brain,
14560            TrustState {
14561                v: 2,
14562                origin: normalized_origin(&cfg.hub)?,
14563                requested: resolved_brain.clone(),
14564                brain: resolved_brain.clone(),
14565                home: None,
14566                anchor: anchor.clone(),
14567                current: format!("ed25519:{}", identity.fingerprint),
14568                head_seq: 0,
14569                feed_hash: None,
14570                rotations: identity.rotations.clone(),
14571                hub_signer: None,
14572                protocol_profile: None,
14573            },
14574            alias_binding.as_ref(),
14575        )?;
14576        return Ok(VerifiedRemote {
14577            head: Head {
14578                brain: resolved_brain,
14579                seq,
14580                updated_at,
14581                feed_hash: None,
14582                verified: true,
14583            },
14584            identity: Some(identity),
14585            head_entry: None,
14586            entries: Vec::new(),
14587            anchor: Some(anchor),
14588        });
14589    }
14590    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14591        return Err(invalid_feed(
14592            "non-empty feed did not advertise a valid SHA-256 head",
14593        ));
14594    }
14595
14596    // On first contact the signed head itself is the TOFU checkpoint. A full
14597    // history replay cannot add authority before an anchor exists; mirrors
14598    // still request the complete chain because they promise an archival copy.
14599    let replay_head_only = !require_full_chain
14600        && pinned
14601            .as_ref()
14602            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14603    let mut after = if replay_head_only {
14604        seq - 1
14605    } else if require_full_chain || pinned.is_none() {
14606        0
14607    } else {
14608        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14609    };
14610    let mut expected_seq = after + 1;
14611    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14612        None
14613    } else {
14614        pinned
14615            .as_ref()
14616            .and_then(|checkpoint| checkpoint.feed_hash.clone())
14617    };
14618    let mut identity: Option<FeedIdentity> = None;
14619    let mut anchor: Option<String> = None;
14620    let mut head_entry: Option<FeedItem> = None;
14621    let mut all_entries = Vec::new();
14622    let mut observed_entries = Vec::new();
14623    let replay_count = seq
14624        .checked_sub(after)
14625        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14626    if replay_count > MAX_FEED_REPLAY_ENTRIES {
14627        return Err(invalid_feed(format!(
14628            "feed replay requires {replay_count} entries, over the client cap"
14629        )));
14630    }
14631    let mut replay_bytes = 0u64;
14632
14633    loop {
14634        let feed_bytes = ensure_raw_ok(
14635            request_raw(
14636                cfg,
14637                "GET",
14638                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14639                None,
14640                Auth::Required,
14641                MAX_FEED_RESPONSE_BYTES,
14642            )?,
14643            "subscribe feed",
14644        )?;
14645        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14646            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14647        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14648            return Err(invalid_feed("brain card and feed head disagree"));
14649        }
14650        if feed.entries.len() > FEED_PAGE_LIMIT {
14651            return Err(invalid_feed("feed page exceeds the requested entry limit"));
14652        }
14653        if feed.scope_limited {
14654            if require_full_chain {
14655                return Err(invalid_feed(
14656                    "path-scoped grants cannot verify a full snapshot chain",
14657                ));
14658            }
14659            return Ok(VerifiedRemote {
14660                head: Head {
14661                    brain: resolved_brain,
14662                    seq,
14663                    updated_at,
14664                    feed_hash: advertised_hash,
14665                    verified: false,
14666                },
14667                identity: None,
14668                head_entry: None,
14669                entries: Vec::new(),
14670                anchor: None,
14671            });
14672        }
14673        let page_identity = feed
14674            .identity
14675            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14676        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14677        if identity
14678            .as_ref()
14679            .is_some_and(|existing| existing != &page_identity)
14680        {
14681            return Err(invalid_feed("identity changed while reading the feed"));
14682        }
14683        if anchor
14684            .as_ref()
14685            .is_some_and(|existing| existing != &page_anchor)
14686        {
14687            return Err(invalid_feed(
14688                "identity anchor changed while reading the feed",
14689            ));
14690        }
14691        identity = Some(page_identity.clone());
14692        if anchor.is_none() {
14693            anchor = Some(page_anchor);
14694        }
14695        if feed.entries.is_empty() {
14696            return Err(invalid_feed("feed page was empty before the signed head"));
14697        }
14698
14699        for item in feed.entries {
14700            if item.entry.seq != expected_seq {
14701                return Err(invalid_feed(format!(
14702                    "expected entry {expected_seq}, feed served {}",
14703                    item.entry.seq
14704                )));
14705            }
14706            if item.entry.seq > seq {
14707                return Err(invalid_feed("feed advanced past the card snapshot"));
14708            }
14709            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14710                return Err(invalid_feed(format!(
14711                    "entry {} does not chain to the local checkpoint",
14712                    item.entry.seq
14713                )));
14714            }
14715            verify_feed_item(&item, &page_identity)?;
14716            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14717            replay_bytes = replay_bytes.saturating_add(
14718                serde_json::to_vec(&item)
14719                    .map_err(|_| invalid_feed("could not size feed entry"))?
14720                    .len() as u64,
14721            );
14722            if replay_bytes > MAX_FEED_REPLAY_BYTES {
14723                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14724            }
14725            previous_hash = Some(item.hash.clone());
14726            after = item.entry.seq;
14727            expected_seq = expected_seq
14728                .checked_add(1)
14729                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14730            if require_full_chain {
14731                all_entries.push(item.clone());
14732            }
14733            observed_entries.push(item.clone());
14734            head_entry = Some(item);
14735        }
14736        if after == seq {
14737            break;
14738        }
14739    }
14740
14741    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14742        return Err(invalid_feed(
14743            "verified chain does not converge on the advertised head",
14744        ));
14745    }
14746    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14747    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14748    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14749    save_canonical_pin_and_alias(
14750        cfg,
14751        &trust_directory,
14752        brain,
14753        &resolved_brain,
14754        TrustState {
14755            v: 2,
14756            origin: normalized_origin(&cfg.hub)?,
14757            requested: resolved_brain.clone(),
14758            brain: resolved_brain.clone(),
14759            home: None,
14760            anchor: anchor.clone(),
14761            current: format!("ed25519:{}", identity.fingerprint),
14762            head_seq: seq,
14763            feed_hash: advertised_hash.clone(),
14764            rotations: identity.rotations.clone(),
14765            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14766            protocol_profile: pinned
14767                .as_ref()
14768                .and_then(|state| state.protocol_profile.clone()),
14769        },
14770        alias_binding.as_ref(),
14771    )?;
14772    Ok(VerifiedRemote {
14773        head: Head {
14774            brain: resolved_brain,
14775            seq,
14776            updated_at,
14777            feed_hash: advertised_hash,
14778            verified: true,
14779        },
14780        identity: Some(identity),
14781        head_entry,
14782        entries: all_entries,
14783        anchor: Some(anchor),
14784    })
14785}
14786
14787/// Read and locally verify the brain's current signed feed head. Identity
14788/// rotation is accepted only through an old-key-signed chain rooted at the
14789/// local TOFU anchor; sequence and hash checkpoints reject rollback and
14790/// equivocation across invocations.
14791pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14792    if let Some(verified) = v2_verified_head(cfg, brain)? {
14793        let observation = Head {
14794            brain: verified.brain_id.clone(),
14795            seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14796            updated_at: verified
14797                .pointer
14798                .as_ref()
14799                .map(|pointer| pointer.signed_at.clone()),
14800            feed_hash: verified
14801                .pointer
14802                .as_ref()
14803                .map(|pointer| pointer.feed_hash.clone()),
14804            verified: true,
14805        };
14806        accept_v2_head(cfg, &verified)?;
14807        return Ok(observation);
14808    }
14809    Ok(verified_remote_head(cfg, brain, false)?.head)
14810}
14811
14812#[cfg(test)]
14813mod tests {
14814    use super::*;
14815
14816    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14817
14818    fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14819        json!({
14820            "sha256": "a".repeat(64),
14821            "bytes": 10,
14822            "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14823        })
14824    }
14825
14826    #[test]
14827    fn upload_reservations_batch_by_count_and_by_size() {
14828        // A real store's first push declares one blob per changed file, which
14829        // does not fit in one request. Every batch has to stay inside both
14830        // bounds, and concatenating them has to reproduce the original order.
14831        let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14832        let batches = batch_upload_declarations(declarations.clone());
14833
14834        assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14835        for batch in &batches {
14836            assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14837            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14838                .expect("batch serializes")
14839                .len();
14840            assert!(
14841                bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14842                "batch body {bytes} exceeds the reservation budget"
14843            );
14844        }
14845        let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14846        assert_eq!(
14847            flattened, declarations,
14848            "batching must preserve the set and order"
14849        );
14850    }
14851
14852    #[test]
14853    fn only_load_shaped_hub_answers_are_worth_asking_again() {
14854        // A migration opens dozens of staging windows, so a busy hub's 429 or
14855        // 5xx must not end the push — while a refusal that states something
14856        // about the request itself has to surface immediately rather than
14857        // being repeated six times behind a minute of waiting.
14858        for status in [408, 429, 500, 502, 503, 504] {
14859            assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14860        }
14861        for status in [400, 401, 403, 404, 409, 413, 422] {
14862            assert!(
14863                !is_retryable_hub_status(status),
14864                "{status} states something about the request"
14865            );
14866        }
14867        // The waits must reach past a whole limiter window to be worth taking.
14868        let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14869        assert!(total >= 60_000, "backoff totals only {total}ms");
14870    }
14871
14872    #[test]
14873    fn a_batch_shares_a_connection_only_within_one_authority() {
14874        // Sharing a pooled agent is what makes a migration stage in minutes,
14875        // but it must never widen where bytes can go: a batch that spans two
14876        // authorities, or names an unsafe URL, falls back to per-object agents
14877        // rather than reusing a connection pinned to somewhere else.
14878        let cfg = HubConfig {
14879            hub: "https://www.sevrahq.com".to_string(),
14880            key: Some("k".to_string()),
14881            agent_key: None,
14882            brain_key: None,
14883            state_dir: PathBuf::from("."),
14884            store_selected: false,
14885        };
14886        assert!(shared_staging_agent(&cfg, &[]).is_none());
14887        assert!(
14888            shared_staging_agent(
14889                &cfg,
14890                &[
14891                    "https://one.example.com/a?sig=1",
14892                    "https://two.example.com/b?sig=2",
14893                ]
14894            )
14895            .is_none(),
14896            "two authorities must not share a pinned pool"
14897        );
14898        assert!(
14899            shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14900            "an unsafe object-store URL must not produce an agent"
14901        );
14902        assert!(
14903            shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14904            "credentials in the URL must not produce an agent"
14905        );
14906    }
14907
14908    #[test]
14909    fn a_staged_change_states_only_operations_and_blobs() {
14910        // The hub refuses a manifest with any other key, so the two sides have
14911        // to agree exactly. A drifting extra field would fail a whole migration
14912        // at the commit, after every byte had already been staged.
14913        let operations = vec![json!({
14914            "op": "put",
14915            "path": "records/a.md",
14916            "blob": "a".repeat(64),
14917            "bytes": 3,
14918        })];
14919        let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14920        let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14921        let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14922        let keys: Vec<&str> = parsed
14923            .as_object()
14924            .expect("manifest is an object")
14925            .keys()
14926            .map(String::as_str)
14927            .collect();
14928        assert_eq!(keys, ["blobs", "operations"]);
14929        assert_eq!(parsed["operations"], Value::Array(operations));
14930        assert_eq!(parsed["blobs"], blobs);
14931    }
14932
14933    #[test]
14934    fn a_staged_push_signs_the_change_not_the_transport() {
14935        // Self-custody signs a request hash the hub recomputes from the parsed
14936        // change. A staged body no longer states its operations, so the signed
14937        // view has to restore them or every large self-custodied push would
14938        // sign a different request than the one the hub validated.
14939        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14940        let staged = json!({
14941            "mutation_id": "dbmd-1",
14942            "rebase": "strict",
14943            "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14944        });
14945        let view = v2_signed_request_view(&staged, &operations);
14946        assert_eq!(view["operations"], Value::Array(operations.clone()));
14947        assert!(view.get("staged_change").is_none());
14948        assert_eq!(view["mutation_id"], staged["mutation_id"]);
14949
14950        let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14951        assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14952    }
14953
14954    #[test]
14955    fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14956        let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
14957        let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
14958            .expect_err("an oversized change must not be staged");
14959        assert!(
14960            matches!(error, LinkError::PushTooLarge { .. }),
14961            "expected a size refusal, got {error:?}"
14962        );
14963    }
14964
14965    #[test]
14966    fn a_push_that_fits_the_request_is_left_inline() {
14967        // The staged lane costs a reservation and a round trip, so it must
14968        // engage only when the body genuinely does not fit. The hub here is a
14969        // closed port: reaching for it at all would fail this test.
14970        let cfg = HubConfig {
14971            hub: "http://127.0.0.1:9".to_string(),
14972            key: Some("k".to_string()),
14973            agent_key: None,
14974            brain_key: None,
14975            state_dir: PathBuf::from("."),
14976            store_selected: false,
14977        };
14978        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14979        let mut body = json!({
14980            "mutation_id": "dbmd-1",
14981            "operations": operations,
14982            "blobs": [],
14983        });
14984        stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
14985        assert!(body.get("staged_change").is_none());
14986        assert_eq!(body["operations"], Value::Array(operations));
14987    }
14988
14989    #[test]
14990    fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
14991        // Byte budget, not just count. One hash can carry many coordinates
14992        // (the same bytes reachable at several paths), so a batch sized only by
14993        // count would blow the body budget on a deduplicated store.
14994        let declarations: Vec<Value> = (0..2_000)
14995            .map(|index| {
14996                json!({
14997                    "sha256": "a".repeat(64),
14998                    "bytes": 10,
14999                    "coordinates": (0..24)
15000                        .map(|slot| format!(
15001                            "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15002                        ))
15003                        .collect::<Vec<_>>(),
15004                })
15005            })
15006            .collect();
15007        let batches = batch_upload_declarations(declarations);
15008        assert!(
15009            batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15010            "wide coordinate sets must bound the batch by size"
15011        );
15012        for batch in &batches {
15013            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15014                .expect("batch serializes")
15015                .len();
15016            assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15017        }
15018    }
15019
15020    #[test]
15021    fn a_small_push_still_rides_exactly_one_request() {
15022        let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
15023        assert_eq!(batch_upload_declarations(declarations).len(), 1);
15024        assert!(batch_upload_declarations(Vec::new()).is_empty());
15025    }
15026
15027    #[test]
15028    fn exact_source_move_becomes_one_provenance_preserving_rename() {
15029        let hash = "a".repeat(64);
15030        let operations = vec![
15031            json!({
15032                "op": "put",
15033                "path": "sources/curated/item.md",
15034                "expected": { "kind": "absent" },
15035                "blob": hash,
15036                "bytes": 19,
15037            }),
15038            json!({
15039                "op": "delete",
15040                "path": "sources/inbox/item.md",
15041                "expected": { "kind": "blob", "hash": hash },
15042            }),
15043        ];
15044
15045        assert_eq!(
15046            infer_exact_source_promotions(operations),
15047            vec![json!({
15048                "op": "rename",
15049                "from": "sources/inbox/item.md",
15050                "to": "sources/curated/item.md",
15051                "expected_from": { "kind": "blob", "hash": hash },
15052                "expected_to": { "kind": "absent" },
15053                "blob": hash,
15054                "bytes": 19,
15055            })]
15056        );
15057    }
15058
15059    #[test]
15060    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
15061        let hash = "b".repeat(64);
15062        let operations = vec![
15063            json!({
15064                "op": "delete",
15065                "path": "sources/inbox/a.md",
15066                "expected": { "kind": "blob", "hash": hash },
15067            }),
15068            json!({
15069                "op": "delete",
15070                "path": "sources/inbox/b.md",
15071                "expected": { "kind": "blob", "hash": hash },
15072            }),
15073            json!({
15074                "op": "put",
15075                "path": "sources/curated/item.md",
15076                "expected": { "kind": "absent" },
15077                "blob": hash,
15078                "bytes": 19,
15079            }),
15080        ];
15081
15082        assert_eq!(
15083            infer_exact_source_promotions(operations.clone()),
15084            operations,
15085            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
15086        );
15087    }
15088
15089    #[test]
15090    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
15091        let hash = "c".repeat(64);
15092        let mut candidate = std::collections::BTreeMap::from([(
15093            "sources/inbox/item.md".to_string(),
15094            V2BaselineFile {
15095                sha256: hash.clone(),
15096                bytes: 19,
15097                proof: None,
15098            },
15099        )]);
15100        let mut candidate_assets = std::collections::BTreeMap::new();
15101        let operations = vec![
15102            json!({
15103                "op": "rename",
15104                "from": "sources/inbox/item.md",
15105                "to": "sources/curated/item.md",
15106                "expected_from": { "kind": "blob", "hash": hash },
15107                "expected_to": { "kind": "absent" },
15108                "blob": hash,
15109                "bytes": 19,
15110            }),
15111            json!({
15112                "op": "put",
15113                "path": "records/rsvps/item.md",
15114                "expected": { "kind": "absent" },
15115                "blob": "d".repeat(64),
15116                "bytes": 23,
15117            }),
15118        ];
15119
15120        assert!(!apply_generated_v2_operations(
15121            &operations,
15122            &std::collections::BTreeMap::new(),
15123            &mut candidate,
15124            &mut candidate_assets,
15125        )
15126        .unwrap());
15127        assert!(!candidate.contains_key("sources/inbox/item.md"));
15128        assert_eq!(
15129            candidate
15130                .get("sources/curated/item.md")
15131                .map(|file| (&file.sha256, file.bytes)),
15132            Some((&hash, 19))
15133        );
15134        assert_eq!(
15135            candidate
15136                .get("records/rsvps/item.md")
15137                .map(|file| (file.sha256.as_str(), file.bytes)),
15138            Some((
15139                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
15140                23
15141            ))
15142        );
15143    }
15144
15145    fn merge_fixture(
15146        base: Option<&str>,
15147        remote: Option<&str>,
15148        local: Option<&str>,
15149        keep_local: bool,
15150    ) -> V2PulledMerge<String> {
15151        let map = |value: Option<&str>| {
15152            value
15153                .map(|value| [("records/a.md".to_string(), value.to_string())])
15154                .into_iter()
15155                .flatten()
15156                .collect::<std::collections::BTreeMap<_, _>>()
15157        };
15158        merge_v2_pulled_records(
15159            &map(base),
15160            &map(remote),
15161            &map(local),
15162            |value, _| value.clone(),
15163            |value, _| value.clone(),
15164            |_| keep_local,
15165        )
15166    }
15167
15168    #[test]
15169    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
15170        let path = "records/a.md".to_string();
15171
15172        let local_add = merge_fixture(None, None, Some("local"), false);
15173        assert_eq!(
15174            local_add.records.get(&path).map(String::as_str),
15175            Some("local")
15176        );
15177        assert!(local_add.accept_remote.is_empty());
15178        assert!(local_add.conflicts.is_empty());
15179
15180        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
15181        assert_eq!(
15182            local_edit.records.get(&path).map(String::as_str),
15183            Some("local")
15184        );
15185        assert!(local_edit.accept_remote.is_empty());
15186        assert!(local_edit.conflicts.is_empty());
15187
15188        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
15189        assert!(!local_delete.records.contains_key(&path));
15190        assert!(local_delete.accept_remote.is_empty());
15191        assert!(local_delete.conflicts.is_empty());
15192
15193        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
15194        assert_eq!(
15195            remote_edit.records.get(&path).map(String::as_str),
15196            Some("remote")
15197        );
15198        assert!(remote_edit.accept_remote.contains(&path));
15199        assert!(remote_edit.conflicts.is_empty());
15200
15201        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
15202        assert!(!remote_delete.records.contains_key(&path));
15203        assert!(remote_delete.accept_remote.contains(&path));
15204        assert!(remote_delete.conflicts.is_empty());
15205
15206        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
15207        assert_eq!(
15208            same_edit.records.get(&path).map(String::as_str),
15209            Some("same")
15210        );
15211        assert!(same_edit.accept_remote.contains(&path));
15212        assert!(same_edit.conflicts.is_empty());
15213
15214        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
15215        assert_eq!(conflict.conflicts, vec![path.clone()]);
15216        assert_eq!(
15217            conflict.records.get(&path).map(String::as_str),
15218            Some("local")
15219        );
15220        assert!(conflict.accept_remote.is_empty());
15221
15222        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
15223        assert_eq!(
15224            kept_home.records.get(&path).map(String::as_str),
15225            Some("local")
15226        );
15227        assert!(kept_home.accept_remote.is_empty());
15228        assert!(kept_home.conflicts.is_empty());
15229    }
15230
15231    #[test]
15232    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
15233        let path = "sources/report.pdf";
15234        let record = crate::AssetRecord {
15235            path: path.to_string(),
15236            sha256: "a".repeat(64),
15237            bytes: 42,
15238            media_type: "application/pdf".to_string(),
15239            wrappers: vec!["gzip".to_string()],
15240            required: true,
15241        };
15242        let mut remote = V2BaselineAsset {
15243            blob_sha256: record.sha256.clone(),
15244            bytes: record.bytes,
15245            media_type: record.media_type.clone(),
15246            wrappers: record.wrappers.clone(),
15247            required: record.required,
15248            disposition: "withheld".to_string(),
15249            leaf_hash: "b".repeat(64),
15250        };
15251
15252        assert!(v2_asset_resumes_hosting(
15253            Some(&remote),
15254            path,
15255            &record,
15256            "hosted"
15257        ));
15258        assert!(!v2_asset_resumes_hosting(
15259            Some(&remote),
15260            path,
15261            &record,
15262            "withheld"
15263        ));
15264
15265        remote.disposition = "hosted".to_string();
15266        assert!(!v2_asset_resumes_hosting(
15267            Some(&remote),
15268            path,
15269            &record,
15270            "hosted"
15271        ));
15272
15273        remote.disposition = "withheld".to_string();
15274        remote.blob_sha256 = "c".repeat(64);
15275        assert!(!v2_asset_resumes_hosting(
15276            Some(&remote),
15277            path,
15278            &record,
15279            "hosted"
15280        ));
15281        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
15282    }
15283
15284    #[test]
15285    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
15286        let path = "records/team/alpha.md".to_string();
15287        let deleted_path = "records/team/deleted.md".to_string();
15288        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
15289            sha256,
15290            bytes,
15291            file: None,
15292        };
15293        let files = vec![
15294            V2ConflictFile {
15295                path: path.clone(),
15296                base: coordinate(None, None),
15297                local: coordinate(Some("b".repeat(64)), Some(7)),
15298                remote: coordinate(Some("a".repeat(64)), Some(5)),
15299            },
15300            V2ConflictFile {
15301                path: deleted_path.clone(),
15302                base: coordinate(Some("c".repeat(64)), Some(9)),
15303                local: coordinate(Some("d".repeat(64)), Some(11)),
15304                remote: coordinate(None, None),
15305            },
15306        ];
15307        let proven = V2BaselineFile {
15308            sha256: "a".repeat(64),
15309            bytes: 5,
15310            proof: None,
15311        };
15312        let current = [(path.clone(), proven.clone())]
15313            .into_iter()
15314            .collect::<std::collections::BTreeMap<_, _>>();
15315
15316        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
15317        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15318        assert_eq!(deleted, vec![deleted_path.clone()]);
15319
15320        let changed = [(
15321            path.clone(),
15322            V2BaselineFile {
15323                sha256: "e".repeat(64),
15324                bytes: 5,
15325                proof: None,
15326            },
15327        )]
15328        .into_iter()
15329        .collect::<std::collections::BTreeMap<_, _>>();
15330        assert!(v2_take_remote_selection(&files, &changed).is_err());
15331
15332        let resurrected = [
15333            (path, proven),
15334            (
15335                deleted_path,
15336                V2BaselineFile {
15337                    sha256: "f".repeat(64),
15338                    bytes: 13,
15339                    proof: None,
15340                },
15341            ),
15342        ]
15343        .into_iter()
15344        .collect::<std::collections::BTreeMap<_, _>>();
15345        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15346    }
15347
15348    #[cfg(target_os = "linux")]
15349    #[test]
15350    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15351        use std::os::fd::AsRawFd as _;
15352
15353        let sandbox = tempfile::TempDir::new().unwrap();
15354        let parent = std::fs::File::open(sandbox.path()).unwrap();
15355        let stage = std::ffi::CString::new("stage").unwrap();
15356        let destination = std::ffi::CString::new("brain").unwrap();
15357
15358        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15359        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15360        install_stage_at(
15361            parent.as_raw_fd(),
15362            stage.as_c_str(),
15363            destination.as_c_str(),
15364            false,
15365        )
15366        .unwrap();
15367        assert!(!sandbox.path().join("stage").exists());
15368        assert_eq!(
15369            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15370            b"created"
15371        );
15372
15373        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15374        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15375        install_stage_at(
15376            parent.as_raw_fd(),
15377            stage.as_c_str(),
15378            destination.as_c_str(),
15379            true,
15380        )
15381        .unwrap();
15382        assert_eq!(
15383            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15384            b"replacement"
15385        );
15386        assert_eq!(
15387            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15388            b"created",
15389            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15390        );
15391    }
15392
15393    struct SignedRemoteFixture {
15394        card: String,
15395        feed: String,
15396        key: AgentSigningKey,
15397        identity: FeedIdentity,
15398    }
15399
15400    fn signed_remote_fixture() -> SignedRemoteFixture {
15401        let rng = ring::rand::SystemRandom::new();
15402        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15403        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15404        let (public_key, multikey) = public_identity_for(&pair);
15405        let identity = FeedIdentity {
15406            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15407            public_key_spki: public_key.clone(),
15408            previous: Vec::new(),
15409            rotations: Vec::new(),
15410        };
15411        let mut entry = FeedEntry {
15412            v: 1,
15413            seq: 1,
15414            ts: "2026-07-30T12:00:00.000Z".to_string(),
15415            brain: multikey.clone(),
15416            public_key: public_key.clone(),
15417            kind: "push".to_string(),
15418            op: "snapshot".to_string(),
15419            pack_sha256: "a".repeat(64),
15420            files: Vec::new(),
15421            removed: Vec::new(),
15422            prev_entry_hash: None,
15423            sig: String::new(),
15424        };
15425        let unsigned = UnsignedFeedEntry {
15426            v: entry.v,
15427            seq: entry.seq,
15428            ts: &entry.ts,
15429            brain: &entry.brain,
15430            public_key: &entry.public_key,
15431            kind: &entry.kind,
15432            op: &entry.op,
15433            pack_sha256: &entry.pack_sha256,
15434            files: &entry.files,
15435            removed: &entry.removed,
15436            prev_entry_hash: &entry.prev_entry_hash,
15437        };
15438        entry.sig =
15439            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15440        let mut exact = serde_json::to_vec(&entry).unwrap();
15441        exact.push(b'\n');
15442        let hash = content_sha256(&exact);
15443        let card = json!({
15444            "id": TEST_BRAIN_ID,
15445            "headSeq": 1,
15446            "feedHash": hash,
15447            "identity": identity.clone(),
15448        })
15449        .to_string();
15450        let feed = json!({
15451            "headSeq": 1,
15452            "feedHash": hash,
15453            "identity": identity.clone(),
15454            "entries": [{"hash": hash, "entry": entry}],
15455            "scopeLimited": false,
15456        })
15457        .to_string();
15458        SignedRemoteFixture {
15459            card,
15460            feed,
15461            key: AgentSigningKey {
15462                pkcs8: pkcs8.as_ref().to_vec(),
15463                multikey,
15464                public_key_spki: public_key,
15465            },
15466            identity,
15467        }
15468    }
15469
15470    #[test]
15471    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15472        let file = |path: &str, byte: char| FeedFile {
15473            path: path.to_string(),
15474            sha256: byte.to_string().repeat(64),
15475            bytes: 1,
15476        };
15477        let a0 = file("records/a.md", 'a');
15478        let a1 = file("records/a.md", 'b');
15479        let stable = file("records/stable.md", 'c');
15480        let added = file("records/added.md", 'd');
15481        let removed_file = file("records/removed.md", 'e');
15482        let previous = vec![a0, stable.clone(), removed_file.clone()];
15483        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15484        let removed = vec![removed_file.path.clone()];
15485
15486        assert_eq!(
15487            verify_v1_manifest_disclosure(
15488                "edit",
15489                &previous,
15490                &resulting,
15491                &[a1.clone(), added.clone()],
15492                &removed,
15493            ),
15494            Ok(())
15495        );
15496        assert_eq!(
15497            verify_v1_manifest_disclosure(
15498                "edit",
15499                &previous,
15500                &resulting,
15501                &[stable.clone(), added.clone(), a1.clone()],
15502                &removed,
15503            ),
15504            Ok(())
15505        );
15506        assert_eq!(
15507            verify_v1_manifest_disclosure(
15508                "edit",
15509                &previous,
15510                &resulting,
15511                std::slice::from_ref(&added),
15512                &removed,
15513            ),
15514            Err(V1DisclosureError::EditMissingChange)
15515        );
15516        assert_eq!(
15517            verify_v1_manifest_disclosure(
15518                "edit",
15519                &previous,
15520                &resulting,
15521                &[file("records/a.md", 'f'), added.clone()],
15522                &removed,
15523            ),
15524            Err(V1DisclosureError::EditFalseFile)
15525        );
15526        assert_eq!(
15527            verify_v1_manifest_disclosure(
15528                "edit",
15529                &previous,
15530                &resulting,
15531                &[a1.clone(), added.clone()],
15532                &[],
15533            ),
15534            Err(V1DisclosureError::RemovedMismatch)
15535        );
15536        assert_eq!(
15537            verify_v1_manifest_disclosure(
15538                "push",
15539                &previous,
15540                &resulting,
15541                &[added.clone(), stable, a1],
15542                &removed,
15543            ),
15544            Ok(())
15545        );
15546        assert_eq!(
15547            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15548            Err(V1DisclosureError::PushManifestMismatch)
15549        );
15550    }
15551
15552    #[test]
15553    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15554        let fixture = signed_remote_fixture();
15555        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15556        let item = feed["entries"][0].to_string();
15557        let oversized_page = format!(
15558            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15559            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15560                .collect::<Vec<_>>()
15561                .join(",")
15562        );
15563        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15564
15565        let oversized_identity = format!(
15566            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15567            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15568                .collect::<Vec<_>>()
15569                .join(",")
15570        );
15571        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15572
15573        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15574        let oversized_entry = format!(
15575            "{{\"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\"}}",
15576            "a".repeat(64),
15577            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15578                .collect::<Vec<_>>()
15579                .join(",")
15580        );
15581        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15582    }
15583
15584    #[test]
15585    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15586        let id = "01arz3ndektsv4rrffq69g5fav";
15587        let digest = "a".repeat(64);
15588        assert_eq!(
15589            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15590            V2BulkConfirmation {
15591                id: id.to_string(),
15592                digest,
15593            }
15594        );
15595        for invalid in [
15596            "",
15597            "01arz3ndektsv4rrffq69g5fav",
15598            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15599            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15600            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15601        ] {
15602            assert!(matches!(
15603                V2BulkConfirmation::parse(invalid),
15604                Err(LinkError::InvalidPack { .. })
15605            ));
15606        }
15607    }
15608
15609    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15610        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15611        use std::net::TcpListener;
15612
15613        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15614        let url = format!("http://{}", listener.local_addr().unwrap());
15615        let handle = std::thread::spawn(move || {
15616            for (status, body) in responses {
15617                let (stream, _) = listener.accept().unwrap();
15618                let mut reader = BufReader::new(stream);
15619                let mut line = String::new();
15620                reader.read_line(&mut line).unwrap();
15621                let mut content_length = 0usize;
15622                loop {
15623                    line.clear();
15624                    reader.read_line(&mut line).unwrap();
15625                    if line == "\r\n" || line == "\n" || line.is_empty() {
15626                        break;
15627                    }
15628                    if let Some((name, value)) = line.split_once(':') {
15629                        if name.eq_ignore_ascii_case("content-length") {
15630                            content_length = value.trim().parse().unwrap();
15631                        }
15632                    }
15633                }
15634                let mut request_body = vec![0_u8; content_length];
15635                reader.read_exact(&mut request_body).unwrap();
15636                let response = format!(
15637                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15638                    body.len()
15639                );
15640                reader.get_mut().write_all(response.as_bytes()).unwrap();
15641            }
15642        });
15643        (url, handle)
15644    }
15645
15646    fn routed_json_hub(
15647        requests: usize,
15648        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15649    ) -> (String, std::thread::JoinHandle<()>) {
15650        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15651        use std::net::TcpListener;
15652
15653        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15654        let url = format!("http://{}", listener.local_addr().unwrap());
15655        let handle = std::thread::spawn(move || {
15656            for _ in 0..requests {
15657                let (stream, _) = listener.accept().unwrap();
15658                let mut reader = BufReader::new(stream);
15659                let mut line = String::new();
15660                reader.read_line(&mut line).unwrap();
15661                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15662                let mut content_length = 0usize;
15663                loop {
15664                    line.clear();
15665                    reader.read_line(&mut line).unwrap();
15666                    if line == "\r\n" || line == "\n" || line.is_empty() {
15667                        break;
15668                    }
15669                    if let Some((name, value)) = line.split_once(':') {
15670                        if name.eq_ignore_ascii_case("content-length") {
15671                            content_length = value.trim().parse().unwrap();
15672                        }
15673                    }
15674                }
15675                let mut request_body = vec![0_u8; content_length];
15676                reader.read_exact(&mut request_body).unwrap();
15677                let (status, body) = respond(&path);
15678                let response = format!(
15679                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15680                    body.len()
15681                );
15682                reader.get_mut().write_all(response.as_bytes()).unwrap();
15683            }
15684        });
15685        (url, handle)
15686    }
15687
15688    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15689        HubConfig {
15690            hub,
15691            key: Some("test-key".to_string()),
15692            agent_key: None,
15693            brain_key: None,
15694            state_dir,
15695            store_selected: false,
15696        }
15697    }
15698
15699    #[cfg(any(unix, windows))]
15700    #[test]
15701    fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
15702        use std::sync::{Arc, Mutex};
15703
15704        let bytes = b"immutable asset bytes".to_vec();
15705        let sha256 = content_sha256(&bytes);
15706        let commit_hash = "c".repeat(64);
15707        let base_url = Arc::new(Mutex::new(String::new()));
15708        let server_base = Arc::clone(&base_url);
15709        let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15710        let server_attempt = Arc::clone(&object_attempt);
15711        let response_bytes = bytes.clone();
15712        let response_sha = sha256.clone();
15713        let response_commit = commit_hash.clone();
15714        let (hub, server) = routed_json_hub(4, move |path| {
15715            if path.contains("/v2/assets/downloads") {
15716                let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
15717                let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
15718                return (
15719                    200,
15720                    json!({
15721                        "v": 2,
15722                        "commit": response_commit,
15723                        "downloads": [{
15724                            "path": "assets/proof.bin",
15725                            "sha256": response_sha,
15726                            "bytes": response_bytes.len(),
15727                            "url": url,
15728                            "method": "GET"
15729                        }]
15730                    })
15731                    .to_string(),
15732                );
15733            }
15734            let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15735            if attempt == 0 {
15736                (403, "{}".to_string())
15737            } else {
15738                (200, String::from_utf8(response_bytes.clone()).unwrap())
15739            }
15740        });
15741        *base_url.lock().unwrap() = hub.clone();
15742
15743        let temp = tempfile::tempdir().unwrap();
15744        let cache = temp.path().join("cache");
15745        std::fs::create_dir(&cache).unwrap();
15746        let cfg = test_hub_config(hub, temp.path().to_path_buf());
15747        let pointer = V2PointerBody {
15748            v: 2,
15749            brain: TEST_BRAIN_ID.to_string(),
15750            seq: 1,
15751            commit_hash,
15752            feed_hash: "f".repeat(64),
15753            content_root: Some("a".repeat(64)),
15754            asset_root: Some("b".repeat(64)),
15755            materializer: "m".repeat(64),
15756            signer_epoch: 1,
15757            control_revision: "d".repeat(64),
15758            backup_preparation: "ready".to_string(),
15759            prior_pointer_hash: None,
15760            signed_at: "2026-08-23T00:00:00Z".to_string(),
15761        };
15762        let path = "assets/proof.bin".to_string();
15763        let asset = V2BaselineAsset {
15764            blob_sha256: sha256.clone(),
15765            bytes: bytes.len() as u64,
15766            media_type: "application/octet-stream".to_string(),
15767            wrappers: Vec::new(),
15768            required: true,
15769            disposition: "hosted".to_string(),
15770            leaf_hash: "e".repeat(64),
15771        };
15772
15773        let staged = stage_v2_asset_download_window(
15774            &cfg,
15775            TEST_BRAIN_ID,
15776            &pointer,
15777            &cache,
15778            &[(&path, &asset)],
15779        )
15780        .expect("a fresh authority-checked capability recovers an expired one");
15781        assert_eq!(staged.len(), 1);
15782        assert_eq!(staged[0].path, path);
15783        assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
15784        assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
15785        server.join().unwrap();
15786    }
15787
15788    #[cfg(any(unix, windows))]
15789    #[test]
15790    fn asset_capability_windows_cannot_exceed_the_worker_bound() {
15791        let temp = tempfile::tempdir().unwrap();
15792        let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
15793        let pointer = V2PointerBody {
15794            v: 2,
15795            brain: TEST_BRAIN_ID.to_string(),
15796            seq: 1,
15797            commit_hash: "c".repeat(64),
15798            feed_hash: "f".repeat(64),
15799            content_root: Some("a".repeat(64)),
15800            asset_root: Some("b".repeat(64)),
15801            materializer: "m".repeat(64),
15802            signer_epoch: 1,
15803            control_revision: "d".repeat(64),
15804            backup_preparation: "ready".to_string(),
15805            prior_pointer_hash: None,
15806            signed_at: "2026-08-23T00:00:00Z".to_string(),
15807        };
15808        let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
15809            .map(|index| format!("assets/{index}.bin"))
15810            .collect::<Vec<_>>();
15811        let assets = paths
15812            .iter()
15813            .map(|_| V2BaselineAsset {
15814                blob_sha256: "a".repeat(64),
15815                bytes: 1,
15816                media_type: "application/octet-stream".to_string(),
15817                wrappers: Vec::new(),
15818                required: true,
15819                disposition: "hosted".to_string(),
15820                leaf_hash: "b".repeat(64),
15821            })
15822            .collect::<Vec<_>>();
15823        let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
15824
15825        let error =
15826            stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
15827                .expect_err("an oversized window must fail before any network request");
15828        assert!(matches!(error, LinkError::InvalidFeed { .. }));
15829    }
15830
15831    #[test]
15832    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15833        use ring::signature::KeyPair as _;
15834
15835        let rng = ring::rand::SystemRandom::new();
15836        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15837        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15838        let (spki, multikey) = public_identity_for(&pair);
15839        let key = AgentSigningKey {
15840            pkcs8: pkcs8.as_ref().to_vec(),
15841            multikey,
15842            public_key_spki: spki,
15843        };
15844        let header = linkmd_sig_header(
15845            &key,
15846            "https://hub-a.example",
15847            "post",
15848            "/api/hub/brains/brain/push?mode=exact",
15849            Some("{\"ok\":true}"),
15850        )
15851        .unwrap();
15852        assert!(header.starts_with("LinkMD-Sig v2,"));
15853        let ts = header
15854            .split(",ts=")
15855            .nth(1)
15856            .unwrap()
15857            .split(',')
15858            .next()
15859            .unwrap();
15860        let signature = URL_SAFE_NO_PAD
15861            .decode(header.rsplit(",sig=").next().unwrap())
15862            .unwrap();
15863        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15864        let accepted = format!(
15865            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15866        );
15867        let replayed = format!(
15868            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15869        );
15870        let public = pair.public_key().as_ref();
15871        assert!(UnparsedPublicKey::new(&ED25519, public)
15872            .verify(accepted.as_bytes(), &signature)
15873            .is_ok());
15874        assert!(
15875            UnparsedPublicKey::new(&ED25519, public)
15876                .verify(replayed.as_bytes(), &signature)
15877                .is_err(),
15878            "a proof captured at hub A must not authenticate at hub B"
15879        );
15880    }
15881
15882    #[test]
15883    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15884        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15885        let card = json!({
15886            "id": other,
15887            "headSeq": 0,
15888            "identity": signed_remote_fixture().identity,
15889        })
15890        .to_string();
15891        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15892        let state = tempfile::tempdir().unwrap();
15893        let cfg = test_hub_config(hub, state.path().to_path_buf());
15894        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15895        assert!(
15896            error.contains("differs from the explicitly requested"),
15897            "{error}"
15898        );
15899        server.join().unwrap();
15900    }
15901
15902    #[test]
15903    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
15904        let first = signed_remote_fixture().identity;
15905        let second = signed_remote_fixture().identity;
15906        let card = |identity: FeedIdentity| {
15907            json!({
15908                "id": TEST_BRAIN_ID,
15909                "headSeq": 0,
15910                "identity": identity,
15911            })
15912            .to_string()
15913        };
15914        let (hub, server) = scripted_json_hub(vec![
15915            (404, "{}".to_string()),
15916            (200, card(first)),
15917            (404, "{}".to_string()),
15918            (200, card(second)),
15919        ]);
15920        let state = tempfile::tempdir().unwrap();
15921        let cfg = test_hub_config(hub, state.path().to_path_buf());
15922        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15923        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15924        assert!(
15925            error.contains("pinned anchor") || error.contains("forked away"),
15926            "{error}"
15927        );
15928        server.join().unwrap();
15929    }
15930
15931    #[test]
15932    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
15933        let old = signed_remote_fixture();
15934        let new = signed_remote_fixture();
15935        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
15936        let unsigned = serde_json::to_string(&UnsignedRotation {
15937            v: 1,
15938            op: "rotate",
15939            brain: &old.key.multikey,
15940            public_key: &old.key.public_key_spki,
15941            new_brain: &new.key.multikey,
15942            new_public_key: &new.key.public_key_spki,
15943            prior_head_seq: 1,
15944            prior_feed_hash: Some(&"a".repeat(64)),
15945            ts: "2026-07-30T12:00:00.000Z".to_string(),
15946        })
15947        .unwrap();
15948        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
15949        let rotation = format!(
15950            "{},\"sig\":\"{}\"}}",
15951            &unsigned[..unsigned.len() - 1],
15952            signature
15953        );
15954        let identity = FeedIdentity {
15955            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
15956            public_key_spki: new.key.public_key_spki,
15957            previous: vec![PreviousIdentity {
15958                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
15959                public_key_spki: old.key.public_key_spki,
15960            }],
15961            rotations: vec![rotation],
15962        };
15963        let card = json!({
15964            "id": TEST_BRAIN_ID,
15965            "headSeq": 0,
15966            "feedHash": null,
15967            "identity": identity,
15968        })
15969        .to_string();
15970        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15971        let state = tempfile::tempdir().unwrap();
15972        let cfg = test_hub_config(hub, state.path().to_path_buf());
15973        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15974        assert!(
15975            error.contains("rotation claims a feed boundary beyond the advertised head"),
15976            "{error}"
15977        );
15978        assert!(
15979            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
15980            "an inconsistent empty-head identity must not become the TOFU checkpoint"
15981        );
15982        server.join().unwrap();
15983    }
15984
15985    #[test]
15986    fn trust_checkpoint_rejects_a_later_fork() {
15987        let fixture = signed_remote_fixture();
15988        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
15989        fork["feedHash"] = Value::String("b".repeat(64));
15990        let (hub, server) = scripted_json_hub(vec![
15991            (404, "{}".to_string()),
15992            (200, fixture.card),
15993            (200, fixture.feed),
15994            (404, "{}".to_string()),
15995            (200, fork.to_string()),
15996        ]);
15997        let state = tempfile::tempdir().unwrap();
15998        let cfg = test_hub_config(hub, state.path().to_path_buf());
15999        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16000        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
16001        server.join().unwrap();
16002    }
16003
16004    #[test]
16005    fn alias_and_canonical_id_share_one_identity_checkpoint() {
16006        let trusted = signed_remote_fixture();
16007        let attacker = signed_remote_fixture();
16008        let (hub, server) = scripted_json_hub(vec![
16009            (404, "{}".to_string()),
16010            (200, trusted.card),
16011            (200, trusted.feed),
16012            (404, "{}".to_string()),
16013            (200, attacker.card),
16014        ]);
16015        let state = tempfile::tempdir().unwrap();
16016        let cfg = test_hub_config(hub, state.path().to_path_buf());
16017        assert!(head(&cfg, "trusted-slug").unwrap().verified);
16018        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16019        assert!(
16020            error.contains("equivocation")
16021                || error.contains("pinned")
16022                || error.contains("identity"),
16023            "{error}"
16024        );
16025        server.join().unwrap();
16026    }
16027
16028    #[test]
16029    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
16030        let state = tempfile::tempdir().unwrap();
16031        let cfg = test_hub_config(
16032            "https://hub.example".to_string(),
16033            state.path().to_path_buf(),
16034        );
16035        let directory = open_trust_dir(&cfg).unwrap();
16036        let old = TEST_BRAIN_ID;
16037        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16038        save_alias_in(
16039            &cfg,
16040            &directory,
16041            &AliasBinding {
16042                v: 1,
16043                origin: normalized_origin(&cfg.hub).unwrap(),
16044                requested: "company-brain".to_string(),
16045                brain: old.to_string(),
16046                home: Some("company-brain".to_string()),
16047            },
16048        )
16049        .unwrap();
16050
16051        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
16052        assert!(matches!(
16053            error,
16054            LinkError::AliasRebindRequired {
16055                alias,
16056                from,
16057                to
16058            } if alias == "company-brain" && from == old && to == new
16059        ));
16060        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
16061            .unwrap()
16062            .unwrap();
16063        assert_eq!(unchanged.brain, old);
16064        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
16065    }
16066
16067    #[test]
16068    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
16069        let alpha = signed_remote_fixture();
16070        let beta = signed_remote_fixture();
16071        let alpha_card = alpha.card.clone();
16072        let alpha_feed = alpha.feed.clone();
16073        let beta_card = beta.card.clone();
16074        let beta_feed = beta.feed.clone();
16075        let (hub, server) = routed_json_hub(5, move |path| {
16076            if path.ends_with("/v2/head") {
16077                (404, "{}".to_string())
16078            } else if path.contains("/alpha/feed?") {
16079                (200, alpha_feed.clone())
16080            } else if path.contains("/beta/feed?") {
16081                (200, beta_feed.clone())
16082            } else if path.ends_with("/alpha") {
16083                (200, alpha_card.clone())
16084            } else if path.ends_with("/beta") {
16085                (200, beta_card.clone())
16086            } else {
16087                (500, r#"{"error":"unexpected path"}"#.to_string())
16088            }
16089        });
16090        let state = tempfile::tempdir().unwrap();
16091        let cfg = test_hub_config(hub, state.path().to_path_buf());
16092        let alpha_cfg = cfg.clone();
16093        let beta_cfg = cfg;
16094        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
16095        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
16096        let results = [first.join().unwrap(), second.join().unwrap()];
16097        assert_eq!(
16098            results.iter().filter(|result| result.is_ok()).count(),
16099            1,
16100            "only one alias identity may establish canonical TOFU: {results:?}"
16101        );
16102        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
16103        server.join().unwrap();
16104    }
16105
16106    #[cfg(unix)]
16107    #[test]
16108    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
16109        use std::os::unix::fs::symlink;
16110
16111        let fixture = signed_remote_fixture();
16112        let card = json!({
16113            "id": TEST_BRAIN_ID,
16114            "headSeq": 0,
16115            "feedHash": Value::Null,
16116            "identity": fixture.identity,
16117        })
16118        .to_string();
16119        let work = tempfile::tempdir().unwrap();
16120        let outside = tempfile::tempdir().unwrap();
16121        let state = work.path().join("state");
16122        let moved = work.path().join("state-held");
16123        let swap_state = state.clone();
16124        let swap_moved = moved.clone();
16125        let outside_path = outside.path().to_path_buf();
16126        let (hub, server) = routed_json_hub(1, move |_| {
16127            // The client has already opened state/trust before this response.
16128            std::fs::rename(&swap_state, &swap_moved).unwrap();
16129            symlink(&outside_path, &swap_state).unwrap();
16130            (200, card.clone())
16131        });
16132        let cfg = test_hub_config(hub, state);
16133
16134        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
16135        assert_eq!(verified.head.seq, 0);
16136        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
16137        assert!(std::fs::read_dir(moved.join("trust"))
16138            .unwrap()
16139            .flatten()
16140            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
16141        server.join().unwrap();
16142    }
16143
16144    #[test]
16145    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
16146        let remote = signed_remote_fixture();
16147        let unrelated = signed_remote_fixture().key;
16148        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
16149        let state = tempfile::tempdir().unwrap();
16150        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
16151        cfg.brain_key = Some(unrelated);
16152        let error = sync_push(
16153            &cfg,
16154            TEST_BRAIN_ID,
16155            &[("DB.md".to_string(), "signed local content".to_string())],
16156        )
16157        .unwrap_err()
16158        .to_string();
16159        assert!(
16160            error.contains("not the verified current brain identity"),
16161            "{error}"
16162        );
16163        server.join().unwrap();
16164    }
16165
16166    #[test]
16167    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
16168        let remote = signed_remote_fixture();
16169        let new = signed_remote_fixture().key;
16170        let state = tempfile::tempdir().unwrap();
16171        let new_file = state.path().join("new.key");
16172        std::fs::write(
16173            &new_file,
16174            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
16175        )
16176        .unwrap();
16177        #[cfg(unix)]
16178        {
16179            use std::os::unix::fs::PermissionsExt as _;
16180            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
16181        }
16182        let forged = json!({
16183            "brain": TEST_BRAIN_ID,
16184            "identity": {
16185                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
16186                "publicKeySpki": new.public_key_spki,
16187            }
16188        })
16189        .to_string();
16190        let (hub, server) = scripted_json_hub(vec![
16191            (404, "{}".to_string()),
16192            (200, remote.card.clone()),
16193            (200, remote.feed.clone()),
16194            (200, forged),
16195            (200, remote.card),
16196            (200, remote.feed),
16197        ]);
16198        let cfg = test_hub_config(hub, state.path().to_path_buf());
16199        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
16200            .unwrap_err()
16201            .to_string();
16202        assert!(
16203            error.contains("without committing the verified new identity"),
16204            "{error}"
16205        );
16206        server.join().unwrap();
16207    }
16208
16209    #[test]
16210    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
16211        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16212        let raw = format!(
16213            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16214        );
16215        let pack = build_store_pack(&[
16216            (
16217                "DB.md".to_string(),
16218                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
16219            ),
16220            ("records/clients/truth.md".to_string(), raw.clone()),
16221        ])
16222        .unwrap();
16223        let by_id = resolve_from_verified_pack(
16224            "01j5qc3v9k4ym8rwbn2tqe6f7d",
16225            &AddressTarget::Id(record_id.to_string()),
16226            pack.clone(),
16227        )
16228        .unwrap();
16229        assert_eq!(by_id["document"]["summary"], "Signed truth");
16230        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
16231        assert_eq!(
16232            by_id["document"]["contentSha"],
16233            content_sha256(raw.as_bytes())
16234        );
16235
16236        let by_path = resolve_from_verified_pack(
16237            "01j5qc3v9k4ym8rwbn2tqe6f7d",
16238            &AddressTarget::Path("records/clients/truth.md".to_string()),
16239            pack,
16240        )
16241        .unwrap();
16242        assert_eq!(by_path["document"]["id"], record_id);
16243        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
16244
16245        let wrong_id = resolve_from_verified_record_bytes(
16246            TEST_BRAIN_ID,
16247            &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
16248            "records/clients/truth.md".to_string(),
16249            raw.as_bytes().to_vec(),
16250        )
16251        .unwrap_err()
16252        .to_string();
16253        assert!(wrong_id.contains("id differs"), "{wrong_id}");
16254
16255        let wrong_path = resolve_from_verified_record_bytes(
16256            TEST_BRAIN_ID,
16257            &AddressTarget::Path("records/clients/other.md".to_string()),
16258            "records/clients/truth.md".to_string(),
16259            raw.into_bytes(),
16260        )
16261        .unwrap_err()
16262        .to_string();
16263        assert!(wrong_path.contains("path differs"), "{wrong_path}");
16264    }
16265
16266    #[test]
16267    fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
16268        let path = "records/clients/truth.md";
16269        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16270        let raw = format!(
16271            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16272        );
16273        let sha256 = content_sha256(raw.as_bytes());
16274        let mut nonce = 0_u128;
16275        let tree = crate::linkmd_v2::build_content_tree(
16276            &[crate::linkmd_v2::ContentFile {
16277                path: path.to_string(),
16278                blob_hash: sha256.clone(),
16279                bytes: raw.len() as u64,
16280            }],
16281            None,
16282            &mut || {
16283                nonce += 1;
16284                format!("{nonce:032x}")
16285            },
16286        )
16287        .unwrap();
16288        let root = tree.root.clone().unwrap();
16289        let mut directory_root = root.clone();
16290        let mut proof = Vec::new();
16291        for component in path.split('/') {
16292            let inclusion =
16293                crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
16294            let child = match &inclusion {
16295                crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
16296                crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
16297                    panic!("fixture path must have an inclusion proof")
16298                }
16299            };
16300            proof.push(json!({
16301                "directory_root": directory_root,
16302                "component": component,
16303                "proof": inclusion,
16304            }));
16305            directory_root = child;
16306        }
16307        let commit_hash = "c".repeat(64);
16308        let pointer = V2PointerBody {
16309            v: 2,
16310            brain: TEST_BRAIN_ID.to_string(),
16311            seq: 1,
16312            commit_hash: commit_hash.clone(),
16313            feed_hash: "f".repeat(64),
16314            content_root: Some(root.clone()),
16315            asset_root: None,
16316            materializer: "dbmd-projection-v1".to_string(),
16317            signer_epoch: 1,
16318            control_revision: "d".repeat(64),
16319            backup_preparation: "e".repeat(64),
16320            prior_pointer_hash: None,
16321            signed_at: "2026-08-21T12:00:00.000Z".to_string(),
16322        };
16323        let manifest = json!({
16324            "v": 2,
16325            "commit": commit_hash,
16326            "content_root": root,
16327            "files": [{
16328                "path": path,
16329                "sha256": sha256,
16330                "bytes": raw.len(),
16331                "proof": proof,
16332            }],
16333            "next_cursor": Value::Null,
16334        })
16335        .to_string();
16336
16337        let path_manifest = manifest.clone();
16338        let (hub, server) = routed_json_hub(1, move |request| {
16339            assert_eq!(
16340                request,
16341                format!(
16342                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
16343                    "c".repeat(64)
16344                )
16345            );
16346            (200, path_manifest.clone())
16347        });
16348        let state = tempfile::tempdir().unwrap();
16349        let cfg = test_hub_config(hub, state.path().to_path_buf());
16350        let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
16351            .unwrap()
16352            .unwrap();
16353        assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
16354        assert!(by_path.proof.is_some());
16355        server.join().unwrap();
16356
16357        let (hub, server) = routed_json_hub(1, move |request| {
16358            assert_eq!(
16359                request,
16360                format!(
16361                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
16362                    "c".repeat(64)
16363                )
16364            );
16365            (404, r#"{"error":"File not found"}"#.to_string())
16366        });
16367        let state = tempfile::tempdir().unwrap();
16368        let cfg = test_hub_config(hub, state.path().to_path_buf());
16369        assert!(
16370            v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
16371                .unwrap()
16372                .is_none()
16373        );
16374        server.join().unwrap();
16375
16376        let id_manifest = manifest;
16377        let (hub, server) = routed_json_hub(1, move |request| {
16378            assert_eq!(
16379                request,
16380                format!(
16381                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
16382                    "c".repeat(64)
16383                )
16384            );
16385            (200, id_manifest.clone())
16386        });
16387        let state = tempfile::tempdir().unwrap();
16388        let cfg = test_hub_config(hub, state.path().to_path_buf());
16389        let (located_path, by_id) =
16390            v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
16391        assert_eq!(located_path, path);
16392        assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
16393        server.join().unwrap();
16394    }
16395
16396    #[test]
16397    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
16398        let unsorted = vec![
16399            ("records/a.md".to_string(), "alpha\n".to_string()),
16400            ("DB.md".to_string(), "# db\n".to_string()),
16401        ];
16402        let sorted = vec![
16403            ("DB.md".to_string(), "# db\n".to_string()),
16404            ("records/a.md".to_string(), "alpha\n".to_string()),
16405        ];
16406        let pack = build_store_pack(&unsorted).unwrap();
16407
16408        // This digest is shared with the hub implementation. It locks every
16409        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
16410        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
16411        // absence of descriptors/extras/comments/ZIP64.
16412        assert_eq!(pack.len(), 219);
16413        assert_eq!(
16414            content_sha256(&pack),
16415            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
16416        );
16417        assert_eq!(pack, build_store_pack(&sorted).unwrap());
16418        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
16419        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
16420        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
16421
16422        assert_eq!(
16423            parse_store_pack(pack).unwrap(),
16424            vec![
16425                ("DB.md".to_string(), b"# db\n".to_vec()),
16426                ("records/a.md".to_string(), b"alpha\n".to_vec()),
16427            ]
16428        );
16429    }
16430
16431    #[test]
16432    fn canonical_store_pack_validates_every_path_before_writing() {
16433        let duplicate = vec![
16434            ("DB.md".to_string(), "first".to_string()),
16435            ("DB.md".to_string(), "second".to_string()),
16436        ];
16437        assert!(build_store_pack(&duplicate)
16438            .unwrap_err()
16439            .to_string()
16440            .contains("duplicate path"));
16441        assert!(matches!(
16442            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16443            Err(LinkError::UnsafePath { .. })
16444        ));
16445    }
16446
16447    #[test]
16448    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16449        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16450        // One byte stands in for the central directory. The trailer itself is
16451        // structurally valid; the count is the sole reason for refusal.
16452        let mut bytes = vec![0_u8];
16453        let zip64_offset = bytes.len() as u64;
16454        bytes.extend_from_slice(b"PK\x06\x06");
16455        bytes.extend_from_slice(&44_u64.to_le_bytes());
16456        bytes.extend_from_slice(&[0_u8; 12]);
16457        bytes.extend_from_slice(&COUNT.to_le_bytes());
16458        bytes.extend_from_slice(&COUNT.to_le_bytes());
16459        bytes.extend_from_slice(&1_u64.to_le_bytes());
16460        bytes.extend_from_slice(&0_u64.to_le_bytes());
16461        bytes.extend_from_slice(b"PK\x06\x07");
16462        bytes.extend_from_slice(&0_u32.to_le_bytes());
16463        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16464        bytes.extend_from_slice(&1_u32.to_le_bytes());
16465        bytes.extend_from_slice(b"PK\x05\x06");
16466        bytes.extend_from_slice(&0_u16.to_le_bytes());
16467        bytes.extend_from_slice(&0_u16.to_le_bytes());
16468        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16469        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16470        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16471        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16472        bytes.extend_from_slice(&0_u16.to_le_bytes());
16473
16474        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16475            .unwrap_err()
16476            .to_string();
16477        assert!(error.contains("invalid file count"), "{error}");
16478    }
16479
16480    #[test]
16481    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16482        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16483        let mut bytes = vec![0_u8];
16484        let zip64_offset = bytes.len() as u64;
16485        bytes.extend_from_slice(b"PK\x06\x06");
16486        bytes.extend_from_slice(&44_u64.to_le_bytes());
16487        bytes.extend_from_slice(&[0_u8; 12]);
16488        bytes.extend_from_slice(&COUNT.to_le_bytes());
16489        bytes.extend_from_slice(&COUNT.to_le_bytes());
16490        bytes.extend_from_slice(&1_u64.to_le_bytes());
16491        bytes.extend_from_slice(&0_u64.to_le_bytes());
16492        bytes.extend_from_slice(b"PK\x06\x07");
16493        bytes.extend_from_slice(&0_u32.to_le_bytes());
16494        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16495        bytes.extend_from_slice(&1_u32.to_le_bytes());
16496        bytes.extend_from_slice(b"PK\x05\x06");
16497        bytes.extend_from_slice(&0_u16.to_le_bytes());
16498        bytes.extend_from_slice(&0_u16.to_le_bytes());
16499        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16500        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16501        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16502        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16503        bytes.extend_from_slice(&0_u16.to_le_bytes());
16504        // The old parser trusted this last low-count signature, while
16505        // ZipArchive fell back to the real Zip64 directory and allocated for
16506        // COUNT entries. Its central-directory offsets are deliberately fake.
16507        let fake_eocd = bytes.len() as u32;
16508        bytes.extend_from_slice(b"PK\x05\x06");
16509        bytes.extend_from_slice(&0_u16.to_le_bytes());
16510        bytes.extend_from_slice(&0_u16.to_le_bytes());
16511        bytes.extend_from_slice(&1_u16.to_le_bytes());
16512        bytes.extend_from_slice(&1_u16.to_le_bytes());
16513        bytes.extend_from_slice(&0_u32.to_le_bytes());
16514        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16515        bytes.extend_from_slice(&0_u16.to_le_bytes());
16516
16517        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16518            .unwrap_err()
16519            .to_string();
16520        assert!(error.contains("central directory"), "{error}");
16521    }
16522
16523    #[test]
16524    fn strict_http_status_handling_rejects_redirects_without_panicking() {
16525        let error = ensure_ok(
16526            HubResponse {
16527                status: 302,
16528                body: Some(json!({"redirect": "/elsewhere"})),
16529            },
16530            "mutation",
16531        )
16532        .unwrap_err();
16533        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16534
16535        let error = ensure_raw_ok(
16536            RawHubResponse {
16537                status: 302,
16538                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16539            },
16540            "feed",
16541        )
16542        .unwrap_err();
16543        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16544    }
16545
16546    #[cfg(unix)]
16547    #[test]
16548    fn collect_push_files_refuses_external_symlink_and_nested_store() {
16549        use std::os::unix::fs::symlink;
16550
16551        let root = tempfile::tempdir().unwrap();
16552        std::fs::write(
16553            root.path().join("DB.md"),
16554            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16555        )
16556        .unwrap();
16557        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16558
16559        let external = tempfile::tempdir().unwrap();
16560        let secret = external.path().join("secret.md");
16561        std::fs::write(&secret, "TOP SECRET").unwrap();
16562        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16563
16564        let store = Store::open_strict(root.path()).unwrap();
16565        let err = collect_push_files(&store).unwrap_err().to_string();
16566        assert!(err.contains("cannot push"), "{err}");
16567        assert!(
16568            !err.contains("TOP SECRET"),
16569            "external bytes must never leak"
16570        );
16571
16572        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16573        let nested = root.path().join("records/nested");
16574        std::fs::create_dir_all(&nested).unwrap();
16575        std::fs::write(
16576            nested.join("DB.md"),
16577            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16578        )
16579        .unwrap();
16580        let err = collect_push_files(&store).unwrap_err().to_string();
16581        assert!(err.contains("nested db.md store"), "{err}");
16582    }
16583
16584    #[cfg(unix)]
16585    #[test]
16586    fn remote_push_uses_opened_root_after_path_replacement() {
16587        use std::os::unix::fs::symlink;
16588
16589        let sandbox = tempfile::tempdir().unwrap();
16590        let root = sandbox.path().join("store");
16591        std::fs::create_dir_all(root.join("records/notes")).unwrap();
16592        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16593        std::fs::write(
16594            root.join("records/notes/owned.md"),
16595            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16596        )
16597        .unwrap();
16598        let store = Store::open_strict(&root).unwrap();
16599        let detached = sandbox.path().join("detached");
16600        std::fs::rename(&root, &detached).unwrap();
16601
16602        let replacement = sandbox.path().join("replacement");
16603        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16604        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16605        std::fs::write(
16606            replacement.join("records/notes/secret.md"),
16607            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16608        )
16609        .unwrap();
16610        symlink(&replacement, &root).unwrap();
16611
16612        let files = collect_push_files(&store).unwrap();
16613        let wire_text = files
16614            .iter()
16615            .map(|(path, content)| format!("{path}\n{content}"))
16616            .collect::<Vec<_>>()
16617            .join("\n");
16618        assert!(wire_text.contains("owned upload"));
16619        assert!(!wire_text.contains("replacement sentinel"));
16620        assert!(!wire_text.contains("records/notes/secret.md"));
16621
16622        let remote = signed_remote_fixture();
16623        let (hub, server) = scripted_json_hub(vec![
16624            (200, remote.card),
16625            (200, remote.feed),
16626            (200, json!({"ok": true}).to_string()),
16627        ]);
16628        let state = tempfile::tempdir().unwrap();
16629        let cfg = test_hub_config(hub, state.path().to_path_buf());
16630        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16631        assert_eq!(pushed, json!({"ok": true}));
16632        server.join().unwrap();
16633    }
16634
16635    #[test]
16636    fn signed_feed_item_verifies_identity_hash_and_signature() {
16637        use ring::rand::SystemRandom;
16638        use ring::signature::{Ed25519KeyPair, KeyPair};
16639
16640        const PREFIX: &[u8] = &[
16641            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16642        ];
16643        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16644        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16645        let mut spki = PREFIX.to_vec();
16646        spki.extend_from_slice(pair.public_key().as_ref());
16647        let public_key = URL_SAFE_NO_PAD.encode(&spki);
16648        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16649        let mut entry = FeedEntry {
16650            v: 1,
16651            seq: 1,
16652            ts: "2026-07-14T00:00:00.000Z".to_string(),
16653            brain: format!("ed25519:{fingerprint}"),
16654            public_key: public_key.clone(),
16655            kind: "push".to_string(),
16656            op: "snapshot".to_string(),
16657            pack_sha256: "a".repeat(64),
16658            files: vec![FeedFile {
16659                path: "DB.md".to_string(),
16660                sha256: "b".repeat(64),
16661                bytes: 3,
16662            }],
16663            removed: vec![],
16664            prev_entry_hash: None,
16665            sig: String::new(),
16666        };
16667        let unsigned = UnsignedFeedEntry {
16668            v: entry.v,
16669            seq: entry.seq,
16670            ts: &entry.ts,
16671            brain: &entry.brain,
16672            public_key: &entry.public_key,
16673            kind: &entry.kind,
16674            op: &entry.op,
16675            pack_sha256: &entry.pack_sha256,
16676            files: &entry.files,
16677            removed: &entry.removed,
16678            prev_entry_hash: &entry.prev_entry_hash,
16679        };
16680        entry.sig =
16681            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16682        let mut exact = serde_json::to_vec(&entry).unwrap();
16683        exact.push(b'\n');
16684        let item = FeedItem {
16685            hash: format!("{:x}", Sha256::digest(&exact)),
16686            entry,
16687        };
16688        let identity = FeedIdentity {
16689            fingerprint,
16690            public_key_spki: public_key,
16691            previous: Vec::new(),
16692            rotations: Vec::new(),
16693        };
16694        assert!(verify_feed_item(&item, &identity).is_ok());
16695        let mut tampered = item;
16696        tampered.entry.pack_sha256 = "c".repeat(64);
16697        assert!(verify_feed_item(&tampered, &identity).is_err());
16698    }
16699
16700    #[test]
16701    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16702        let rng = ring::rand::SystemRandom::new();
16703        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16704        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16705        let (spki, multikey) = public_identity_for(&pair);
16706        let identity = V2HeadIdentity {
16707            custody: "self".to_string(),
16708            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16709            public_key_spki: spki.clone(),
16710            previous: Vec::new(),
16711            rotations: Vec::new(),
16712        };
16713        let unsigned = json!({
16714            "actor_ref": "a".repeat(64),
16715            "asset_root": Value::Null,
16716            "brain": multikey,
16717            "changes_sha256": "b".repeat(64),
16718            "control_revision": "c".repeat(64),
16719            "materializer": "dbmd-projection-v1",
16720            "op": "changeset",
16721            "parent_asset_root": Value::Null,
16722            "parent_commit": Value::Null,
16723            "parent_root": Value::Null,
16724            "prev_entry_hash": Value::Null,
16725            "public_key": spki,
16726            "seq": 1,
16727            "signer_epoch": 1,
16728            "state_root": "d".repeat(64),
16729            "ts": "2026-08-19T12:00:00.000Z",
16730            "v": 2,
16731            "v1_bridge": {
16732                "feed_hash": "e".repeat(64),
16733                "head_seq": 7,
16734                "pack_sha256": "f".repeat(64),
16735            },
16736        });
16737        let sign_value = |value: Value| {
16738            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16739            let mut object = value.as_object().unwrap().clone();
16740            object.insert(
16741                "sig".to_string(),
16742                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16743            );
16744            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16745        };
16746        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16747
16748        let mut extra = unsigned.clone();
16749        extra
16750            .as_object_mut()
16751            .unwrap()
16752            .insert("future".to_string(), Value::Bool(true));
16753        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16754
16755        let mut missing = unsigned.clone();
16756        missing.as_object_mut().unwrap().remove("v1_bridge");
16757        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16758
16759        let mut invalid_bridge = unsigned;
16760        invalid_bridge.as_object_mut().unwrap().insert(
16761            "v1_bridge".to_string(),
16762            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16763        );
16764        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16765    }
16766
16767    #[test]
16768    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16769        let vector: Value = serde_json::from_str(include_str!(
16770            "../tests/vectors/linkmd-v2-commit-bridge.json"
16771        ))
16772        .unwrap();
16773        let identity_value = vector.get("identity").unwrap();
16774        let identity = V2HeadIdentity {
16775            custody: "self".to_string(),
16776            fingerprint: identity_value
16777                .get("fingerprint")
16778                .and_then(Value::as_str)
16779                .unwrap()
16780                .to_string(),
16781            public_key_spki: identity_value
16782                .get("public_key_spki")
16783                .and_then(Value::as_str)
16784                .unwrap()
16785                .to_string(),
16786            previous: Vec::new(),
16787            rotations: Vec::new(),
16788        };
16789        let private = URL_SAFE_NO_PAD
16790            .decode(
16791                identity_value
16792                    .get("private_key_pkcs8")
16793                    .and_then(Value::as_str)
16794                    .unwrap(),
16795            )
16796            .unwrap();
16797        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16798            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16799            .unwrap();
16800        let base = vector.get("body").unwrap().as_object().unwrap();
16801
16802        for item in vector.get("valid").unwrap().as_array().unwrap() {
16803            let mut body = base.clone();
16804            body.insert(
16805                "v1_bridge".to_string(),
16806                item.get("v1_bridge").unwrap().clone(),
16807            );
16808            body.insert(
16809                "sig".to_string(),
16810                item.get("signature_base64url").unwrap().clone(),
16811            );
16812            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16813            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16814            assert_eq!(
16815                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16816                item.get("commit_hash").and_then(Value::as_str).unwrap()
16817            );
16818            assert_eq!(
16819                format!("{:x}", Sha256::digest(&signed)),
16820                item.get("feed_hash").and_then(Value::as_str).unwrap()
16821            );
16822        }
16823
16824        for item in vector.get("invalid").unwrap().as_array().unwrap() {
16825            let mut body = base.clone();
16826            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16827                for field in remove {
16828                    body.remove(field.as_str().unwrap());
16829                }
16830            }
16831            if let Some(set) = item.get("set").and_then(Value::as_object) {
16832                for (field, value) in set {
16833                    body.insert(field.clone(), value.clone());
16834                }
16835            }
16836            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16837            body.insert(
16838                "sig".to_string(),
16839                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16840            );
16841            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16842            assert!(
16843                verified_v2_commit_object(&signed, &identity).is_err(),
16844                "accepted invalid shared vector {}",
16845                item.get("reason").and_then(Value::as_str).unwrap()
16846            );
16847        }
16848    }
16849
16850    #[test]
16851    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16852        let vector: Value = serde_json::from_str(include_str!(
16853            "../tests/vectors/linkmd-v2-changeset-withheld.json"
16854        ))
16855        .unwrap();
16856        assert_eq!(
16857            vector.get("profile").and_then(Value::as_str),
16858            Some("link.md-v2-changeset-withheld")
16859        );
16860        let canonical =
16861            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16862        let expected = STANDARD
16863            .decode(
16864                vector
16865                    .get("canonical_base64")
16866                    .and_then(Value::as_str)
16867                    .unwrap(),
16868            )
16869            .unwrap();
16870        assert_eq!(canonical, expected);
16871        assert_eq!(
16872            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16873            vector.get("domain_hash").and_then(Value::as_str).unwrap()
16874        );
16875    }
16876
16877    #[test]
16878    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16879        let remote = signed_remote_fixture();
16880        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16881        let legacy_item = legacy.entries.first().unwrap();
16882        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16883        let body = json!({
16884            "actor_ref": "a".repeat(64),
16885            "asset_root": Value::Null,
16886            "brain": remote.key.multikey,
16887            "changes_sha256": "b".repeat(64),
16888            "control_revision": "c".repeat(64),
16889            "materializer": "dbmd-projection-v1",
16890            "op": "changeset",
16891            "parent_asset_root": Value::Null,
16892            "parent_commit": Value::Null,
16893            "parent_root": Value::Null,
16894            "prev_entry_hash": Value::Null,
16895            "public_key": remote.key.public_key_spki,
16896            "seq": 1,
16897            "signer_epoch": 1,
16898            "state_root": "d".repeat(64),
16899            "ts": "2026-08-19T12:00:00.000Z",
16900            "v": 2,
16901            "v1_bridge": {
16902                "feed_hash": legacy_item.hash,
16903                "head_seq": legacy_item.entry.seq,
16904                "pack_sha256": legacy_item.entry.pack_sha256,
16905            },
16906        });
16907        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
16908        let mut signed = body.as_object().unwrap().clone();
16909        signed.insert(
16910            "sig".to_string(),
16911            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16912        );
16913        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
16914        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
16915        let feed_hash = content_sha256(&raw);
16916        let pointer = V2PointerBody {
16917            v: 2,
16918            brain: TEST_BRAIN_ID.to_string(),
16919            seq: 1,
16920            commit_hash: commit_hash.clone(),
16921            feed_hash: feed_hash.clone(),
16922            content_root: Some("d".repeat(64)),
16923            asset_root: None,
16924            materializer: "dbmd-projection-v1".to_string(),
16925            signer_epoch: 1,
16926            control_revision: "c".repeat(64),
16927            backup_preparation: "e".repeat(64),
16928            prior_pointer_hash: None,
16929            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
16930        };
16931        let v2_page = json!({
16932            "v": 2,
16933            "head_seq": 1,
16934            "head_commit_hash": commit_hash,
16935            "head_feed_hash": feed_hash,
16936            "entries": [{
16937                "seq": 1,
16938                "commit_hash": pointer.commit_hash,
16939                "feed_hash": pointer.feed_hash,
16940                "bytes_base64": STANDARD.encode(&raw),
16941            }],
16942            "next_after": 1,
16943            "complete": true,
16944        })
16945        .to_string();
16946        let identity = V2HeadIdentity {
16947            custody: "self".to_string(),
16948            fingerprint: remote.identity.fingerprint.clone(),
16949            public_key_spki: remote.identity.public_key_spki.clone(),
16950            previous: Vec::new(),
16951            rotations: Vec::new(),
16952        };
16953        let checkpoint = TrustState {
16954            v: 2,
16955            origin: "unused".to_string(),
16956            requested: TEST_BRAIN_ID.to_string(),
16957            brain: TEST_BRAIN_ID.to_string(),
16958            home: None,
16959            anchor: remote.key.multikey.clone(),
16960            current: remote.key.multikey,
16961            head_seq: legacy_item.entry.seq,
16962            feed_hash: Some(legacy_item.hash.clone()),
16963            rotations: Vec::new(),
16964            hub_signer: None,
16965            protocol_profile: None,
16966        };
16967        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
16968        let state = tempfile::tempdir().unwrap();
16969        let cfg = test_hub_config(hub, state.path().to_path_buf());
16970        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
16971        server.join().unwrap();
16972
16973        let mut wrong = checkpoint;
16974        wrong.feed_hash = Some("0".repeat(64));
16975        let (hub, server) = scripted_json_hub(vec![(
16976            200,
16977            json!({
16978                "v": 2,
16979                "head_seq": 1,
16980                "head_commit_hash": pointer.commit_hash,
16981                "head_feed_hash": pointer.feed_hash,
16982                "entries": [{
16983                    "seq": 1,
16984                    "commit_hash": pointer.commit_hash,
16985                    "feed_hash": pointer.feed_hash,
16986                    "bytes_base64": STANDARD.encode(&raw),
16987                }],
16988                "next_after": 1,
16989                "complete": true,
16990            })
16991            .to_string(),
16992        )]);
16993        let state = tempfile::tempdir().unwrap();
16994        let cfg = test_hub_config(hub, state.path().to_path_buf());
16995        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
16996        server.join().unwrap();
16997    }
16998
16999    #[test]
17000    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
17001        let rng = ring::rand::SystemRandom::new();
17002        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17003        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17004        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17005        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17006        let (old_spki, old_multikey) = public_identity_for(&old);
17007        let (new_spki, new_multikey) = public_identity_for(&new);
17008        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
17009            v: 1,
17010            op: "rotate",
17011            brain: &old_multikey,
17012            public_key: &old_spki,
17013            new_brain: &new_multikey,
17014            new_public_key: &new_spki,
17015            prior_head_seq: 1,
17016            prior_feed_hash: Some(&"9".repeat(64)),
17017            ts: "2026-08-19T12:01:00.000Z".to_string(),
17018        })
17019        .unwrap();
17020        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
17021        let rotation = format!(
17022            "{},\"sig\":\"{}\"}}",
17023            &rotation_unsigned[..rotation_unsigned.len() - 1],
17024            rotation_sig
17025        );
17026        let identity = V2HeadIdentity {
17027            custody: "self".to_string(),
17028            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17029            public_key_spki: new_spki.clone(),
17030            previous: vec![V2PreviousIdentity {
17031                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17032                public_key_spki: old_spki.clone(),
17033            }],
17034            rotations: vec![rotation],
17035        };
17036        let commit = |seq: u64,
17037                      epoch: u64,
17038                      multikey: &str,
17039                      spki: &str,
17040                      pair: &ring::signature::Ed25519KeyPair| {
17041            let value = json!({
17042                "actor_ref": "a".repeat(64),
17043                "asset_root": Value::Null,
17044                "brain": multikey,
17045                "changes_sha256": "b".repeat(64),
17046                "control_revision": "c".repeat(64),
17047                "materializer": "dbmd-projection-v1",
17048                "op": "changeset",
17049                "parent_asset_root": Value::Null,
17050                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
17051                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
17052                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
17053                "public_key": spki,
17054                "seq": seq,
17055                "signer_epoch": epoch,
17056                "state_root": "1".repeat(64),
17057                "ts": "2026-08-19T12:00:00.000Z",
17058                "v": 2,
17059                "v1_bridge": Value::Null,
17060            });
17061            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17062            let mut object = value.as_object().unwrap().clone();
17063            object.insert(
17064                "sig".to_string(),
17065                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17066            );
17067            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17068        };
17069
17070        assert!(verified_v2_commit_object(
17071            &commit(1, 1, &old_multikey, &old_spki, &old),
17072            &identity,
17073        )
17074        .is_ok());
17075        assert!(verified_v2_commit_object(
17076            &commit(2, 2, &new_multikey, &new_spki, &new),
17077            &identity,
17078        )
17079        .is_ok());
17080        assert!(verified_v2_commit_object(
17081            &commit(2, 1, &old_multikey, &old_spki, &old),
17082            &identity,
17083        )
17084        .is_err());
17085        assert!(verified_v2_commit_object(
17086            &commit(1, 2, &new_multikey, &new_spki, &new),
17087            &identity,
17088        )
17089        .is_err());
17090    }
17091
17092    #[test]
17093    fn a_self_custody_entry_verifies_like_any_hub_entry() {
17094        let rng = ring::rand::SystemRandom::new();
17095        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17096        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17097        let (spki, multikey) = public_identity_for(&pair);
17098        let key = AgentSigningKey {
17099            pkcs8: pkcs8.as_ref().to_vec(),
17100            multikey: multikey.clone(),
17101            public_key_spki: spki.clone(),
17102        };
17103        let files = vec![WireFeedFile {
17104            path: "DB.md".to_string(),
17105            sha256: "a".repeat(64),
17106            bytes: 3,
17107        }];
17108        let raw = self_custody_entry(
17109            &key,
17110            1,
17111            "2026-07-23T12:00:00.000Z".to_string(),
17112            &"c".repeat(64),
17113            &files,
17114            None,
17115        )
17116        .unwrap();
17117        // The exact client serialization parses as a feed entry and passes the
17118        // SAME verifier every subscribe read runs — the self-custody path
17119        // produces first-class wire-profile-v1 entries.
17120        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
17121        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
17122        let item = FeedItem { hash, entry };
17123        let identity = FeedIdentity {
17124            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17125            public_key_spki: spki,
17126            previous: Vec::new(),
17127            rotations: Vec::new(),
17128        };
17129        assert!(verify_feed_item(&item, &identity).is_ok());
17130    }
17131
17132    #[test]
17133    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
17134        let rng = ring::rand::SystemRandom::new();
17135        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17136        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17137        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17138        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17139        let (old_spki, old_multikey) = public_identity_for(&old);
17140        let (new_spki, new_multikey) = public_identity_for(&new);
17141        let unsigned = serde_json::to_string(&UnsignedRotation {
17142            v: 1,
17143            op: "rotate",
17144            brain: &old_multikey,
17145            public_key: &old_spki,
17146            new_brain: &new_multikey,
17147            new_public_key: &new_spki,
17148            prior_head_seq: 1,
17149            prior_feed_hash: Some(&"a".repeat(64)),
17150            ts: "2026-07-30T12:00:00.000Z".to_string(),
17151        })
17152        .unwrap();
17153        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
17154        let rotation = format!(
17155            "{},\"sig\":\"{}\"}}",
17156            &unsigned[..unsigned.len() - 1],
17157            signature
17158        );
17159        let identity = FeedIdentity {
17160            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17161            public_key_spki: new_spki,
17162            previous: vec![PreviousIdentity {
17163                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17164                public_key_spki: old_spki,
17165            }],
17166            rotations: vec![rotation],
17167        };
17168        let pin = TrustState {
17169            v: 2,
17170            origin: "https://hub.example".to_string(),
17171            requested: "brain".to_string(),
17172            brain: "brain".to_string(),
17173            home: None,
17174            anchor: old_multikey.clone(),
17175            current: old_multikey.clone(),
17176            head_seq: 1,
17177            feed_hash: Some("a".repeat(64)),
17178            rotations: Vec::new(),
17179            hub_signer: None,
17180            protocol_profile: None,
17181        };
17182        assert_eq!(
17183            verify_identity_chain(&identity, Some(&pin)).unwrap(),
17184            old_multikey
17185        );
17186        let mut accepted = pin.clone();
17187        accepted.current = new_multikey.clone();
17188        accepted.rotations = identity.rotations.clone();
17189        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
17190            v: 1,
17191            op: "rotate",
17192            brain: &old_multikey,
17193            public_key: &identity.previous[0].public_key_spki,
17194            new_brain: &new_multikey,
17195            new_public_key: &identity.public_key_spki,
17196            prior_head_seq: 1,
17197            prior_feed_hash: Some(&"a".repeat(64)),
17198            ts: "2026-07-30T12:00:01.000Z".to_string(),
17199        })
17200        .unwrap();
17201        let alternate_signature =
17202            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
17203        let mut rewritten = identity.clone();
17204        rewritten.rotations[0] = format!(
17205            "{},\"sig\":\"{}\"}}",
17206            &alternate_unsigned[..alternate_unsigned.len() - 1],
17207            alternate_signature
17208        );
17209        assert!(
17210            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
17211            "an alternate valid statement must not rewrite accepted history"
17212        );
17213
17214        let mut stale_entry = FeedEntry {
17215            v: 1,
17216            seq: 2,
17217            ts: "2026-07-30T12:01:00.000Z".to_string(),
17218            brain: pin.current.clone(),
17219            public_key: identity.previous[0].public_key_spki.clone(),
17220            kind: "push".to_string(),
17221            op: "snapshot".to_string(),
17222            pack_sha256: "b".repeat(64),
17223            files: Vec::new(),
17224            removed: Vec::new(),
17225            prev_entry_hash: pin.feed_hash.clone(),
17226            sig: String::new(),
17227        };
17228        let stale_unsigned = UnsignedFeedEntry {
17229            v: stale_entry.v,
17230            seq: stale_entry.seq,
17231            ts: &stale_entry.ts,
17232            brain: &stale_entry.brain,
17233            public_key: &stale_entry.public_key,
17234            kind: &stale_entry.kind,
17235            op: &stale_entry.op,
17236            pack_sha256: &stale_entry.pack_sha256,
17237            files: &stale_entry.files,
17238            removed: &stale_entry.removed,
17239            prev_entry_hash: &stale_entry.prev_entry_hash,
17240        };
17241        stale_entry.sig = URL_SAFE_NO_PAD.encode(
17242            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
17243                .as_ref(),
17244        );
17245        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
17246        stale_exact.push(b'\n');
17247        let stale_item = FeedItem {
17248            hash: content_sha256(&stale_exact),
17249            entry: stale_entry,
17250        };
17251        assert!(
17252            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
17253                .is_err(),
17254            "a key retired before the checkpoint must never append after it"
17255        );
17256        assert!(
17257            verify_feed_item(&stale_item, &identity).is_err(),
17258            "an old key must never append after its signed rotation boundary"
17259        );
17260
17261        let mut missing = identity.clone();
17262        missing.rotations.clear();
17263        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
17264
17265        let mut tampered = identity;
17266        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
17267        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
17268    }
17269
17270    #[cfg(unix)]
17271    #[test]
17272    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
17273        use std::os::unix::fs::symlink;
17274
17275        let dir = tempfile::tempdir().unwrap();
17276        let target = dir.path().join("valuable.txt");
17277        let planted = dir.path().join("agent.key");
17278        std::fs::write(&target, "do not overwrite").unwrap();
17279        symlink(&target, &planted).unwrap();
17280
17281        assert!(matches!(
17282            generate_agent_key(&planted),
17283            Err(LinkError::BadAgentKey { .. })
17284        ));
17285        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
17286    }
17287
17288    #[cfg(unix)]
17289    #[test]
17290    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
17291        use std::os::unix::fs::symlink;
17292
17293        let root = tempfile::tempdir().unwrap();
17294        let outside = tempfile::tempdir().unwrap();
17295        symlink(outside.path(), root.path().join("redirect")).unwrap();
17296
17297        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
17298        assert!(!outside.path().join("agent.key").exists());
17299    }
17300
17301    // ── Address parsing ─────────────────────────────────────────────────────
17302
17303    #[test]
17304    fn address_bare_brain_with_and_without_sigil() {
17305        for raw in ["@acme-ops", "acme-ops"] {
17306            let a = Address::parse(raw).expect(raw);
17307            assert_eq!(a.brain, "acme-ops");
17308            assert_eq!(a.target, None);
17309        }
17310    }
17311
17312    #[test]
17313    fn address_ulid_target_parses_as_id() {
17314        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
17315        assert_eq!(a.brain, "acme");
17316        assert_eq!(
17317            a.target,
17318            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
17319        );
17320    }
17321
17322    #[test]
17323    fn address_md_path_target_parses_as_path() {
17324        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
17325        assert_eq!(
17326            a.target,
17327            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
17328        );
17329    }
17330
17331    #[test]
17332    fn address_rejects_malformed_forms() {
17333        for raw in [
17334            "",
17335            "@",
17336            "@/x",
17337            "@acme/",
17338            "@acme/../etc/passwd",
17339            "@acme/records/.hidden.md",
17340            "@ACME",             // uppercase is not a hub ref shape
17341            "@acme/notes/x.txt", // target is neither ULID nor .md path
17342            "@a b",              // whitespace
17343        ] {
17344            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
17345        }
17346    }
17347
17348    // ── Path safety ─────────────────────────────────────────────────────────
17349
17350    #[test]
17351    fn safe_paths_accept_store_shapes_and_reject_escapes() {
17352        for ok in [
17353            "DB.md",
17354            "assets.jsonl",
17355            "records/clients/lumio.md",
17356            "sources/emails/2026/07/x.md",
17357        ] {
17358            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
17359        }
17360        for bad in [
17361            "",
17362            "/etc/passwd",
17363            "../up.md",
17364            "records/../../up.md",
17365            "records//x.md",
17366            ".dbmd/config",
17367            "records/.hidden/x.md",
17368            "records/a b.md",
17369            "records\\win.md",
17370        ] {
17371            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
17372        }
17373    }
17374
17375    #[cfg(unix)]
17376    #[test]
17377    fn opened_destination_capability_survives_an_ancestor_path_swap() {
17378        use std::os::unix::fs::symlink;
17379
17380        let work = tempfile::tempdir().unwrap();
17381        let outside = tempfile::tempdir().unwrap();
17382        let original = work.path().join("destination");
17383        let moved = work.path().join("destination-moved");
17384        let directory = open_or_create_dir_nofollow(&original).unwrap();
17385
17386        std::fs::rename(&original, &moved).unwrap();
17387        symlink(outside.path(), &original).unwrap();
17388        write_pull_entries_beneath_dir(
17389            &directory,
17390            &[("records/note.md".to_string(), b"held inode".to_vec())],
17391        )
17392        .unwrap();
17393
17394        assert_eq!(
17395            std::fs::read(moved.join("records/note.md")).unwrap(),
17396            b"held inode"
17397        );
17398        assert!(!outside.path().join("records/note.md").exists());
17399    }
17400
17401    // ── Config resolution (flag + file precedence; env is covered by the CLI
17402    //    integration tests, where a child process isolates it) ───────────────
17403
17404    #[test]
17405    fn hub_config_flag_beats_file_and_requires_some_source() {
17406        let dir = tempfile::tempdir().unwrap();
17407        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
17408        std::fs::write(
17409            dir.path().join(CONFIG_REL_PATH),
17410            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
17411        )
17412        .unwrap();
17413
17414        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
17415        assert_eq!(from_flag.hub, "https://flag.example.com");
17416
17417        let from_file = hub_config(None, dir.path()).unwrap();
17418        assert_eq!(from_file.hub, "https://file.example.com");
17419
17420        let none = hub_config(None, tempfile::tempdir().unwrap().path());
17421        assert!(matches!(none, Err(LinkError::NoHub)));
17422    }
17423
17424    #[test]
17425    fn https_guard_allows_loopback_only_for_plain_http() {
17426        assert!(assert_safe_hub("https://hub.example.com").is_ok());
17427        assert!(assert_safe_hub("http://localhost:3000").is_ok());
17428        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
17429        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
17430        assert!(matches!(
17431            assert_safe_hub("http://hub.example.com"),
17432            Err(LinkError::UnsafeHub { .. })
17433        ));
17434        assert!(matches!(
17435            assert_safe_hub("hub.example.com"),
17436            Err(LinkError::UnsafeHub { .. })
17437        ));
17438        assert!(matches!(
17439            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
17440            Err(LinkError::UnsafeHub { .. })
17441        ));
17442        assert!(matches!(
17443            assert_safe_hub("https://hub.example.com@attacker.example"),
17444            Err(LinkError::UnsafeHub { .. })
17445        ));
17446        assert!(matches!(
17447            assert_safe_hub("https://hub.example.com/base"),
17448            Err(LinkError::UnsafeHub { .. })
17449        ));
17450    }
17451
17452    #[test]
17453    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17454        for blocked in [
17455            "127.0.0.1",
17456            "10.0.0.1",
17457            "100.64.0.1",
17458            "169.254.169.254",
17459            "172.16.0.1",
17460            "192.168.0.1",
17461            "192.88.99.1",
17462            "198.18.0.1",
17463            "203.0.113.1",
17464            "::1",
17465            "fe80::1",
17466            "fd00::1",
17467            "2001:db8::1",
17468            "2001:1::1",
17469            "2002:7f00:1::",
17470            "3fff::1",
17471        ] {
17472            assert!(
17473                !is_public_registry_ip(blocked.parse().unwrap()),
17474                "must block {blocked}"
17475            );
17476        }
17477        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17478        assert!(is_public_registry_ip(
17479            "2606:4700:4700::1111".parse().unwrap()
17480        ));
17481        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17482    }
17483
17484    #[test]
17485    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17486        use ureq::Resolver as _;
17487
17488        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17489        let resolver = PinnedRegistryResolver {
17490            netloc: "home.example:443".to_string(),
17491            addresses: vec![pinned],
17492        };
17493        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17494        assert!(resolver.resolve("127.0.0.1:443").is_err());
17495        assert_eq!(
17496            resolver.resolve("home.example:443").unwrap(),
17497            vec![pinned],
17498            "subsequent connects reuse the validated answer instead of DNS"
17499        );
17500    }
17501
17502    #[test]
17503    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17504        let cfg = HubConfig {
17505            hub: "https://hub.example".to_string(),
17506            key: None,
17507            agent_key: None,
17508            brain_key: None,
17509            state_dir: tempfile::tempdir().unwrap().keep(),
17510            store_selected: false,
17511        };
17512        assert!(
17513            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17514            "a production hub must not turn its presigned URL into an SSRF primitive"
17515        );
17516
17517        let store_selected = HubConfig {
17518            hub: "https://127.0.0.1".to_string(),
17519            store_selected: true,
17520            ..cfg
17521        };
17522        assert!(
17523            hub_agent(&store_selected).is_err(),
17524            "bytes in a cloned store must not select a private-network hub"
17525        );
17526    }
17527
17528    #[test]
17529    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17530        assert_eq!(
17531            one_past_bounded_limit(MAX_PACK_BYTES),
17532            Some(MAX_PACK_BYTES + 1),
17533            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17534        );
17535        assert_eq!(
17536            presigned_download_read_limit(),
17537            MAX_PACK_BYTES + 1,
17538            "the presigned reader is capped by the client constant, not a hub response"
17539        );
17540        assert_eq!(
17541            one_past_bounded_limit(u64::MAX),
17542            None,
17543            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17544        );
17545    }
17546
17547    #[test]
17548    fn https_guard_matches_the_scheme_case_insensitively() {
17549        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
17550        // hub is still HTTPS, never a misleading non-HTTPS refusal.
17551        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17552        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17553        // And an uppercase plain-HTTP hub is still refused outside loopback.
17554        assert!(matches!(
17555            assert_safe_hub("HTTP://hub.example.com"),
17556            Err(LinkError::UnsafeHub { .. })
17557        ));
17558    }
17559
17560    #[test]
17561    fn clean_key_refuses_paste_artifacts_without_echoing() {
17562        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
17563        for bad in ["vc account", "vc\naccount", "ключ", ""] {
17564            let err = clean_key(bad).unwrap_err();
17565            assert!(matches!(err, LinkError::BadKey));
17566            assert!(
17567                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17568                "error must not echo the key"
17569            );
17570        }
17571    }
17572
17573    // ── Verb entry gates: refs must never reshape the request path ──────────
17574
17575    /// A config whose hub passes the loopback guard but is never listened on:
17576    /// every refusal below must come from the entry gate BEFORE a request
17577    /// exists — a dial on this dead port would surface `Transport` instead.
17578    fn dead_hub() -> HubConfig {
17579        HubConfig {
17580            hub: "http://127.0.0.1:9".to_string(),
17581            key: Some("k".to_string()),
17582            agent_key: None,
17583            brain_key: None,
17584            state_dir: PathBuf::from("."),
17585            store_selected: false,
17586        }
17587    }
17588
17589    #[test]
17590    fn request_retries_a_connection_failure_before_sending() {
17591        use std::io::{Read as _, Write as _};
17592        use std::net::TcpListener;
17593        use std::thread;
17594        use std::time::Duration;
17595
17596        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17597        let address = probe.local_addr().unwrap();
17598        drop(probe);
17599        let server = thread::spawn(move || {
17600            thread::sleep(Duration::from_millis(40));
17601            let listener = TcpListener::bind(address).unwrap();
17602            let (mut stream, _) = listener.accept().unwrap();
17603            let mut request_bytes = [0_u8; 1024];
17604            let _ = stream.read(&mut request_bytes).unwrap();
17605            stream
17606                .write_all(
17607                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17608                )
17609                .unwrap();
17610        });
17611        let cfg = HubConfig {
17612            hub: format!("http://{address}"),
17613            key: None,
17614            agent_key: None,
17615            brain_key: None,
17616            state_dir: tempfile::tempdir().unwrap().keep(),
17617            store_selected: false,
17618        };
17619
17620        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17621        assert_eq!(response.status, 200);
17622        assert_eq!(response.body, Some(json!({ "ok": true })));
17623        server.join().unwrap();
17624    }
17625
17626    #[test]
17627    fn a_commit_goes_back_for_a_receipt_it_lost() {
17628        use std::io::{Read as _, Write as _};
17629        use std::net::TcpListener;
17630        use std::thread;
17631
17632        // The mutation coordinate is derived from the observed base and the
17633        // exact operation set so a repeat is the SAME effect. A commit that
17634        // lands durably and then loses its connection must therefore be asked
17635        // after, not reported as a failure — which is what happened to a
17636        // 32,338-file store that had already committed.
17637        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17638        let address = listener.local_addr().unwrap();
17639        let server = thread::spawn(move || {
17640            // First attempt: promise a body, hang up mid-way.
17641            let (mut first, _) = listener.accept().unwrap();
17642            let mut bytes = [0_u8; 4096];
17643            let _ = first.read(&mut bytes).unwrap();
17644            first
17645                .write_all(
17646                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17647                )
17648                .unwrap();
17649            drop(first);
17650            // Second attempt: the receipt for the commit that already landed.
17651            let (mut second, _) = listener.accept().unwrap();
17652            let _ = second.read(&mut bytes).unwrap();
17653            second
17654                .write_all(
17655                    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\"}",
17656                )
17657                .unwrap();
17658        });
17659        let cfg = HubConfig {
17660            hub: format!("http://{address}"),
17661            key: Some("k".to_string()),
17662            agent_key: None,
17663            brain_key: None,
17664            state_dir: tempfile::tempdir().unwrap().keep(),
17665            store_selected: false,
17666        };
17667
17668        let response = request_patient(
17669            &cfg,
17670            "POST",
17671            "/api/hub/brains/b/v2/commits",
17672            Some(&json!({ "mutation_id": "dbmd-1" })),
17673            Auth::Required,
17674        )
17675        .expect("the receipt is collected on the second ask");
17676        assert_eq!(response.status, 200);
17677        assert_eq!(
17678            response
17679                .body
17680                .as_ref()
17681                .and_then(|value| value.get("outcome"))
17682                .and_then(Value::as_str),
17683            Some("converged"),
17684            "an already-applied mutation answers with its receipt"
17685        );
17686        server.join().unwrap();
17687    }
17688
17689    #[test]
17690    fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
17691        use std::io::{Read as _, Write as _};
17692        use std::net::TcpListener;
17693        use std::thread;
17694
17695        // A connection that fails while the body is arriving is the same class
17696        // of problem as one that fails before it arrives at all. Typed as a
17697        // bare io error it matched no retry path and named no peer, and one
17698        // stalled body ended a whole migration reporting only "timed out
17699        // reading response".
17700        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17701        let address = listener.local_addr().unwrap();
17702        let server = thread::spawn(move || {
17703            let (mut stream, _) = listener.accept().unwrap();
17704            let mut request_bytes = [0_u8; 1024];
17705            let _ = stream.read(&mut request_bytes).unwrap();
17706            // Promise a hundred bytes, send ten, hang up.
17707            stream
17708                .write_all(
17709                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17710                )
17711                .unwrap();
17712        });
17713        let cfg = HubConfig {
17714            hub: format!("http://{address}"),
17715            key: None,
17716            agent_key: None,
17717            brain_key: None,
17718            state_dir: tempfile::tempdir().unwrap().keep(),
17719            store_selected: false,
17720        };
17721
17722        let error = request(&cfg, "POST", "/truncated", None, Auth::None)
17723            .expect_err("a truncated body must not read as success");
17724        match error {
17725            LinkError::Transport { hub, .. } => {
17726                assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17727            }
17728            other => panic!("expected a transport failure, got {other:?}"),
17729        }
17730        server.join().unwrap();
17731    }
17732
17733    #[test]
17734    fn a_safe_get_retries_when_its_body_dies_mid_stream() {
17735        use std::io::{Read as _, Write as _};
17736        use std::net::{TcpListener, TcpStream};
17737        use std::thread;
17738
17739        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17740        let address = listener.local_addr().unwrap();
17741        let server = thread::spawn(move || {
17742            let read_request = |stream: &mut TcpStream| {
17743                let mut request = Vec::new();
17744                let mut bytes = [0_u8; 1024];
17745                loop {
17746                    let read = stream.read(&mut bytes).unwrap();
17747                    if read == 0 {
17748                        break;
17749                    }
17750                    request.extend_from_slice(&bytes[..read]);
17751                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17752                    else {
17753                        continue;
17754                    };
17755                    let headers = String::from_utf8_lossy(&request[..header_end]);
17756                    let content_length = headers
17757                        .lines()
17758                        .find_map(|line| {
17759                            let (name, value) = line.split_once(':')?;
17760                            name.eq_ignore_ascii_case("content-length")
17761                                .then(|| value.trim().parse::<usize>().ok())
17762                                .flatten()
17763                        })
17764                        .unwrap_or(0);
17765                    if request.len() >= header_end + 4 + content_length {
17766                        break;
17767                    }
17768                }
17769            };
17770            let (mut first, _) = listener.accept().unwrap();
17771            read_request(&mut first);
17772            first
17773                .write_all(
17774                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17775                )
17776                .unwrap();
17777            drop(first);
17778
17779            let (mut second, _) = listener.accept().unwrap();
17780            read_request(&mut second);
17781            second
17782                .write_all(
17783                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17784                )
17785                .unwrap();
17786        });
17787        let cfg = HubConfig {
17788            hub: format!("http://{address}"),
17789            key: None,
17790            agent_key: None,
17791            brain_key: None,
17792            state_dir: tempfile::tempdir().unwrap().keep(),
17793            store_selected: false,
17794        };
17795
17796        let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
17797            .expect("a safe read retries the interrupted body");
17798        assert_eq!(response.status, 200);
17799        assert_eq!(response.body, Some(json!({ "ok": true })));
17800        server.join().unwrap();
17801    }
17802
17803    #[test]
17804    fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
17805        use std::io::{Read as _, Write as _};
17806        use std::net::{TcpListener, TcpStream};
17807        use std::thread;
17808
17809        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17810        let address = listener.local_addr().unwrap();
17811        let server = thread::spawn(move || {
17812            let read_request = |stream: &mut TcpStream| {
17813                let mut request = Vec::new();
17814                let mut bytes = [0_u8; 1024];
17815                loop {
17816                    let read = stream.read(&mut bytes).unwrap();
17817                    if read == 0 {
17818                        break;
17819                    }
17820                    request.extend_from_slice(&bytes[..read]);
17821                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17822                    else {
17823                        continue;
17824                    };
17825                    let headers = String::from_utf8_lossy(&request[..header_end]);
17826                    let content_length = headers
17827                        .lines()
17828                        .find_map(|line| {
17829                            let (name, value) = line.split_once(':')?;
17830                            name.eq_ignore_ascii_case("content-length")
17831                                .then(|| value.trim().parse::<usize>().ok())
17832                                .flatten()
17833                        })
17834                        .unwrap_or(0);
17835                    if request.len() >= header_end + 4 + content_length {
17836                        break;
17837                    }
17838                }
17839            };
17840            let (mut first, _) = listener.accept().unwrap();
17841            read_request(&mut first);
17842            first
17843                .write_all(
17844                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17845                )
17846                .unwrap();
17847            drop(first);
17848
17849            let (mut second, _) = listener.accept().unwrap();
17850            read_request(&mut second);
17851            second
17852                .write_all(
17853                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17854                )
17855                .unwrap();
17856        });
17857        let cfg = HubConfig {
17858            hub: format!("http://{address}"),
17859            key: None,
17860            agent_key: None,
17861            brain_key: None,
17862            state_dir: tempfile::tempdir().unwrap().keep(),
17863            store_selected: false,
17864        };
17865
17866        let response = request_raw_retryable_read(
17867            &cfg,
17868            "POST",
17869            "/v2/stream",
17870            Some(&json!({ "files": ["proof"] })),
17871            Auth::None,
17872            1_024,
17873        )
17874        .expect("an explicitly safe POST retries the interrupted body");
17875        assert_eq!(response.status, 200);
17876        assert_eq!(
17877            serde_json::from_slice::<Value>(&response.body).unwrap(),
17878            json!({ "ok": true })
17879        );
17880        server.join().unwrap();
17881    }
17882
17883    #[test]
17884    fn object_store_transport_errors_never_render_presigned_urls() {
17885        use std::net::TcpListener;
17886
17887        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17888        let address = listener.local_addr().unwrap();
17889        drop(listener);
17890        let signature = "do-not-render-this-presigned-signature";
17891        let raw =
17892            format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
17893        let error = ureq::get(&raw)
17894            .timeout(std::time::Duration::from_millis(250))
17895            .call()
17896            .expect_err("the closed local port must fail");
17897        let ureq::Error::Transport(transport) = error else {
17898            panic!("expected a transport failure");
17899        };
17900
17901        let rendered = object_store_transport_error(transport).to_string();
17902        assert!(rendered.contains("the object store"));
17903        assert!(rendered.contains("network error"));
17904        assert!(!rendered.contains(&raw));
17905        assert!(!rendered.contains(signature));
17906        assert!(!rendered.contains("X-Amz-"));
17907    }
17908
17909    #[test]
17910    fn endpoint_cap_refuses_a_body_before_json_parsing() {
17911        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
17912        let cfg = HubConfig {
17913            hub,
17914            key: None,
17915            agent_key: None,
17916            brain_key: None,
17917            state_dir: tempfile::tempdir().unwrap().keep(),
17918            store_selected: false,
17919        };
17920
17921        assert!(matches!(
17922            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
17923            Err(LinkError::ResponseTooLarge { .. })
17924        ));
17925        server.join().unwrap();
17926    }
17927
17928    #[test]
17929    fn overall_deadline_stops_a_dribbled_response_body() {
17930        use std::io::{Read as _, Write as _};
17931        use std::net::TcpListener;
17932        use std::time::{Duration, Instant};
17933
17934        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17935        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
17936        let server = std::thread::spawn(move || {
17937            let (mut stream, _) = listener.accept().unwrap();
17938            let mut request = [0_u8; 1024];
17939            let _ = stream.read(&mut request);
17940            stream
17941                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
17942                .unwrap();
17943            for byte in [b'x'; 32] {
17944                if stream.write_all(&[byte]).is_err() {
17945                    break;
17946                }
17947                std::thread::sleep(Duration::from_millis(40));
17948            }
17949        });
17950        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17951        let started = Instant::now();
17952        let response = http.get(&url).call().unwrap();
17953        let mut body = Vec::new();
17954        let error = response
17955            .into_reader()
17956            .read_to_end(&mut body)
17957            .expect_err("per-read progress must not reset the overall deadline");
17958        assert!(
17959            started.elapsed() < Duration::from_millis(700),
17960            "dribbled body exceeded the wall-clock budget: {error}"
17961        );
17962        server.join().unwrap();
17963    }
17964
17965    #[test]
17966    fn overall_deadline_stops_a_stalled_upload() {
17967        use std::net::TcpListener;
17968        use std::time::{Duration, Instant};
17969
17970        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17971        let url = format!("http://{}/upload", listener.local_addr().unwrap());
17972        let server = std::thread::spawn(move || {
17973            let (_stream, _) = listener.accept().unwrap();
17974            // Never consume the request body. Once the kernel send buffer fills,
17975            // the client must leave on its absolute write deadline.
17976            std::thread::sleep(Duration::from_millis(600));
17977        });
17978        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17979        let body = vec![0x5a; 32 * 1024 * 1024];
17980        let started = Instant::now();
17981        let error = http
17982            .put(&url)
17983            .send_bytes(&body)
17984            .expect_err("stalled request-body writes must time out");
17985        assert!(
17986            started.elapsed() < Duration::from_millis(700),
17987            "stalled upload exceeded the wall-clock budget: {error}"
17988        );
17989        server.join().unwrap();
17990    }
17991
17992    #[test]
17993    fn presigned_source_retries_share_one_upload_deadline() {
17994        use std::net::TcpListener;
17995        use std::time::{Duration, Instant};
17996
17997        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17998        let address = listener.local_addr().unwrap();
17999        let signature = "do-not-render-this-stalled-upload-signature";
18000        let url = format!(
18001            "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
18002        );
18003        let server = std::thread::spawn(move || {
18004            let (_stream, _) = listener.accept().unwrap();
18005            // Keep the socket open without consuming the request. The first
18006            // attempt must spend the complete shared budget; retries may not
18007            // each restart that budget.
18008            std::thread::sleep(Duration::from_millis(600));
18009        });
18010
18011        let directory = tempfile::tempdir().unwrap();
18012        std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
18013        std::fs::create_dir(directory.path().join("records")).unwrap();
18014        let relative = "records/stalled.bin";
18015        let bytes = vec![0x5a; 32 * 1024 * 1024];
18016        std::fs::write(directory.path().join(relative), &bytes).unwrap();
18017        let store = Store::open_strict(directory.path()).unwrap();
18018        let cfg = HubConfig {
18019            hub: format!("http://{address}"),
18020            key: None,
18021            agent_key: None,
18022            brain_key: None,
18023            state_dir: tempfile::tempdir().unwrap().keep(),
18024            store_selected: false,
18025        };
18026        let source = V2UploadSource {
18027            path: relative.to_string(),
18028            bytes: bytes.len() as u64,
18029        };
18030
18031        let started = Instant::now();
18032        let error = put_presigned_source_with_budget(
18033            &cfg,
18034            &url,
18035            &json!({ "content-length": source.bytes.to_string() }),
18036            &store,
18037            &source,
18038            None,
18039            Duration::from_millis(150),
18040        )
18041        .expect_err("a black-holed upload must leave at its shared deadline");
18042        assert!(
18043            started.elapsed() < Duration::from_millis(700),
18044            "presigned retries exceeded their shared budget: {error}"
18045        );
18046        let rendered = error.to_string();
18047        assert!(rendered.contains("the object store"));
18048        assert!(!rendered.contains(&url));
18049        assert!(!rendered.contains(signature));
18050        server.join().unwrap();
18051    }
18052
18053    #[test]
18054    fn verb_entry_gates_accept_the_hub_ref_shapes() {
18055        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
18056            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
18057            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
18058            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
18059        }
18060    }
18061
18062    #[test]
18063    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
18064        let cfg = dead_hub();
18065        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
18066            assert!(
18067                matches!(
18068                    sync_pull(&cfg, bad, None),
18069                    Err(LinkError::BadAddress { .. })
18070                ),
18071                "sync_pull must refuse {bad:?}"
18072            );
18073            assert!(
18074                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
18075                "sync_push must refuse {bad:?}"
18076            );
18077            assert!(
18078                matches!(
18079                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
18080                    Err(LinkError::BadAddress { .. })
18081                ),
18082                "grant_issue must refuse {bad:?}"
18083            );
18084            assert!(
18085                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
18086                "grant_list must refuse {bad:?}"
18087            );
18088            assert!(
18089                matches!(
18090                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
18091                    Err(LinkError::BadAddress { .. })
18092                ),
18093                "grant_revoke must refuse brain {bad:?}"
18094            );
18095            assert!(
18096                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
18097                "head must refuse {bad:?}"
18098            );
18099        }
18100    }
18101
18102    #[test]
18103    fn grant_revoke_refuses_url_reshaping_grant_ids() {
18104        let cfg = dead_hub();
18105        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
18106            assert!(
18107                matches!(
18108                    grant_revoke(&cfg, "acme", bad),
18109                    Err(LinkError::BadGrantId { .. })
18110                ),
18111                "grant_revoke must refuse grant id {bad:?}"
18112            );
18113        }
18114    }
18115
18116    #[test]
18117    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
18118        let cfg = dead_hub();
18119        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
18120            assert!(
18121                matches!(
18122                    propose(&cfg, bad, "intake", "hi"),
18123                    Err(LinkError::BadAddress { .. })
18124                ),
18125                "propose must refuse handle {bad:?}"
18126            );
18127        }
18128        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
18129        assert!(matches!(
18130            propose(&cfg, "acme-site", "intake", &oversize),
18131            Err(LinkError::ProposeTooLarge { .. })
18132        ));
18133        // A clean handle + in-cap body passes both gates: the failure is now
18134        // the (dead) wire, proving the gates refuse shape, not the verb.
18135        assert!(matches!(
18136            propose(&cfg, "acme-site", "intake", "hi"),
18137            Err(LinkError::Transport { .. })
18138        ));
18139    }
18140
18141    #[test]
18142    fn resolve_refuses_a_hand_built_unsafe_address() {
18143        let cfg = dead_hub();
18144        for brain in ["../up", "a/b", "a?x", "a#f"] {
18145            let addr = Address {
18146                brain: brain.to_string(),
18147                target: None,
18148            };
18149            assert!(
18150                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18151                "resolve must refuse brain {brain:?}"
18152            );
18153        }
18154        for target in [
18155            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
18156            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
18157            AddressTarget::Path("../up.md".to_string()),
18158            AddressTarget::Path("records/x.md#frag".to_string()),
18159        ] {
18160            let addr = Address {
18161                brain: "acme".to_string(),
18162                target: Some(target.clone()),
18163            };
18164            assert!(
18165                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18166                "resolve must refuse target {target:?}"
18167            );
18168        }
18169    }
18170
18171    #[test]
18172    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
18173        let mut local = std::collections::BTreeMap::new();
18174        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
18175        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
18176        let mut remote = std::collections::BTreeMap::new();
18177        remote.insert(
18178            "records/a.md".to_string(),
18179            V2BaselineFile {
18180                sha256: "c".repeat(64),
18181                bytes: 1,
18182                proof: None,
18183            },
18184        );
18185        remote.insert(
18186            "records/b.md".to_string(),
18187            V2BaselineFile {
18188                sha256: "b".repeat(64),
18189                bytes: 1,
18190                proof: None,
18191            },
18192        );
18193        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18194    }
18195
18196    #[test]
18197    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
18198        let local = std::collections::BTreeMap::new();
18199        let mut remote = std::collections::BTreeMap::new();
18200        remote.insert(
18201            "private/local.md".to_string(),
18202            V2BaselineFile {
18203                sha256: "d".repeat(64),
18204                bytes: 1,
18205                proof: None,
18206            },
18207        );
18208        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
18209        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18210    }
18211
18212    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
18213        V2VerifiedHead {
18214            requested: TEST_BRAIN_ID.to_string(),
18215            brain_id: TEST_BRAIN_ID.to_string(),
18216            view_kind: "scoped".to_string(),
18217            view_revision: revision.to_string(),
18218            control_revision: revision.to_string(),
18219            identity: V2HeadIdentity {
18220                custody: "hub".to_string(),
18221                fingerprint: "test".to_string(),
18222                public_key_spki: "test".to_string(),
18223                previous: Vec::new(),
18224                rotations: Vec::new(),
18225            },
18226            pointer: None,
18227            trust: TrustState {
18228                v: 2,
18229                origin: "https://hub.example".to_string(),
18230                requested: TEST_BRAIN_ID.to_string(),
18231                brain: TEST_BRAIN_ID.to_string(),
18232                home: None,
18233                anchor: "ed25519:test".to_string(),
18234                current: "ed25519:test".to_string(),
18235                head_seq: 0,
18236                feed_hash: None,
18237                rotations: Vec::new(),
18238                hub_signer: None,
18239                protocol_profile: Some("link-v2".to_string()),
18240            },
18241            alias: None,
18242        }
18243    }
18244
18245    #[test]
18246    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
18247        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
18248        assert!(accepted_as_v2(&trust));
18249
18250        trust.protocol_profile = None;
18251        trust.hub_signer = Some("ed25519:hub".to_string());
18252        assert!(accepted_as_v2(&trust));
18253
18254        trust.hub_signer = None;
18255        assert!(!accepted_as_v2(&trust));
18256    }
18257
18258    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
18259        V2SyncBaseline {
18260            v: 2,
18261            origin: "https://hub.example".to_string(),
18262            brain: TEST_BRAIN_ID.to_string(),
18263            checkout_id: Some("c".repeat(64)),
18264            head_seq: Some(0),
18265            commit_hash: None,
18266            content_root: None,
18267            asset_root: None,
18268            assets: std::collections::BTreeMap::new(),
18269            view_kind: Some("scoped".to_string()),
18270            view_revision: Some(revision.to_string()),
18271            control_revision: Some(revision.to_string()),
18272            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
18273            files: std::collections::BTreeMap::new(),
18274            local_policy_digest: None,
18275            local_eligibility: std::collections::BTreeMap::new(),
18276            remote_copy_remains: std::collections::BTreeMap::new(),
18277        }
18278    }
18279
18280    #[test]
18281    fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
18282        let cfg = test_hub_config(
18283            "https://hub.example".to_string(),
18284            tempfile::tempdir().unwrap().keep(),
18285        );
18286        let mut baseline = scoped_test_baseline(&"a".repeat(64));
18287        baseline.assets.insert(
18288            "assets/archive.bin".to_string(),
18289            V2BaselineAsset {
18290                blob_sha256: "b".repeat(64),
18291                bytes: MAX_STORE_BYTES + 1,
18292                media_type: "application/octet-stream".to_string(),
18293                wrappers: vec!["records/archive.md".to_string()],
18294                required: true,
18295                disposition: "hosted".to_string(),
18296                leaf_hash: "c".repeat(64),
18297            },
18298        );
18299
18300        let accepted = serde_json::to_vec(&baseline).unwrap();
18301        assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
18302
18303        baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
18304        let refused = serde_json::to_vec(&baseline).unwrap();
18305        assert!(matches!(
18306            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
18307            Err(LinkError::InvalidFeed { .. })
18308        ));
18309    }
18310
18311    #[test]
18312    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
18313        let directory = tempfile::tempdir().unwrap();
18314        std::fs::write(
18315            directory.path().join("DB.md"),
18316            scoped_projection_bytes(TEST_BRAIN_ID),
18317        )
18318        .unwrap();
18319        let store = Store::open_strict(directory.path()).unwrap();
18320        let head = scoped_test_head(&"a".repeat(64));
18321        let baseline = scoped_test_baseline(&"a".repeat(64));
18322        let mut view = v2_local_files(&store).unwrap();
18323        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
18324        assert!(!view.riding.contains_key("DB.md"));
18325        assert!(!view.eligibility.contains_key("DB.md"));
18326    }
18327
18328    #[test]
18329    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
18330        let directory = tempfile::tempdir().unwrap();
18331        std::fs::write(
18332            directory.path().join("DB.md"),
18333            scoped_projection_bytes(TEST_BRAIN_ID),
18334        )
18335        .unwrap();
18336        let store = Store::open_strict(directory.path()).unwrap();
18337        let head = scoped_test_head(&"a".repeat(64));
18338        let baseline = scoped_test_baseline(&"a".repeat(64));
18339
18340        let mut carried = v2_local_files(&store).unwrap();
18341        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
18342        let handed_off =
18343            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
18344        assert!(!handed_off.riding.contains_key("DB.md"));
18345
18346        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
18347        assert!(!freshly_scanned.riding.contains_key("DB.md"));
18348
18349        std::fs::write(
18350            directory.path().join("DB.md"),
18351            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18352        )
18353        .unwrap();
18354        let tampered = Store::open_strict(directory.path()).unwrap();
18355        assert!(matches!(
18356            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
18357            Err(LinkError::ScopedProjectionModified)
18358        ));
18359    }
18360
18361    #[test]
18362    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
18363        let directory = tempfile::tempdir().unwrap();
18364        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18365        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18366        std::fs::write(
18367            directory.path().join("DB.md"),
18368            b"---\nname: Kept home test\n---\n",
18369        )
18370        .unwrap();
18371        std::fs::write(
18372            directory.path().join("records/notes/a.md"),
18373            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
18374        )
18375        .unwrap();
18376        std::fs::write(
18377            directory.path().join("sources/private/secret.md"),
18378            b"---\ntype: note\n---\nlocal only\n",
18379        )
18380        .unwrap();
18381        std::fs::write(
18382            directory.path().join("sources/private/unlinked.md"),
18383            b"---\ntype: note\n---\nnot disclosed\n",
18384        )
18385        .unwrap();
18386        std::fs::write(
18387            directory.path().join(".sevralocal"),
18388            b"sources/private/**\n",
18389        )
18390        .unwrap();
18391
18392        let store = Store::open_strict(directory.path()).unwrap();
18393        let view = v2_local_files(&store).unwrap();
18394        assert!(!view.riding.contains_key("sources/private/secret.md"));
18395        assert_eq!(
18396            view.withheld_links,
18397            vec![V2WithheldLink {
18398                source: "records/notes/a.md".to_string(),
18399                target: "sources/private/secret.md".to_string(),
18400            }]
18401        );
18402    }
18403
18404    #[test]
18405    fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
18406        // An export omits kept-home content by construction, so a store
18407        // restored from one links to names it does not carry. Without this the
18408        // owner's own export could never be pushed back — the links would read
18409        // as broken and the commit would be refused.
18410        let directory = tempfile::tempdir().unwrap();
18411        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18412        std::fs::write(
18413            directory.path().join("DB.md"),
18414            b"---\nname: Restored export\n---\n",
18415        )
18416        .unwrap();
18417        std::fs::write(
18418            directory.path().join("records/notes/a.md"),
18419            b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
18420        )
18421        .unwrap();
18422        std::fs::write(
18423            directory.path().join(".sevralocal"),
18424            b"sources/private/**\n",
18425        )
18426        .unwrap();
18427
18428        let store = Store::open_strict(directory.path()).unwrap();
18429        let view = v2_local_files(&store).unwrap();
18430        assert_eq!(
18431            view.withheld_links,
18432            vec![V2WithheldLink {
18433                source: "records/notes/a.md".to_string(),
18434                target: "sources/private/absent.md".to_string(),
18435            }]
18436        );
18437        // A dangling link the owner never claimed is still dangling.
18438        std::fs::write(
18439            directory.path().join("records/notes/b.md"),
18440            b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
18441        )
18442        .unwrap();
18443        let store = Store::open_strict(directory.path()).unwrap();
18444        let view = v2_local_files(&store).unwrap();
18445        assert!(
18446            !view
18447                .withheld_links
18448                .iter()
18449                .any(|link| link.target == "records/notes/nowhere.md"),
18450            "an unclaimed dangling target must not be declared withheld"
18451        );
18452    }
18453
18454    #[test]
18455    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
18456        let directory = tempfile::tempdir().unwrap();
18457        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18458        std::fs::write(
18459            directory.path().join("DB.md"),
18460            b"---\nname: Withdrawal test\n---\n",
18461        )
18462        .unwrap();
18463        let source = b"---\ntype: note\n---\nlocal evidence\n";
18464        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
18465        std::fs::write(
18466            directory.path().join(".sevralocal"),
18467            b"sources/private/**\n",
18468        )
18469        .unwrap();
18470        let store = Store::open_strict(directory.path()).unwrap();
18471        let view = v2_local_files(&store).unwrap();
18472        let mut remote = std::collections::BTreeMap::new();
18473        remote.insert(
18474            "sources/private/evidence.md".to_string(),
18475            V2BaselineFile {
18476                sha256: content_sha256(source),
18477                bytes: source.len() as u64,
18478                proof: None,
18479            },
18480        );
18481        assert_eq!(
18482            v2_content_withdrawal_operation(
18483                &store,
18484                &view,
18485                &remote,
18486                "sources/private/evidence.md",
18487                "approved retention change",
18488            )
18489            .unwrap(),
18490            json!({
18491                "op": "withdraw_from_hosting",
18492                "path": "sources/private/evidence.md",
18493                "expected": { "kind": "blob", "hash": content_sha256(source) },
18494                "reason": "approved retention change",
18495            })
18496        );
18497
18498        std::fs::write(
18499            directory.path().join("sources/private/evidence.md"),
18500            b"changed after review",
18501        )
18502        .unwrap();
18503        assert!(matches!(
18504            v2_content_withdrawal_operation(
18505                &store,
18506                &view,
18507                &remote,
18508                "sources/private/evidence.md",
18509                "approved retention change",
18510            ),
18511            Err(LinkError::InvalidPack { .. })
18512        ));
18513    }
18514
18515    #[test]
18516    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
18517        let directory = tempfile::tempdir().unwrap();
18518        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
18519        std::fs::write(
18520            directory.path().join("DB.md"),
18521            b"---\nname: Asset withdrawal test\n---\n",
18522        )
18523        .unwrap();
18524        let bytes = b"private binary";
18525        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
18526        std::fs::write(
18527            directory.path().join(".sevralocal"),
18528            b"sources/files/private.pdf\n",
18529        )
18530        .unwrap();
18531        let store = Store::open_strict(directory.path()).unwrap();
18532        let view = v2_local_files(&store).unwrap();
18533        let local = crate::AssetRecord {
18534            path: "sources/files/private.pdf".to_string(),
18535            sha256: content_sha256(bytes),
18536            bytes: bytes.len() as u64,
18537            media_type: "application/pdf".to_string(),
18538            wrappers: vec!["sources/files/private.md".to_string()],
18539            required: true,
18540        };
18541        let current = V2BaselineAsset {
18542            blob_sha256: local.sha256.clone(),
18543            bytes: local.bytes,
18544            media_type: local.media_type.clone(),
18545            wrappers: local.wrappers.clone(),
18546            required: local.required,
18547            disposition: "hosted".to_string(),
18548            leaf_hash: "d".repeat(64),
18549        };
18550        assert_eq!(
18551            v2_asset_withdrawal_operation(
18552                &store,
18553                &view,
18554                &local.path,
18555                &local,
18556                &current,
18557                "approved retention change",
18558            )
18559            .unwrap(),
18560            json!({
18561                "op": "asset_withdraw",
18562                "path": local.path,
18563                "expected": { "kind": "asset", "hash": "d".repeat(64) },
18564                "reason": "approved retention change",
18565            })
18566        );
18567
18568        let mut mismatched = current.clone();
18569        mismatched.required = false;
18570        assert!(matches!(
18571            v2_asset_withdrawal_operation(
18572                &store,
18573                &view,
18574                &local.path,
18575                &local,
18576                &mismatched,
18577                "approved retention change",
18578            ),
18579            Err(LinkError::InvalidPack { .. })
18580        ));
18581    }
18582
18583    #[test]
18584    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
18585        let first = v2_checkout_id(None).unwrap();
18586        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
18587        assert_ne!(first, v2_checkout_id(None).unwrap());
18588        assert!(is_sha256(&first));
18589    }
18590
18591    #[test]
18592    fn scoped_projection_edit_and_scope_transition_fail_closed() {
18593        let directory = tempfile::tempdir().unwrap();
18594        std::fs::write(
18595            directory.path().join("DB.md"),
18596            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
18597        )
18598        .unwrap();
18599        let store = Store::open_strict(directory.path()).unwrap();
18600        let head = scoped_test_head(&"a".repeat(64));
18601        let baseline = scoped_test_baseline(&"a".repeat(64));
18602        let mut view = v2_local_files(&store).unwrap();
18603        assert!(matches!(
18604            remove_scoped_projection(&head, Some(&baseline), &mut view),
18605            Err(LinkError::ScopedProjectionModified)
18606        ));
18607
18608        let changed = scoped_test_head(&"b".repeat(64));
18609        assert!(matches!(
18610            ensure_v2_view_compatible(&changed, Some(&baseline)),
18611            Err(LinkError::ScopedViewChanged)
18612        ));
18613
18614        let mut same_view_new_control = head.clone();
18615        same_view_new_control.control_revision = "c".repeat(64);
18616        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
18617        assert!(!same_v2_head(&head, &same_view_new_control));
18618    }
18619
18620    #[test]
18621    fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
18622        let mut head = scoped_test_head(&"a".repeat(64));
18623        head.control_revision = "b".repeat(64);
18624        head.pointer = Some(V2PointerBody {
18625            v: 2,
18626            brain: TEST_BRAIN_ID.to_string(),
18627            seq: 7,
18628            commit_hash: "c".repeat(64),
18629            feed_hash: "d".repeat(64),
18630            content_root: Some("e".repeat(64)),
18631            asset_root: Some("f".repeat(64)),
18632            materializer: "dbmd-projection-v1".to_string(),
18633            signer_epoch: 1,
18634            control_revision: head.control_revision.clone(),
18635            backup_preparation: "0".repeat(64),
18636            prior_pointer_hash: Some("1".repeat(64)),
18637            signed_at: "2026-08-24T00:00:00.000Z".to_string(),
18638        });
18639        let mut baseline = scoped_test_baseline(&head.view_revision);
18640        baseline.head_seq = Some(7);
18641        baseline.commit_hash = Some("c".repeat(64));
18642        baseline.content_root = Some("e".repeat(64));
18643        baseline.asset_root = Some("f".repeat(64));
18644        baseline.control_revision = Some(head.control_revision.clone());
18645        assert!(v2_baseline_matches_head(&head, &baseline));
18646
18647        let mut changed = baseline.clone();
18648        changed.head_seq = Some(8);
18649        assert!(!v2_baseline_matches_head(&head, &changed));
18650        let mut changed = baseline.clone();
18651        changed.commit_hash = Some("2".repeat(64));
18652        assert!(!v2_baseline_matches_head(&head, &changed));
18653        let mut changed = baseline.clone();
18654        changed.content_root = Some("3".repeat(64));
18655        assert!(!v2_baseline_matches_head(&head, &changed));
18656        let mut changed = baseline.clone();
18657        changed.asset_root = Some("4".repeat(64));
18658        assert!(!v2_baseline_matches_head(&head, &changed));
18659        let mut changed = baseline.clone();
18660        changed.view_revision = Some("5".repeat(64));
18661        assert!(!v2_baseline_matches_head(&head, &changed));
18662        let mut changed = baseline.clone();
18663        changed.control_revision = Some("6".repeat(64));
18664        assert!(!v2_baseline_matches_head(&head, &changed));
18665
18666        let mut changed_head = head.clone();
18667        changed_head.view_kind = "full".to_string();
18668        assert!(!v2_baseline_matches_head(&changed_head, &baseline));
18669    }
18670
18671    #[test]
18672    fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
18673        let sandbox = tempfile::tempdir().unwrap();
18674        let cfg = test_hub_config(
18675            "https://hub.example".to_string(),
18676            sandbox.path().to_path_buf(),
18677        );
18678        let head = scoped_test_head(&"a".repeat(64));
18679        let baseline = scoped_test_baseline(&head.view_revision);
18680        let mut encoded = serde_json::to_value(&baseline).unwrap();
18681        encoded.as_object_mut().unwrap().remove("control_revision");
18682        let parsed =
18683            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
18684        assert!(parsed.control_revision.is_none());
18685        assert!(!v2_baseline_matches_head(&head, &parsed));
18686    }
18687
18688    #[test]
18689    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
18690        let scoped = scoped_test_head(&"a".repeat(64));
18691        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
18692        assert!(matches!(
18693            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18694            Err(LinkError::ScopedProjectionModified)
18695        ));
18696
18697        let mut full = scoped.clone();
18698        full.view_kind = "full".to_string();
18699        let mut full_baseline = scoped_baseline.clone();
18700        full_baseline.view_kind = Some("full".to_string());
18701        full_baseline.projection_sha256 = None;
18702        assert!(matches!(
18703            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18704            Err(LinkError::InvalidPack { .. })
18705        ));
18706
18707        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18708        assert!(
18709            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18710        );
18711    }
18712
18713    #[test]
18714    fn scoped_view_metadata_is_explicitly_non_authoritative() {
18715        let head = scoped_test_head(&"a".repeat(64));
18716        let value: Value =
18717            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18718        assert_eq!(value["kind"], "link.md-scoped-view");
18719        assert_eq!(value["authoritative"], false);
18720        assert_eq!(value["visible_files"], 7);
18721        assert_eq!(value["brain"], TEST_BRAIN_ID);
18722    }
18723
18724    #[test]
18725    fn local_scoped_marker_requires_the_exact_generated_projection() {
18726        let directory = tempfile::tempdir().unwrap();
18727        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18728        std::fs::write(
18729            directory.path().join("DB.md"),
18730            scoped_projection_bytes(TEST_BRAIN_ID),
18731        )
18732        .unwrap();
18733        let head = scoped_test_head(&"a".repeat(64));
18734        std::fs::write(
18735            directory.path().join(".dbmd/view.json"),
18736            scoped_view_metadata(&head, 0).unwrap(),
18737        )
18738        .unwrap();
18739        let store = Store::open_strict(directory.path()).unwrap();
18740        assert!(has_verified_local_scoped_view(&store));
18741
18742        std::fs::write(
18743            directory.path().join("DB.md"),
18744            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18745        )
18746        .unwrap();
18747        let altered = Store::open_strict(directory.path()).unwrap();
18748        assert!(!has_verified_local_scoped_view(&altered));
18749    }
18750
18751    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18752        use ring::signature::KeyPair as _;
18753
18754        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18755        let rng = ring::rand::SystemRandom::new();
18756        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18757        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18758        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18759        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18760        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18761        let blob = b"new";
18762        let blob_hash = content_sha256(blob);
18763        let changes = json!({
18764            "mutation_id": "sync:proposal-fixture",
18765            "operations": [{
18766                "blob": blob_hash,
18767                "bytes": blob.len(),
18768                "expected": null,
18769                "op": "put",
18770                "path": "records/new.md",
18771            }],
18772            "reason": "fixture",
18773            "v": 2,
18774        });
18775        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18776        let changes_base64 = STANDARD.encode(&changes_bytes);
18777        let descriptor = json!({
18778            "base": null,
18779            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18780            "changes_base64": changes_base64,
18781            "rebase": "strict",
18782            "v": 2,
18783        });
18784        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18785        let payload_hash = "b".repeat(64);
18786        let submitted_at = "2026-08-19T12:00:00.000Z";
18787        let claim = json!({
18788            "actor_root": {
18789                "actor_class": "foreign_key",
18790                "credential": "ed25519:fixture",
18791                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18792                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18793                "principal": "key:fixture",
18794                "role": null,
18795            },
18796            "brain": TEST_BRAIN_ID,
18797            "clear_sha256": clear_hash,
18798            "control_revision": "c".repeat(64),
18799            "mutation_id": "sync:proposal-fixture",
18800            "payload_sha256": payload_hash,
18801            "proposal_id": proposal_id,
18802            "submitted_at": submitted_at,
18803            "v": 2,
18804        });
18805        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
18806        let envelope = json!({
18807            "claim": claim,
18808            "fingerprint": fingerprint,
18809            "public_key": public_key,
18810            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
18811        });
18812        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18813        let submission_hash =
18814            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
18815        let mut head = scoped_test_head(&"c".repeat(64));
18816        head.view_kind = "full".to_string();
18817        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
18818        let value = json!({
18819            "proposal": {
18820                "base": null,
18821                "blobs": [{
18822                    "bytes": blob.len(),
18823                    "endpoint": format!(
18824                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
18825                    ),
18826                    "sha256": blob_hash,
18827                }],
18828                "changes_base64": changes_base64,
18829                "clear_sha256": clear_hash,
18830                "expires_at": "2026-08-26T12:00:00.000Z",
18831                "id": proposal_id,
18832                "payload_sha256": payload_hash,
18833                "proposer": { "class": "foreign_key" },
18834                "rebase": "strict",
18835                "state": "pending",
18836                "submission_claim_base64": STANDARD.encode(envelope_bytes),
18837                "submission_claim_sha256": submission_hash,
18838                "submitted_at": submitted_at,
18839            },
18840            "v": 2,
18841        });
18842        (head, proposal_id, value)
18843    }
18844
18845    #[test]
18846    fn v2_proposal_verifier_accepts_exact_signed_payload() {
18847        let (head, proposal_id, value) = signed_proposal_fixture();
18848        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18849        assert_eq!(verified.blobs.len(), 1);
18850        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18851    }
18852
18853    #[test]
18854    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18855        let (head, proposal_id, value) = signed_proposal_fixture();
18856
18857        let mut changed = value.clone();
18858        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18859        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18860
18861        let mut redirected = value.clone();
18862        redirected["proposal"]["blobs"][0]["endpoint"] =
18863            Value::String("https://attacker.example/blob".to_string());
18864        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18865
18866        let mut forged = value;
18867        let encoded = forged["proposal"]["submission_claim_base64"]
18868            .as_str()
18869            .unwrap();
18870        let mut envelope: Value =
18871            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18872        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18873        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18874        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18875        forged["proposal"]["submission_claim_sha256"] = Value::String(
18876            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18877        );
18878        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18879    }
18880
18881    #[cfg(unix)]
18882    #[test]
18883    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18884        let sandbox = tempfile::tempdir().unwrap();
18885        let destination = sandbox.path().join("brain");
18886        let entries = vec![
18887            (
18888                "DB.md".to_string(),
18889                scoped_projection_bytes(TEST_BRAIN_ID),
18890            ),
18891            (
18892                "records/contacts/a.md".to_string(),
18893                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18894                    .to_vec(),
18895            ),
18896        ];
18897        install_pulled_delta(&destination, &entries, &[], true).unwrap();
18898        assert!(destination.join("index.md").is_file());
18899        assert!(destination.join("records/index.md").is_file());
18900        assert!(destination.join("records/contacts/index.md").is_file());
18901        assert!(destination.join("records/contacts/index.jsonl").is_file());
18902    }
18903
18904    #[cfg(unix)]
18905    #[test]
18906    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
18907        let sandbox = tempfile::tempdir().unwrap();
18908        let destination = sandbox.path().join("brain");
18909        let cache = sandbox.path().join("cache");
18910        std::fs::create_dir(&cache).unwrap();
18911        let db = scoped_projection_bytes(TEST_BRAIN_ID);
18912        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
18913        let db_source = cache.join("db");
18914        let shared_source = cache.join("shared");
18915        crate::fsx::write_atomic(&db_source, &db).unwrap();
18916        crate::fsx::write_atomic(&shared_source, shared).unwrap();
18917        let mut entries = vec![V2StagedFile {
18918            path: "DB.md".to_string(),
18919            source: db_source,
18920            sha256: content_sha256(&db),
18921            bytes: db.len() as u64,
18922        }];
18923        for index in 0..512 {
18924            entries.push(V2StagedFile {
18925                path: format!("records/items/{index:05}.md"),
18926                source: shared_source.clone(),
18927                sha256: content_sha256(shared),
18928                bytes: shared.len() as u64,
18929            });
18930        }
18931        install_pulled_delta_sources(
18932            &destination,
18933            &entries,
18934            &[],
18935            false,
18936            None,
18937            &scoped_test_head(&"c".repeat(64)),
18938        )
18939        .unwrap();
18940        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
18941        for index in 0..512 {
18942            assert_eq!(
18943                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
18944                shared
18945            );
18946        }
18947        assert!(
18948            std::fs::read_dir(sandbox.path())
18949                .unwrap()
18950                .all(|entry| !entry
18951                    .unwrap()
18952                    .file_name()
18953                    .to_string_lossy()
18954                    .contains("pull-stage")),
18955            "the private stage must be atomically installed or removed"
18956        );
18957    }
18958
18959    #[cfg(unix)]
18960    #[test]
18961    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
18962        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
18963
18964        let sandbox = tempfile::tempdir().unwrap();
18965        let root = sandbox.path().join("brain");
18966        std::fs::create_dir_all(root.join("records/items")).unwrap();
18967        let db = scoped_projection_bytes(TEST_BRAIN_ID);
18968        let old = b"---\ntype: note\n---\n\nold\n";
18969        let new = b"---\ntype: note\n---\n\nnew\n";
18970        let removed = b"---\ntype: note\n---\n\nremove me\n";
18971        std::fs::write(root.join("DB.md"), &db).unwrap();
18972        std::fs::write(root.join("records/items/change.md"), old).unwrap();
18973        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
18974        for index in 0..512 {
18975            std::fs::write(
18976                root.join(format!("records/items/untouched-{index:04}.md")),
18977                old,
18978            )
18979            .unwrap();
18980        }
18981        let untouched = root.join("records/items/untouched-0256.md");
18982        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
18983        let source = sandbox.path().join("changed-source");
18984        crate::fsx::write_atomic(&source, new).unwrap();
18985        let same_source = sandbox.path().join("unchanged-source");
18986        crate::fsx::write_atomic(&same_source, old).unwrap();
18987        let same_entry = V2StagedFile {
18988            path: "records/items/change.md".to_string(),
18989            source: same_source,
18990            sha256: content_sha256(old),
18991            bytes: old.len() as u64,
18992        };
18993        let entry = V2StagedFile {
18994            path: "records/items/change.md".to_string(),
18995            source,
18996            sha256: content_sha256(new),
18997            bytes: new.len() as u64,
18998        };
18999        let head = scoped_test_head(&"c".repeat(64));
19000
19001        // A no-op established pull neither clones the checkout nor arms a
19002        // journal. Inode identity is a direct regression guard against the
19003        // former brain-wide private-copy implementation.
19004        install_established_v2_delta(
19005            Store::open_strict(&root).unwrap(),
19006            &[same_entry],
19007            &["records/items/already-absent.md".to_string()],
19008            true,
19009            None,
19010            &head,
19011        )
19012        .unwrap();
19013        assert_eq!(
19014            std::fs::metadata(&untouched).unwrap().ino(),
19015            untouched_inode
19016        );
19017        assert!(!root.join(V2_PULL_JOURNAL).exists());
19018
19019        install_established_v2_delta(
19020            Store::open_strict(&root).unwrap(),
19021            &[entry],
19022            &["records/items/delete.md".to_string()],
19023            false,
19024            None,
19025            &head,
19026        )
19027        .unwrap();
19028        assert_eq!(
19029            std::fs::read(root.join("records/items/change.md")).unwrap(),
19030            new
19031        );
19032        assert!(!root.join("records/items/delete.md").exists());
19033        assert_eq!(
19034            std::fs::metadata(&untouched).unwrap().ino(),
19035            untouched_inode
19036        );
19037        assert!(root.join(V2_PULL_JOURNAL).is_file());
19038        assert_eq!(
19039            std::fs::metadata(root.join(V2_PULL_JOURNAL))
19040                .unwrap()
19041                .permissions()
19042                .mode()
19043                & 0o777,
19044            0o600
19045        );
19046        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
19047            .unwrap()
19048            .unwrap();
19049        assert_eq!(
19050            std::fs::metadata(root.join(&journal.backup_dir))
19051                .unwrap()
19052                .permissions()
19053                .mode()
19054                & 0o777,
19055            0o700
19056        );
19057        for entry in &journal.entries {
19058            if let Some(backup) = &entry.backup {
19059                assert_eq!(
19060                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
19061                        .unwrap()
19062                        .permissions()
19063                        .mode()
19064                        & 0o777,
19065                    0o600
19066                );
19067            }
19068        }
19069
19070        let cfg = test_hub_config(
19071            "https://example.test".to_string(),
19072            sandbox.path().join("state"),
19073        );
19074        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19075        assert_eq!(
19076            std::fs::read(root.join("records/items/change.md")).unwrap(),
19077            old
19078        );
19079        assert_eq!(
19080            std::fs::read(root.join("records/items/delete.md")).unwrap(),
19081            removed
19082        );
19083        assert_eq!(
19084            std::fs::metadata(&untouched).unwrap().ino(),
19085            untouched_inode
19086        );
19087        assert!(!root.join(V2_PULL_JOURNAL).exists());
19088    }
19089
19090    #[test]
19091    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
19092        let body = b"bounded bytes";
19093        let path = "records/example.md".to_string();
19094        let file = V2BaselineFile {
19095            sha256: content_sha256(body),
19096            bytes: body.len() as u64,
19097            proof: None,
19098        };
19099        let header = serde_json::to_vec(&json!({
19100            "bytes": body.len(),
19101            "path": path,
19102            "sha256": file.sha256,
19103            "v": 2,
19104        }))
19105        .unwrap();
19106        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
19107        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
19108        stream.extend_from_slice(&header);
19109        stream.extend_from_slice(body);
19110        stream.extend_from_slice(&0_u32.to_be_bytes());
19111        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
19112        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
19113
19114        let mut tampered = stream.clone();
19115        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
19116        tampered[body_offset] ^= 1;
19117        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
19118
19119        let mut trailing = stream;
19120        trailing.push(0);
19121        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
19122    }
19123
19124    #[test]
19125    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
19126        let sandbox = tempfile::TempDir::new().unwrap();
19127        let root = sandbox.path().join("brain");
19128        std::fs::create_dir_all(&root).unwrap();
19129        std::fs::write(
19130            root.join("DB.md"),
19131            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19132        )
19133        .unwrap();
19134        let store = Store::open_strict(&root).unwrap();
19135        let incomplete = crate::ulid::mint();
19136        store
19137            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
19138            .unwrap();
19139        let expired = crate::ulid::mint();
19140        store
19141            .create_dir_all(&v2_conflict_relative(&expired, "files"))
19142            .unwrap();
19143        let plan = V2ConflictPlan {
19144            v: 2,
19145            class: "content_resolution_required".to_string(),
19146            bundle: expired.clone(),
19147            brain: TEST_BRAIN_ID.to_string(),
19148            origin: "https://example.test".to_string(),
19149            created_unix: 0,
19150            expires_unix: 0,
19151            base_seq: None,
19152            base_commit: None,
19153            remote_seq: 0,
19154            remote_commit: None,
19155            remote_content_root: None,
19156            view_kind: "full".to_string(),
19157            view_revision: "a".repeat(64),
19158            files: vec![V2ConflictFile {
19159                path: "records/value.md".to_string(),
19160                base: V2ConflictCoordinate {
19161                    sha256: None,
19162                    bytes: None,
19163                    file: None,
19164                },
19165                local: V2ConflictCoordinate {
19166                    sha256: None,
19167                    bytes: None,
19168                    file: None,
19169                },
19170                remote: V2ConflictCoordinate {
19171                    sha256: None,
19172                    bytes: None,
19173                    file: None,
19174                },
19175            }],
19176        };
19177        let mut bytes = serde_json::to_vec(&plan).unwrap();
19178        bytes.push(b'\n');
19179        store
19180            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
19181            .unwrap();
19182
19183        let listed = sync_conflicts(&root, false, false).unwrap();
19184        assert_eq!(listed["bundles"], 2);
19185        assert_eq!(listed["pruned"], 0);
19186        let pruned = sync_conflicts(&root, true, false).unwrap();
19187        assert_eq!(pruned["bundles"], 0);
19188        assert_eq!(pruned["pruned"], 2);
19189        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
19190        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
19191    }
19192
19193    #[test]
19194    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
19195        let sandbox = tempfile::TempDir::new().unwrap();
19196        let root = sandbox.path().join("brain");
19197        std::fs::create_dir_all(&root).unwrap();
19198        std::fs::write(
19199            root.join("DB.md"),
19200            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19201        )
19202        .unwrap();
19203        let store = Store::open_strict(&root).unwrap();
19204        let bundle = crate::ulid::mint();
19205        store
19206            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
19207            .unwrap();
19208        store
19209            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
19210            .unwrap();
19211
19212        assert!(sync_conflicts(&root, true, false).is_err());
19213        assert!(sync_conflicts(&root, false, true).is_err());
19214        let pruned = sync_conflicts(&root, true, true).unwrap();
19215        assert_eq!(pruned["pruned"], 1);
19216        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
19217    }
19218
19219    #[test]
19220    fn ready_pull_journal_rolls_back_exact_preimages() {
19221        let sandbox = tempfile::TempDir::new().unwrap();
19222        let root = sandbox.path().join("brain");
19223        std::fs::create_dir_all(root.join("records")).unwrap();
19224        std::fs::write(
19225            root.join("DB.md"),
19226            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19227        )
19228        .unwrap();
19229        let path = "records/value.md";
19230        let old = b"---\ntype: note\n---\n\nold\n";
19231        let new = b"---\ntype: note\n---\n\nnew\n";
19232        std::fs::write(root.join(path), old).unwrap();
19233        let store = Store::open_strict(&root).unwrap();
19234        let bundle = crate::ulid::mint();
19235        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19236        store
19237            .create_private_dir_all(Path::new(&backup_dir))
19238            .unwrap();
19239        store
19240            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
19241            .unwrap();
19242        let journal = V2PullJournal {
19243            v: 1,
19244            phase: V2PullPhase::Ready,
19245            brain: TEST_BRAIN_ID.to_string(),
19246            previous: V2PullCoordinate {
19247                head_seq: None,
19248                commit_hash: None,
19249                view_kind: None,
19250                view_revision: None,
19251            },
19252            next: V2PullCoordinate {
19253                head_seq: Some(2),
19254                commit_hash: Some("c".repeat(64)),
19255                view_kind: Some("full".to_string()),
19256                view_revision: Some("d".repeat(64)),
19257            },
19258            backup_dir: backup_dir.clone(),
19259            entries: vec![V2PullJournalEntry {
19260                path: path.to_string(),
19261                old: Some(V2PullFileCoordinate {
19262                    sha256: content_sha256(old),
19263                    bytes: old.len() as u64,
19264                }),
19265                new: Some(V2PullFileCoordinate {
19266                    sha256: content_sha256(new),
19267                    bytes: new.len() as u64,
19268                }),
19269                backup: Some("00000000".to_string()),
19270            }],
19271        };
19272        validate_v2_pull_journal(&journal).unwrap();
19273        store
19274            .write_private_atomic_new(
19275                Path::new(V2_PULL_JOURNAL),
19276                &v2_pull_journal_bytes(&journal).unwrap(),
19277            )
19278            .unwrap();
19279        store.write_atomic(Path::new(path), new).unwrap();
19280
19281        let cfg = test_hub_config(
19282            "https://example.test".to_string(),
19283            sandbox.path().join("state"),
19284        );
19285        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19286        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
19287        assert!(!root.join(V2_PULL_JOURNAL).exists());
19288        assert!(!root.join(backup_dir).exists());
19289    }
19290
19291    #[test]
19292    fn preparing_pull_journal_discards_only_private_staging() {
19293        let sandbox = tempfile::TempDir::new().unwrap();
19294        let root = sandbox.path().join("brain");
19295        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
19296        std::fs::write(
19297            root.join("DB.md"),
19298            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19299        )
19300        .unwrap();
19301        let store = Store::open_strict(&root).unwrap();
19302        let bundle = crate::ulid::mint();
19303        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19304        store
19305            .create_private_dir_all(Path::new(&backup_dir))
19306            .unwrap();
19307        let journal = V2PullJournal {
19308            v: 1,
19309            phase: V2PullPhase::Preparing,
19310            brain: TEST_BRAIN_ID.to_string(),
19311            previous: V2PullCoordinate {
19312                head_seq: None,
19313                commit_hash: None,
19314                view_kind: None,
19315                view_revision: None,
19316            },
19317            next: V2PullCoordinate {
19318                head_seq: Some(1),
19319                commit_hash: Some("a".repeat(64)),
19320                view_kind: Some("full".to_string()),
19321                view_revision: Some("b".repeat(64)),
19322            },
19323            backup_dir: backup_dir.clone(),
19324            entries: vec![V2PullJournalEntry {
19325                path: "records/new.md".to_string(),
19326                old: None,
19327                new: Some(V2PullFileCoordinate {
19328                    sha256: "c".repeat(64),
19329                    bytes: 1,
19330                }),
19331                backup: None,
19332            }],
19333        };
19334        store
19335            .write_private_atomic_new(
19336                Path::new(V2_PULL_JOURNAL),
19337                &v2_pull_journal_bytes(&journal).unwrap(),
19338            )
19339            .unwrap();
19340        let cfg = test_hub_config(
19341            "https://example.test".to_string(),
19342            sandbox.path().join("state"),
19343        );
19344
19345        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19346
19347        assert!(root.join("DB.md").is_file());
19348        assert!(!root.join(V2_PULL_JOURNAL).exists());
19349        assert!(!root.join(backup_dir).exists());
19350    }
19351
19352    #[test]
19353    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
19354        let sandbox = tempfile::TempDir::new().unwrap();
19355        let root = sandbox.path().join("brain");
19356        std::fs::create_dir_all(root.join("records")).unwrap();
19357        std::fs::write(
19358            root.join("DB.md"),
19359            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19360        )
19361        .unwrap();
19362        let new = b"---\ntype: note\n---\n\nnew\n";
19363        std::fs::write(root.join("records/value.md"), new).unwrap();
19364        let store = Store::open_strict(&root).unwrap();
19365        let bundle = crate::ulid::mint();
19366        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19367        store
19368            .create_private_dir_all(Path::new(&backup_dir))
19369            .unwrap();
19370        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
19371        store.create_private_dir_all(Path::new(&orphan)).unwrap();
19372        let next = V2PullCoordinate {
19373            head_seq: Some(2),
19374            commit_hash: Some("c".repeat(64)),
19375            view_kind: Some("full".to_string()),
19376            view_revision: Some("d".repeat(64)),
19377        };
19378        let journal = V2PullJournal {
19379            v: 1,
19380            phase: V2PullPhase::Ready,
19381            brain: TEST_BRAIN_ID.to_string(),
19382            previous: V2PullCoordinate {
19383                head_seq: Some(1),
19384                commit_hash: Some("a".repeat(64)),
19385                view_kind: Some("full".to_string()),
19386                view_revision: Some("b".repeat(64)),
19387            },
19388            next: next.clone(),
19389            backup_dir: backup_dir.clone(),
19390            entries: vec![V2PullJournalEntry {
19391                path: "records/value.md".to_string(),
19392                old: Some(V2PullFileCoordinate {
19393                    sha256: "e".repeat(64),
19394                    bytes: new.len() as u64,
19395                }),
19396                new: Some(V2PullFileCoordinate {
19397                    sha256: content_sha256(new),
19398                    bytes: new.len() as u64,
19399                }),
19400                backup: Some("00000000".to_string()),
19401            }],
19402        };
19403        store
19404            .write_private_atomic_new(
19405                Path::new(V2_PULL_JOURNAL),
19406                &v2_pull_journal_bytes(&journal).unwrap(),
19407            )
19408            .unwrap();
19409        let cfg = test_hub_config(
19410            "https://example.test".to_string(),
19411            sandbox.path().join("state"),
19412        );
19413        save_v2_baseline(
19414            &cfg,
19415            TEST_BRAIN_ID,
19416            &root,
19417            &V2SyncBaseline {
19418                v: 2,
19419                origin: "https://example.test".to_string(),
19420                brain: TEST_BRAIN_ID.to_string(),
19421                checkout_id: Some("c".repeat(64)),
19422                head_seq: next.head_seq,
19423                commit_hash: next.commit_hash.clone(),
19424                content_root: Some("f".repeat(64)),
19425                asset_root: None,
19426                assets: Default::default(),
19427                view_kind: next.view_kind.clone(),
19428                view_revision: next.view_revision.clone(),
19429                control_revision: Some("d".repeat(64)),
19430                projection_sha256: None,
19431                files: Default::default(),
19432                local_policy_digest: None,
19433                local_eligibility: Default::default(),
19434                remote_copy_remains: Default::default(),
19435            },
19436        )
19437        .unwrap();
19438
19439        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19440
19441        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
19442        assert!(!root.join(V2_PULL_JOURNAL).exists());
19443        assert!(!root.join(backup_dir).exists());
19444        assert!(!root.join(orphan).exists());
19445    }
19446}