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    #[serde(default)]
3664    projection_sha256: Option<String>,
3665    files: std::collections::BTreeMap<String, V2BaselineFile>,
3666    #[serde(default)]
3667    local_policy_digest: Option<String>,
3668    #[serde(default)]
3669    local_eligibility: std::collections::BTreeMap<String, bool>,
3670    #[serde(default)]
3671    remote_copy_remains: std::collections::BTreeMap<String, String>,
3672}
3673
3674struct V2LocalView {
3675    riding: std::collections::BTreeMap<String, (String, u64)>,
3676    eligibility: std::collections::BTreeMap<String, bool>,
3677    policy: crate::linkmd_sync_policy::SyncPolicy,
3678    withheld_links: Vec<V2WithheldLink>,
3679}
3680
3681#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3682struct V2WithheldLink {
3683    source: String,
3684    target: String,
3685}
3686
3687#[derive(Debug, Clone, Deserialize, Serialize)]
3688struct V2ProofStep {
3689    directory_root: String,
3690    component: String,
3691    proof: crate::linkmd_v2::HamtProof,
3692}
3693
3694#[derive(Debug, Deserialize)]
3695struct V2ManifestFile {
3696    path: String,
3697    sha256: String,
3698    bytes: u64,
3699    proof: Vec<V2ProofStep>,
3700}
3701
3702#[derive(Debug, Deserialize)]
3703struct V2ManifestPage {
3704    v: u8,
3705    commit: String,
3706    content_root: Option<String>,
3707    files: Vec<V2ManifestFile>,
3708    next_cursor: Option<String>,
3709}
3710
3711#[derive(Debug, Clone, Deserialize, Serialize)]
3712struct V2BaselineAsset {
3713    blob_sha256: String,
3714    bytes: u64,
3715    media_type: String,
3716    wrappers: Vec<String>,
3717    required: bool,
3718    disposition: String,
3719    leaf_hash: String,
3720}
3721
3722#[derive(Debug, Deserialize)]
3723struct V2AssetManifestItem {
3724    path: String,
3725    blob_sha256: String,
3726    bytes: u64,
3727    media_type: String,
3728    wrappers: Vec<String>,
3729    required: bool,
3730    disposition: String,
3731    leaf_hash: String,
3732    proof: crate::linkmd_v2::HamtProof,
3733}
3734
3735#[derive(Debug, Deserialize)]
3736struct V2AssetManifestPage {
3737    v: u8,
3738    commit: String,
3739    asset_root: Option<String>,
3740    assets: Vec<V2AssetManifestItem>,
3741    next_cursor: Option<String>,
3742}
3743
3744#[derive(Debug, Deserialize)]
3745struct V2SigningCandidate {
3746    seq: u64,
3747    content_root: Option<String>,
3748    asset_root: Option<String>,
3749    signing_bytes_base64: String,
3750    changes_base64: String,
3751    actor_claim_base64: String,
3752}
3753
3754#[derive(Debug, Deserialize)]
3755struct V2SigningCandidatePage {
3756    v: u8,
3757    challenge_id: String,
3758    mutation_id: String,
3759    request_hash: String,
3760    parent: V2SigningParent,
3761    candidate: V2SigningCandidate,
3762    files: Vec<V2ManifestFile>,
3763    #[serde(default)]
3764    assets: Vec<V2AssetManifestItem>,
3765    next_cursor: Option<String>,
3766    expires_at: String,
3767}
3768
3769#[derive(Debug, Deserialize)]
3770struct V2SigningParent {
3771    seq: u64,
3772    commit_hash: Option<String>,
3773}
3774
3775fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3776    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3777        .map_err(|error| invalid_feed(error.to_string()))?;
3778    let components = normalized.split('/').collect::<Vec<_>>();
3779    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3780        return Err(invalid_feed("v2 file proof has the wrong shape"));
3781    }
3782    let mut directory_root = root.to_string();
3783    for (index, step) in file.proof.iter().enumerate() {
3784        if step.directory_root != directory_root || step.component != components[index] {
3785            return Err(invalid_feed(
3786                "v2 file proof path chain differs from its manifest",
3787            ));
3788        }
3789        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3790            .map_err(|error| invalid_feed(error.to_string()))?
3791        {
3792            return Err(invalid_feed("v2 file proof failed verification"));
3793        }
3794        let entry = match &step.proof {
3795            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3796            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3797                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3798            }
3799        };
3800        if index + 1 == components.len() {
3801            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3802                || entry.child_hash != file.sha256
3803                || entry.bytes != Some(file.bytes)
3804            {
3805                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3806            }
3807        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3808            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3809        } else {
3810            directory_root = entry.child_hash.clone();
3811        }
3812    }
3813    Ok(())
3814}
3815
3816fn v2_manifest(
3817    cfg: &HubConfig,
3818    brain: &str,
3819    pointer: Option<&V2PointerBody>,
3820) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3821    let Some(pointer) = pointer else {
3822        return Ok(std::collections::BTreeMap::new());
3823    };
3824    let Some(root) = pointer.content_root.as_deref() else {
3825        return Ok(std::collections::BTreeMap::new());
3826    };
3827    let mut files = std::collections::BTreeMap::new();
3828    let mut after = String::new();
3829    loop {
3830        let encoded_after: String =
3831            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3832        let path = format!(
3833            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3834            pointer.commit_hash
3835        );
3836        let value = ensure_ok(
3837            request_capped(
3838                cfg,
3839                "GET",
3840                &path,
3841                None,
3842                Auth::Required,
3843                MAX_FEED_RESPONSE_BYTES,
3844            )?,
3845            "v2 file manifest",
3846        )?;
3847        let page: V2ManifestPage = serde_json::from_value(value)
3848            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3849        if page.v != 2
3850            || page.commit != pointer.commit_hash
3851            || page.content_root.as_deref() != Some(root)
3852            || page.files.len() > 500
3853        {
3854            return Err(invalid_feed(
3855                "v2 file manifest is not bound to the verified head",
3856            ));
3857        }
3858        for file in page.files {
3859            verify_v2_file_proof(root, &file)?;
3860            if files
3861                .insert(
3862                    file.path.clone(),
3863                    V2BaselineFile {
3864                        sha256: file.sha256,
3865                        bytes: file.bytes,
3866                        proof: Some(file.proof),
3867                    },
3868                )
3869                .is_some()
3870            {
3871                return Err(invalid_feed("v2 file manifest repeats a path"));
3872            }
3873            if files.len() > MAX_PUSH_FILES {
3874                return Err(invalid_feed(
3875                    "v2 file manifest exceeds the file-count bound",
3876                ));
3877            }
3878        }
3879        match page.next_cursor {
3880            None => break,
3881            Some(next) if next > after => after = next,
3882            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3883        }
3884    }
3885    Ok(files)
3886}
3887
3888/// Fetch one exact path proof instead of enumerating the whole permission
3889/// view. This keeps `resolve @brain/path` proportional to path depth even for
3890/// a very large brain. The returned leaf is accepted only after its complete
3891/// proof chain reaches the content root signed by `pointer`.
3892fn v2_manifest_file(
3893    cfg: &HubConfig,
3894    brain: &str,
3895    pointer: &V2PointerBody,
3896    path: &str,
3897) -> LinkResult<Option<V2BaselineFile>> {
3898    let Some(root) = pointer.content_root.as_deref() else {
3899        return Ok(None);
3900    };
3901    crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3902        path: error.to_string(),
3903    })?;
3904    let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3905    let response = request_capped(
3906        cfg,
3907        "GET",
3908        &format!(
3909            "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3910            pointer.commit_hash
3911        ),
3912        None,
3913        Auth::Required,
3914        MAX_FEED_RESPONSE_BYTES,
3915    )?;
3916    // Exact v2 file reads deliberately collapse absent and unreadable paths
3917    // into the same 404. Preserve that non-oracle as a normal `None`; callers
3918    // such as `resolve` then produce the stable, local `NOT_FOUND` contract.
3919    if response.status == 404 {
3920        return Ok(None);
3921    }
3922    let value = ensure_ok(response, "v2 exact file proof")?;
3923    let mut page: V2ManifestPage = serde_json::from_value(value)
3924        .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3925    if page.v != 2
3926        || page.commit != pointer.commit_hash
3927        || page.content_root.as_deref() != Some(root)
3928        || page.next_cursor.is_some()
3929        || page.files.len() != 1
3930        || page.files[0].path != path
3931    {
3932        return Err(invalid_feed(
3933            "v2 exact file proof is not bound to the requested signed path",
3934        ));
3935    }
3936    let file = page.files.pop().expect("exactly one file was checked");
3937    verify_v2_file_proof(root, &file)?;
3938    Ok(Some(V2BaselineFile {
3939        sha256: file.sha256,
3940        bytes: file.bytes,
3941        proof: Some(file.proof),
3942    }))
3943}
3944
3945/// Resolve an id through the hub's synchronously maintained, content-light
3946/// validation certificate, then require the same exact signed path proof as a
3947/// path lookup. The certificate is only an efficient locator: the downloaded
3948/// bytes are parsed again and their id is checked by the caller.
3949fn v2_manifest_file_by_id(
3950    cfg: &HubConfig,
3951    brain: &str,
3952    pointer: &V2PointerBody,
3953    id: &str,
3954) -> LinkResult<(String, V2BaselineFile)> {
3955    let root = pointer
3956        .content_root
3957        .as_deref()
3958        .ok_or_else(|| LinkError::Http {
3959            what: "resolve",
3960            status: 404,
3961            message: "record not found".to_string(),
3962            code: Some("NOT_FOUND".to_string()),
3963            details: None,
3964        })?;
3965    if !crate::ulid::is_ulid(id) {
3966        return Err(LinkError::BadAddress {
3967            given: id.to_string(),
3968            reason: BAD_TARGET_REASON.to_string(),
3969        });
3970    }
3971    let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3972    let value = ensure_ok(
3973        request_capped(
3974            cfg,
3975            "GET",
3976            &format!(
3977                "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3978                pointer.commit_hash
3979            ),
3980            None,
3981            Auth::Required,
3982            MAX_FEED_RESPONSE_BYTES,
3983        )?,
3984        "v2 exact id proof",
3985    )?;
3986    let mut page: V2ManifestPage = serde_json::from_value(value)
3987        .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3988    if page.v != 2
3989        || page.commit != pointer.commit_hash
3990        || page.content_root.as_deref() != Some(root)
3991        || page.next_cursor.is_some()
3992        || page.files.len() != 1
3993    {
3994        return Err(invalid_feed(
3995            "v2 exact id proof is not bound to one signed path",
3996        ));
3997    }
3998    let file = page.files.pop().expect("exactly one file was checked");
3999    if !safe_store_rel_path(&file.path)
4000        || !file.path.ends_with(".md")
4001        || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4002    {
4003        return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4004    }
4005    verify_v2_file_proof(root, &file)?;
4006    Ok((
4007        file.path,
4008        V2BaselineFile {
4009            sha256: file.sha256,
4010            bytes: file.bytes,
4011            proof: Some(file.proof),
4012        },
4013    ))
4014}
4015
4016fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4017    crate::linkmd_v2::normalize_path(&item.path)
4018        .map_err(|error| invalid_feed(error.to_string()))?;
4019    if !is_sha256(&item.blob_sha256)
4020        || !is_sha256(&item.leaf_hash)
4021        || item.bytes > MAX_ASSET_BYTES
4022        || item.wrappers.is_empty()
4023        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4024        || item
4025            .wrappers
4026            .iter()
4027            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4028    {
4029        return Err(invalid_feed("v2 asset manifest item is invalid"));
4030    }
4031    let leaf = json!({
4032        "blob_sha256": item.blob_sha256,
4033        "bytes": item.bytes,
4034        "disposition": item.disposition,
4035        "media_type": item.media_type,
4036        "path": item.path,
4037        "required": item.required,
4038        "v": 2,
4039        "wrappers": item.wrappers,
4040    });
4041    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4042        .map_err(|error| invalid_feed(error.to_string()))?
4043        != item.leaf_hash
4044        || !crate::linkmd_v2::verify_proof_with_domain(
4045            root,
4046            &item.path,
4047            &item.proof,
4048            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4049        )
4050        .map_err(|error| invalid_feed(error.to_string()))?
4051    {
4052        return Err(invalid_feed("v2 asset inclusion proof failed"));
4053    }
4054    match &item.proof {
4055        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4056            if entry.name == item.path
4057                && entry.kind == crate::linkmd_v2::EntryKind::Blob
4058                && entry.child_hash == item.leaf_hash
4059                && entry.bytes == Some(item.bytes) =>
4060        {
4061            Ok(())
4062        }
4063        _ => Err(invalid_feed(
4064            "v2 asset proof leaf differs from its manifest",
4065        )),
4066    }
4067}
4068
4069fn v2_asset_manifest(
4070    cfg: &HubConfig,
4071    brain: &str,
4072    pointer: Option<&V2PointerBody>,
4073) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4074    let Some(pointer) = pointer else {
4075        return Ok(std::collections::BTreeMap::new());
4076    };
4077    let Some(root) = pointer.asset_root.as_deref() else {
4078        return Ok(std::collections::BTreeMap::new());
4079    };
4080    let mut assets = std::collections::BTreeMap::new();
4081    let mut after = String::new();
4082    loop {
4083        let encoded_after: String =
4084            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4085        let path = format!(
4086            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4087            pointer.commit_hash
4088        );
4089        let value = ensure_ok(
4090            request_capped(
4091                cfg,
4092                "GET",
4093                &path,
4094                None,
4095                Auth::Required,
4096                MAX_FEED_RESPONSE_BYTES,
4097            )?,
4098            "v2 asset manifest",
4099        )?;
4100        let page: V2AssetManifestPage = serde_json::from_value(value)
4101            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4102        if page.v != 2
4103            || page.commit != pointer.commit_hash
4104            || page.asset_root.as_deref() != Some(root)
4105            || page.assets.len() > 500
4106        {
4107            return Err(invalid_feed(
4108                "v2 asset manifest is not bound to the verified head",
4109            ));
4110        }
4111        for item in page.assets {
4112            verify_v2_asset_proof(root, &item)?;
4113            let path = item.path.clone();
4114            if assets
4115                .insert(
4116                    path,
4117                    V2BaselineAsset {
4118                        blob_sha256: item.blob_sha256,
4119                        bytes: item.bytes,
4120                        media_type: item.media_type,
4121                        wrappers: item.wrappers,
4122                        required: item.required,
4123                        disposition: item.disposition,
4124                        leaf_hash: item.leaf_hash,
4125                    },
4126                )
4127                .is_some()
4128            {
4129                return Err(invalid_feed("v2 asset manifest repeats a path"));
4130            }
4131            if assets.len() > MAX_PUSH_FILES {
4132                return Err(invalid_feed(
4133                    "v2 asset manifest exceeds the item-count bound",
4134                ));
4135            }
4136        }
4137        match page.next_cursor {
4138            None => break,
4139            Some(next) if next > after => after = next,
4140            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4141        }
4142    }
4143    Ok(assets)
4144}
4145
4146fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4147    crate::AssetRecord {
4148        path: path.to_string(),
4149        sha256: asset.blob_sha256.clone(),
4150        bytes: asset.bytes,
4151        media_type: asset.media_type.clone(),
4152        wrappers: asset.wrappers.clone(),
4153        required: asset.required,
4154    }
4155}
4156
4157fn v2_asset_resumes_hosting(
4158    remote: Option<&V2BaselineAsset>,
4159    path: &str,
4160    record: &crate::AssetRecord,
4161    disposition: &str,
4162) -> bool {
4163    remote.is_some_and(|asset| {
4164        asset.disposition == "withheld"
4165            && disposition == "hosted"
4166            && v2_asset_record(asset, path) == *record
4167    })
4168}
4169
4170fn v2_asset_record_manifest_bytes(
4171    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4172) -> LinkResult<Vec<u8>> {
4173    let mut bytes = Vec::new();
4174    for (path, asset) in assets {
4175        if asset.path != *path {
4176            return Err(invalid_feed(
4177                "local asset manifest key differs from its record path",
4178            ));
4179        }
4180        serde_json::to_writer(&mut bytes, asset)
4181            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4182        bytes.push(b'\n');
4183    }
4184    Ok(bytes)
4185}
4186
4187fn v2_local_asset_records(
4188    store: &Store,
4189) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4190    let assets = crate::assets::read_manifest(store)
4191        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4192    if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4193        return Err(LinkError::InvalidPack {
4194            message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4195        });
4196    }
4197    Ok(assets
4198        .into_iter()
4199        .map(|asset| (asset.path.clone(), asset))
4200        .collect())
4201}
4202
4203fn v2_asset_records_match_remote(
4204    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4205    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4206) -> bool {
4207    local.len() == remote.len()
4208        && remote
4209            .iter()
4210            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4211}
4212
4213#[derive(Debug, Clone, PartialEq, Eq)]
4214struct V2PulledMerge<T> {
4215    records: std::collections::BTreeMap<String, T>,
4216    accept_remote: std::collections::BTreeSet<String>,
4217    conflicts: Vec<String>,
4218}
4219
4220/// Compute the local checkout that a pull may install without discarding a
4221/// local-only edit. Remote-only changes are accepted, identical concurrent
4222/// changes converge, and divergent concurrent changes are reported. The
4223/// caller can exempt kept-home coordinates from both remote installation and
4224/// conflict disclosure.
4225fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4226    base: &std::collections::BTreeMap<String, Base>,
4227    remote: &std::collections::BTreeMap<String, Remote>,
4228    local: &std::collections::BTreeMap<String, Record>,
4229    base_record: BaseRecord,
4230    remote_record: RemoteRecord,
4231    keep_local: KeepLocal,
4232) -> V2PulledMerge<Record>
4233where
4234    Record: Clone + Eq,
4235    BaseRecord: Fn(&Base, &str) -> Record,
4236    RemoteRecord: Fn(&Remote, &str) -> Record,
4237    KeepLocal: Fn(&str) -> bool,
4238{
4239    let paths = base
4240        .keys()
4241        .chain(remote.keys())
4242        .chain(local.keys())
4243        .cloned()
4244        .collect::<std::collections::BTreeSet<_>>();
4245    let mut records = local.clone();
4246    let mut accept_remote = std::collections::BTreeSet::new();
4247    let mut conflicts = Vec::new();
4248    for path in paths {
4249        if keep_local(&path) {
4250            continue;
4251        }
4252        let base_value = base.get(&path).map(|value| base_record(value, &path));
4253        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4254        let local_value = local.get(&path).cloned();
4255        if local_value != base_value && remote_value != base_value && local_value != remote_value {
4256            conflicts.push(path);
4257            continue;
4258        }
4259        if local_value == base_value || local_value == remote_value {
4260            accept_remote.insert(path.clone());
4261            match remote_value {
4262                Some(value) => {
4263                    records.insert(path, value);
4264                }
4265                None => {
4266                    records.remove(&path);
4267                }
4268            }
4269        }
4270    }
4271    V2PulledMerge {
4272        records,
4273        accept_remote,
4274        conflicts,
4275    }
4276}
4277
4278fn sign_verified_v2_candidate(
4279    cfg: &HubConfig,
4280    head: &V2VerifiedHead,
4281    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4282    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4283    mutation_id: &str,
4284    request_body: &Value,
4285    challenge_value: &Value,
4286) -> LinkResult<(String, String, String)> {
4287    if head.view_kind != "full" {
4288        return Err(invalid_feed(
4289            "a scoped self-custody writer must use the proposal workflow",
4290        ));
4291    }
4292    if head.identity.custody != "self" {
4293        return Err(invalid_feed(
4294            "a hub-custodied brain unexpectedly requested an external signature",
4295        ));
4296    }
4297    let key = cfg
4298        .brain_key
4299        .as_ref()
4300        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4301    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4302        || key.public_key_spki != head.identity.public_key_spki
4303    {
4304        return Err(bad_agent_key(
4305            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4306        ));
4307    }
4308    let challenge_id = challenge_value
4309        .get("id")
4310        .and_then(Value::as_str)
4311        .filter(|id| crate::ulid::is_ulid(id))
4312        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4313    let expected_endpoint = format!(
4314        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4315        head.brain_id
4316    );
4317    if challenge_value
4318        .get("candidate_endpoint")
4319        .and_then(Value::as_str)
4320        != Some(expected_endpoint.as_str())
4321    {
4322        return Err(invalid_feed(
4323            "self-custody challenge candidate endpoint is not origin-bound",
4324        ));
4325    }
4326
4327    let mut files = std::collections::BTreeMap::new();
4328    let mut after = String::new();
4329    type CandidateCoordinate = (
4330        String,
4331        String,
4332        String,
4333        String,
4334        Option<String>,
4335        Option<String>,
4336        u64,
4337        Option<String>,
4338    );
4339    let mut pinned: Option<CandidateCoordinate> = None;
4340    loop {
4341        let encoded_after: String =
4342            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4343        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4344        let value = ensure_ok(
4345            request_capped(
4346                cfg,
4347                "GET",
4348                &path,
4349                None,
4350                Auth::Required,
4351                MAX_FEED_RESPONSE_BYTES,
4352            )?,
4353            "v2 self-custody candidate",
4354        )?;
4355        let page: V2SigningCandidatePage = serde_json::from_value(value)
4356            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4357        if page.v != 2
4358            || page.challenge_id != challenge_id
4359            || page.mutation_id != mutation_id
4360            || page.candidate.seq != page.parent.seq + 1
4361            || page.files.len() > 500
4362            || page.expires_at.is_empty()
4363        {
4364            return Err(invalid_feed(
4365                "self-custody candidate is not bound to this mutation",
4366            ));
4367        }
4368        let coordinate = (
4369            page.request_hash.clone(),
4370            page.candidate.signing_bytes_base64.clone(),
4371            page.candidate.changes_base64.clone(),
4372            page.candidate.actor_claim_base64.clone(),
4373            page.candidate.content_root.clone(),
4374            page.candidate.asset_root.clone(),
4375            page.parent.seq,
4376            page.parent.commit_hash.clone(),
4377        );
4378        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4379            return Err(invalid_feed(
4380                "self-custody candidate changed between manifest pages",
4381            ));
4382        }
4383        pinned = Some(coordinate);
4384        let root = page
4385            .candidate
4386            .content_root
4387            .as_deref()
4388            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4389        for file in page.files {
4390            verify_v2_file_proof(root, &file)?;
4391            if files
4392                .insert(
4393                    file.path.clone(),
4394                    V2BaselineFile {
4395                        sha256: file.sha256,
4396                        bytes: file.bytes,
4397                        proof: Some(file.proof),
4398                    },
4399                )
4400                .is_some()
4401            {
4402                return Err(invalid_feed(
4403                    "self-custody candidate repeats a manifest path",
4404                ));
4405            }
4406            if files.len() > MAX_PUSH_FILES {
4407                return Err(invalid_feed(
4408                    "self-custody candidate exceeds the file-count bound",
4409                ));
4410            }
4411        }
4412        match page.next_cursor {
4413            None => break,
4414            Some(next) if next > after => after = next,
4415            Some(_) => {
4416                return Err(invalid_feed(
4417                    "self-custody candidate cursor did not advance",
4418                ))
4419            }
4420        }
4421    }
4422    if files.len() != expected.len()
4423        || files.iter().any(|(path, file)| {
4424            expected.get(path).is_none_or(|expected| {
4425                expected.sha256 != file.sha256 || expected.bytes != file.bytes
4426            })
4427        })
4428    {
4429        return Err(invalid_feed(
4430            "self-custody candidate contains an unexpected file mutation",
4431        ));
4432    }
4433    let mut assets = std::collections::BTreeMap::new();
4434    after.clear();
4435    loop {
4436        let encoded_after: String =
4437            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4438        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4439        let value = ensure_ok(
4440            request_capped(
4441                cfg,
4442                "GET",
4443                &path,
4444                None,
4445                Auth::Required,
4446                MAX_FEED_RESPONSE_BYTES,
4447            )?,
4448            "v2 self-custody asset candidate",
4449        )?;
4450        let page: V2SigningCandidatePage = serde_json::from_value(value)
4451            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4452        let coordinate = (
4453            page.request_hash.clone(),
4454            page.candidate.signing_bytes_base64.clone(),
4455            page.candidate.changes_base64.clone(),
4456            page.candidate.actor_claim_base64.clone(),
4457            page.candidate.content_root.clone(),
4458            page.candidate.asset_root.clone(),
4459            page.parent.seq,
4460            page.parent.commit_hash.clone(),
4461        );
4462        if page.v != 2
4463            || page.challenge_id != challenge_id
4464            || page.mutation_id != mutation_id
4465            || page.assets.len() > 500
4466            || pinned.as_ref() != Some(&coordinate)
4467        {
4468            return Err(invalid_feed(
4469                "self-custody asset candidate changed or is not bound",
4470            ));
4471        }
4472        let root = page.candidate.asset_root.as_deref();
4473        if !page.assets.is_empty() && root.is_none() {
4474            return Err(invalid_feed("asset candidate has no asset root"));
4475        }
4476        for item in page.assets {
4477            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4478            if assets
4479                .insert(
4480                    item.path.clone(),
4481                    V2BaselineAsset {
4482                        blob_sha256: item.blob_sha256,
4483                        bytes: item.bytes,
4484                        media_type: item.media_type,
4485                        wrappers: item.wrappers,
4486                        required: item.required,
4487                        disposition: item.disposition,
4488                        leaf_hash: item.leaf_hash,
4489                    },
4490                )
4491                .is_some()
4492            {
4493                return Err(invalid_feed("self-custody candidate repeats an asset"));
4494            }
4495        }
4496        match page.next_cursor {
4497            None => break,
4498            Some(next) if next > after => after = next,
4499            Some(_) => {
4500                return Err(invalid_feed(
4501                    "self-custody asset candidate cursor did not advance",
4502                ))
4503            }
4504        }
4505    }
4506    if assets.len() != expected_assets.len()
4507        || assets.iter().any(|(path, asset)| {
4508            expected_assets.get(path).is_none_or(|expected| {
4509                asset.blob_sha256 != expected.blob_sha256
4510                    || asset.bytes != expected.bytes
4511                    || asset.media_type != expected.media_type
4512                    || asset.wrappers != expected.wrappers
4513                    || asset.required != expected.required
4514                    || asset.disposition != expected.disposition
4515            })
4516        })
4517    {
4518        return Err(invalid_feed(
4519            "self-custody candidate contains an unexpected asset mutation",
4520        ));
4521    }
4522    let Some((
4523        request_hash,
4524        signing_b64,
4525        changes_b64,
4526        actor_b64,
4527        root,
4528        asset_root,
4529        parent_seq,
4530        parent,
4531    )) = pinned
4532    else {
4533        return Err(invalid_feed("self-custody candidate has no manifest"));
4534    };
4535    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4536    let current_commit = head
4537        .pointer
4538        .as_ref()
4539        .map(|pointer| pointer.commit_hash.clone());
4540    if parent_seq != current_seq || parent != current_commit {
4541        return Err(LinkError::RemoteAdvancedDuringSync);
4542    }
4543    let changes = STANDARD
4544        .decode(changes_b64)
4545        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4546    let mut expected_changes = json!({
4547        "mutation_id": mutation_id,
4548        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4549        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4550        "v": 2,
4551    });
4552    if let Some(withheld_links) = request_body.get("withheld_links") {
4553        expected_changes["withheld_links"] = withheld_links.clone();
4554    }
4555    if let Some(checkout_id) = request_body.get("checkout_id") {
4556        expected_changes["checkout_id"] = checkout_id.clone();
4557    }
4558    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4559        .map_err(|error| invalid_feed(error.to_string()))?;
4560    if changes != expected_changes_bytes {
4561        return Err(invalid_feed(
4562            "self-custody changeset differs from the requested mutation",
4563        ));
4564    }
4565    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4566        .map_err(|error| invalid_feed(error.to_string()))?;
4567    let request_value = json!({
4568        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4569        "brain": head.brain_id,
4570        "changes_sha256": changes_hash,
4571        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4572        "v": 2,
4573        "v1_bridge": Value::Null,
4574    });
4575    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4576        .map_err(|error| invalid_feed(error.to_string()))?;
4577    if request_hash != expected_request_hash {
4578        return Err(invalid_feed(
4579            "self-custody request hash differs from the requested mutation",
4580        ));
4581    }
4582    let actor = STANDARD
4583        .decode(actor_b64)
4584        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4585    let actor_value: Value = serde_json::from_slice(&actor)
4586        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4587    if crate::linkmd_v2::canonical_bytes(&actor_value)
4588        .map_err(|error| invalid_feed(error.to_string()))?
4589        != actor
4590    {
4591        return Err(invalid_feed("self-custody actor claim is not canonical"));
4592    }
4593    let actor_object = actor_value
4594        .as_object()
4595        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4596    let actor_claim = actor_object
4597        .get("claim")
4598        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4599    let actor_public_key = actor_object
4600        .get("public_key")
4601        .and_then(Value::as_str)
4602        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4603    let actor_fingerprint = actor_object
4604        .get("fingerprint")
4605        .and_then(Value::as_str)
4606        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4607    let actor_signature = actor_object
4608        .get("sig")
4609        .and_then(Value::as_str)
4610        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4611    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4612        .map_err(|error| invalid_feed(error.to_string()))?;
4613    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4614    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4615    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4616    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4617    let impact = actor_claim
4618        .get("result")
4619        .and_then(|result| result.get("impact"))
4620        .and_then(Value::as_object);
4621    let impact_fields = [
4622        "creates",
4623        "updates",
4624        "deletes",
4625        "withdrawals",
4626        "renames",
4627        "restores",
4628        "asset_changes",
4629        "public_expansions",
4630        "executable_activations",
4631    ];
4632    let impact_is_valid = impact.is_some_and(|impact| {
4633        impact.len() == impact_fields.len() + 1
4634            && impact.get("v").and_then(Value::as_u64) == Some(1)
4635            && impact_fields
4636                .iter()
4637                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4638    });
4639    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4640        || head
4641            .trust
4642            .hub_signer
4643            .as_ref()
4644            .is_some_and(|known| known != &expected_actor_signer)
4645        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4646        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4647        || actor_claim
4648            .get("candidate")
4649            .and_then(|candidate| candidate.get("changes_sha256"))
4650            .and_then(Value::as_str)
4651            != Some(changes_hash.as_str())
4652        || actor_claim
4653            .get("candidate")
4654            .and_then(|candidate| candidate.get("state_root"))
4655            != Some(&expected_actor_root)
4656        || actor_claim
4657            .get("candidate")
4658            .and_then(|candidate| candidate.get("asset_root"))
4659            != Some(&expected_actor_asset_root)
4660        || actor_claim
4661            .get("candidate")
4662            .and_then(|candidate| candidate.get("control_revision"))
4663            .and_then(Value::as_str)
4664            != Some(head.control_revision.as_str())
4665        || !impact_is_valid
4666    {
4667        return Err(invalid_feed(
4668            "self-custody actor claim does not bind the verified authority",
4669        ));
4670    }
4671    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4672        .map_err(|error| invalid_feed(error.to_string()))?;
4673    let signing = STANDARD
4674        .decode(signing_b64)
4675        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4676    let signing_value: Value = serde_json::from_slice(&signing)
4677        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4678    if crate::linkmd_v2::canonical_bytes(&signing_value)
4679        .map_err(|error| invalid_feed(error.to_string()))?
4680        != signing
4681    {
4682        return Err(invalid_feed("self-custody signing bytes are not canonical"));
4683    }
4684    let pointer = head.pointer.as_ref();
4685    let expected_materializer = pointer
4686        .map(|value| value.materializer.as_str())
4687        .unwrap_or("dbmd-projection-v1");
4688    let expected_parent_commit = request_body
4689        .get("base")
4690        .and_then(|base| base.get("commit_hash"))
4691        .cloned()
4692        .unwrap_or(Value::Null);
4693    let expected_parent_root = request_body
4694        .get("base")
4695        .and_then(|base| base.get("content_root"))
4696        .cloned()
4697        .unwrap_or(Value::Null);
4698    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4699    let expected_parent_asset_root = request_body
4700        .get("base")
4701        .and_then(|base| base.get("asset_root"))
4702        .cloned()
4703        .unwrap_or(Value::Null);
4704    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4705    let expected_prev_entry = pointer
4706        .map(|value| Value::String(value.feed_hash.clone()))
4707        .unwrap_or(Value::Null);
4708    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4709        .map_err(|_| invalid_feed("brain identity history is too large"))?
4710        + 1;
4711    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4712        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4713        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4714        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4715        || signing_value.get("public_key").and_then(Value::as_str)
4716            != Some(key.public_key_spki.as_str())
4717        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4718        || signing_value.get("parent_root") != Some(&expected_parent_root)
4719        || signing_value.get("state_root") != Some(&expected_state_root)
4720        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4721        || signing_value.get("asset_root") != Some(&expected_asset_root)
4722        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4723        || signing_value.get("changes_sha256").and_then(Value::as_str)
4724            != Some(changes_hash.as_str())
4725        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4726        || signing_value
4727            .get("control_revision")
4728            .and_then(Value::as_str)
4729            != Some(head.control_revision.as_str())
4730        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4731        || signing_value.get("v1_bridge") != Some(&Value::Null)
4732        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4733    {
4734        return Err(invalid_feed(
4735            "self-custody signing bytes do not bind the verified candidate",
4736        ));
4737    }
4738    let pair = agent_keypair(&key.pkcs8)?;
4739    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4740    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4741}
4742
4743fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4744    let origin = normalized_origin(&cfg.hub)?;
4745    let absolute = if checkout.is_absolute() {
4746        checkout.to_path_buf()
4747    } else {
4748        std::env::current_dir()?.join(checkout)
4749    };
4750    Ok(format!(
4751        "sync-{}.json",
4752        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4753    ))
4754}
4755
4756fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4757    if let Some(value) = existing {
4758        if !is_sha256(value) {
4759            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4760        }
4761        return Ok(value.to_string());
4762    }
4763    use ring::rand::SecureRandom as _;
4764    let mut random = [0_u8; 32];
4765    ring::rand::SystemRandom::new()
4766        .fill(&mut random)
4767        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4768    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4769}
4770
4771#[cfg(any(unix, windows))]
4772fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4773    let directory = open_trust_dir(cfg)?;
4774    let origin = normalized_origin(&cfg.hub)?;
4775    let name = format!(
4776        "operation-{}.lock",
4777        content_sha256(format!("{origin}\0{brain}").as_bytes())
4778    );
4779    lock_trust_name(&directory, &name)
4780}
4781
4782#[cfg(not(any(unix, windows)))]
4783fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4784    Err(LinkError::UnsupportedPlatform {
4785        operation: "serialized link.md v2 sync",
4786    })
4787}
4788
4789fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4790    left.brain_id == right.brain_id
4791        && left.view_kind == right.view_kind
4792        && left.view_revision == right.view_revision
4793        && left.control_revision == right.control_revision
4794        && match (&left.pointer, &right.pointer) {
4795            (None, None) => true,
4796            (Some(left), Some(right)) => {
4797                left.seq == right.seq
4798                    && left.commit_hash == right.commit_hash
4799                    && left.content_root == right.content_root
4800                    && left.asset_root == right.asset_root
4801                    && left.feed_hash == right.feed_hash
4802            }
4803            _ => false,
4804        }
4805}
4806
4807fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4808    format!(
4809        "---\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"
4810    )
4811    .into_bytes()
4812}
4813
4814fn scoped_projection_sha256(brain: &str) -> String {
4815    content_sha256(&scoped_projection_bytes(brain))
4816}
4817
4818#[derive(Deserialize)]
4819struct LocalScopedViewMarker {
4820    v: u8,
4821    kind: String,
4822    authoritative: bool,
4823    brain: String,
4824    projection_sha256: String,
4825}
4826
4827/// True only when this store carries the exact generated marker for a local
4828/// link.md scoped view. This is presentation context, never authorization;
4829/// the hub remains the only authority for reads and writes.
4830pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4831    let marker = store
4832        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4833        .ok()
4834        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4835    let Some(marker) = marker else {
4836        return false;
4837    };
4838    if marker.v != 1
4839        || marker.kind != "link.md-scoped-view"
4840        || marker.authoritative
4841        || !crate::ulid::is_ulid(&marker.brain)
4842        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4843    {
4844        return false;
4845    }
4846    store
4847        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4848        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4849}
4850
4851fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4852    let mut bytes = serde_json::to_vec_pretty(&json!({
4853        "v": 1,
4854        "kind": "link.md-scoped-view",
4855        "authoritative": false,
4856        "brain": head.brain_id,
4857        "view_revision": head.view_revision,
4858        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4859        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4860        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4861        "visible_files": files,
4862        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4863    }))
4864    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4865    bytes.push(b'\n');
4866    Ok(bytes)
4867}
4868
4869fn refresh_scoped_view_marker(
4870    store: &Store,
4871    head: &V2VerifiedHead,
4872    files: usize,
4873) -> LinkResult<()> {
4874    if head.view_kind == "scoped" {
4875        store.write_atomic(
4876            Path::new(".dbmd/view.json"),
4877            &scoped_view_metadata(head, files)?,
4878        )?;
4879    }
4880    Ok(())
4881}
4882
4883fn ensure_v2_view_compatible(
4884    head: &V2VerifiedHead,
4885    baseline: Option<&V2SyncBaseline>,
4886) -> LinkResult<()> {
4887    let Some(baseline) = baseline else {
4888        return Ok(());
4889    };
4890    match (
4891        baseline.view_kind.as_deref(),
4892        baseline.view_revision.as_deref(),
4893    ) {
4894        (None, None) if head.view_kind == "full" => Ok(()),
4895        (Some(kind), Some(revision))
4896            if kind == head.view_kind && revision == head.view_revision =>
4897        {
4898            Ok(())
4899        }
4900        _ => Err(LinkError::ScopedViewChanged),
4901    }
4902}
4903
4904fn ensure_established_v2_checkout_opened(
4905    head: &V2VerifiedHead,
4906    baseline: Option<&V2SyncBaseline>,
4907    opened: bool,
4908) -> LinkResult<()> {
4909    if baseline.is_none() || opened {
4910        return Ok(());
4911    }
4912    if head.view_kind == "scoped" {
4913        return Err(LinkError::ScopedProjectionModified);
4914    }
4915    Err(LinkError::InvalidPack {
4916        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4917    })
4918}
4919
4920fn remove_scoped_projection(
4921    head: &V2VerifiedHead,
4922    baseline: Option<&V2SyncBaseline>,
4923    view: &mut V2LocalView,
4924) -> LinkResult<()> {
4925    if head.view_kind != "scoped" {
4926        return Ok(());
4927    }
4928    let expected = scoped_projection_sha256(&head.brain_id);
4929    if baseline
4930        .and_then(|state| state.projection_sha256.as_deref())
4931        .is_some_and(|pinned| pinned != expected)
4932    {
4933        return Err(LinkError::ScopedViewChanged);
4934    }
4935    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4936        return Err(LinkError::ScopedProjectionModified);
4937    }
4938    view.riding.remove("DB.md");
4939    view.eligibility.remove("DB.md");
4940    Ok(())
4941}
4942
4943fn local_view_for_v2_push(
4944    store: &Store,
4945    head: &V2VerifiedHead,
4946    baseline: Option<&V2SyncBaseline>,
4947    carried: Option<V2LocalView>,
4948) -> LinkResult<V2LocalView> {
4949    match carried {
4950        // The pull half already verified and removed the generated scoped
4951        // projection before constructing its in-memory handoff. Requiring it
4952        // a second time would turn every scoped bidirectional write into a
4953        // false tamper refusal.
4954        Some(view) => Ok(view),
4955        None => {
4956            let mut view = v2_local_files(store)?;
4957            remove_scoped_projection(head, baseline, &mut view)?;
4958            Ok(view)
4959        }
4960    }
4961}
4962
4963fn files_for_v2_view(
4964    head: &V2VerifiedHead,
4965    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4966) -> std::collections::BTreeMap<String, V2BaselineFile> {
4967    if head.view_kind == "scoped" {
4968        // Even when a grant explicitly includes canonical DB.md, a partial
4969        // checkout uses a generated marker. Canonical validation remains a
4970        // server-side operation over the complete brain.
4971        files.remove("DB.md");
4972    }
4973    files
4974}
4975
4976fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4977    let baseline: V2SyncBaseline =
4978        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4979    if baseline.v != 2
4980        || baseline.origin != normalized_origin(&cfg.hub)?
4981        || baseline.brain != brain
4982        || baseline
4983            .commit_hash
4984            .as_deref()
4985            .is_some_and(|hash| !is_sha256(hash))
4986        || baseline
4987            .content_root
4988            .as_deref()
4989            .is_some_and(|hash| !is_sha256(hash))
4990        || baseline
4991            .asset_root
4992            .as_deref()
4993            .is_some_and(|hash| !is_sha256(hash))
4994        || baseline
4995            .local_policy_digest
4996            .as_deref()
4997            .is_some_and(|hash| !is_sha256(hash))
4998        || baseline
4999            .view_kind
5000            .as_deref()
5001            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5002        || baseline
5003            .view_revision
5004            .as_deref()
5005            .is_some_and(|hash| !is_sha256(hash))
5006        || baseline
5007            .projection_sha256
5008            .as_deref()
5009            .is_some_and(|hash| !is_sha256(hash))
5010        || (baseline.view_kind.as_deref() == Some("scoped")
5011            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5012        || baseline.files.len() > MAX_PUSH_FILES
5013        || baseline.assets.len() > MAX_PUSH_FILES
5014        || baseline.local_eligibility.len() > MAX_PUSH_FILES
5015        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5016        || baseline.files.iter().any(|(path, file)| {
5017            crate::linkmd_v2::normalize_path(path).is_err()
5018                || !is_sha256(&file.sha256)
5019                || file.bytes > MAX_STORE_BYTES
5020        })
5021        || baseline.assets.iter().any(|(path, asset)| {
5022            crate::linkmd_v2::normalize_path(path).is_err()
5023                || !is_sha256(&asset.blob_sha256)
5024                || !is_sha256(&asset.leaf_hash)
5025                || asset.bytes > MAX_ASSET_BYTES
5026                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5027                || asset.wrappers.is_empty()
5028                || asset
5029                    .wrappers
5030                    .iter()
5031                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5032        })
5033        || baseline
5034            .local_eligibility
5035            .keys()
5036            .chain(baseline.remote_copy_remains.keys())
5037            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5038        || baseline
5039            .remote_copy_remains
5040            .values()
5041            .any(|hash| !is_sha256(hash))
5042        || baseline
5043            .checkout_id
5044            .as_deref()
5045            .is_some_and(|checkout_id| !is_sha256(checkout_id))
5046    {
5047        return Err(invalid_feed("v2 sync baseline failed validation"));
5048    }
5049    Ok(baseline)
5050}
5051
5052#[cfg(unix)]
5053fn load_v2_baseline(
5054    cfg: &HubConfig,
5055    brain: &str,
5056    checkout: &Path,
5057) -> LinkResult<Option<V2SyncBaseline>> {
5058    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5059    let directory = open_trust_dir(cfg)?;
5060    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5061    let _lock = lock_trust_name(&directory, &name_string)?;
5062    let name = c_name(name_string.as_bytes(), &name_string)?;
5063    let fd = unsafe {
5064        libc::openat(
5065            directory.as_raw_fd(),
5066            name.as_ptr(),
5067            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5068        )
5069    };
5070    if fd < 0 {
5071        let error = std::io::Error::last_os_error();
5072        return if error.kind() == std::io::ErrorKind::NotFound {
5073            Ok(None)
5074        } else {
5075            Err(LinkError::UnsafePath { path: name_string })
5076        };
5077    }
5078    let file = unsafe { std::fs::File::from_raw_fd(fd) };
5079    let mut bytes = Vec::new();
5080    file.take(MAX_FEED_RESPONSE_BYTES + 1)
5081        .read_to_end(&mut bytes)?;
5082    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
5083        return Err(invalid_feed("v2 sync baseline is oversized"));
5084    }
5085    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5086}
5087
5088#[cfg(windows)]
5089fn load_v2_baseline(
5090    cfg: &HubConfig,
5091    brain: &str,
5092    checkout: &Path,
5093) -> LinkResult<Option<V2SyncBaseline>> {
5094    let directory = open_trust_dir(cfg)?;
5095    let name = v2_baseline_name(cfg, brain, checkout)?;
5096    let _lock = lock_trust_name(&directory, &name)?;
5097    let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5098    match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5099        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5100        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5101        Err(_) => Err(LinkError::UnsafePath { path: name }),
5102    }
5103}
5104
5105#[cfg(not(any(unix, windows)))]
5106fn load_v2_baseline(
5107    _cfg: &HubConfig,
5108    _brain: &str,
5109    _checkout: &Path,
5110) -> LinkResult<Option<V2SyncBaseline>> {
5111    Err(LinkError::UnsupportedPlatform {
5112        operation: "verified link.md v2 baseline",
5113    })
5114}
5115
5116#[cfg(unix)]
5117fn save_v2_baseline(
5118    cfg: &HubConfig,
5119    brain: &str,
5120    checkout: &Path,
5121    baseline: &V2SyncBaseline,
5122) -> LinkResult<()> {
5123    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5124    let directory = open_trust_dir(cfg)?;
5125    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5126    let _lock = lock_trust_name(&directory, &name_string)?;
5127    let name = c_name(name_string.as_bytes(), &name_string)?;
5128    let mut bytes = serde_json::to_vec(baseline)
5129        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5130    bytes.push(b'\n');
5131    let temp_string = format!(
5132        ".{name_string}.tmp.{}-{}",
5133        std::process::id(),
5134        std::time::SystemTime::now()
5135            .duration_since(std::time::UNIX_EPOCH)
5136            .unwrap_or_default()
5137            .as_nanos()
5138    );
5139    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5140    let fd = unsafe {
5141        libc::openat(
5142            directory.as_raw_fd(),
5143            temp.as_ptr(),
5144            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5145            0o600,
5146        )
5147    };
5148    if fd < 0 {
5149        return Err(std::io::Error::last_os_error().into());
5150    }
5151    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5152    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5153        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5154        return Err(error.into());
5155    }
5156    drop(file);
5157    if unsafe {
5158        libc::renameat(
5159            directory.as_raw_fd(),
5160            temp.as_ptr(),
5161            directory.as_raw_fd(),
5162            name.as_ptr(),
5163        )
5164    } != 0
5165    {
5166        let error = std::io::Error::last_os_error();
5167        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5168        return Err(error.into());
5169    }
5170    directory.sync_all()?;
5171    Ok(())
5172}
5173
5174#[cfg(windows)]
5175fn save_v2_baseline(
5176    cfg: &HubConfig,
5177    brain: &str,
5178    checkout: &Path,
5179    baseline: &V2SyncBaseline,
5180) -> LinkResult<()> {
5181    let directory = open_trust_dir(cfg)?;
5182    let name = v2_baseline_name(cfg, brain, checkout)?;
5183    let _lock = lock_trust_name(&directory, &name)?;
5184    let mut bytes = serde_json::to_vec(baseline)
5185        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5186    bytes.push(b'\n');
5187    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5188    Ok(())
5189}
5190
5191#[cfg(not(any(unix, windows)))]
5192fn save_v2_baseline(
5193    _cfg: &HubConfig,
5194    _brain: &str,
5195    _checkout: &Path,
5196    _baseline: &V2SyncBaseline,
5197) -> LinkResult<()> {
5198    Err(LinkError::UnsupportedPlatform {
5199        operation: "verified link.md v2 baseline",
5200    })
5201}
5202
5203fn v2_baseline_from_head(
5204    cfg: &HubConfig,
5205    head: &V2VerifiedHead,
5206    files: std::collections::BTreeMap<String, V2BaselineFile>,
5207    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5208    local: Option<&V2LocalView>,
5209    checkout_id: Option<&str>,
5210) -> LinkResult<V2SyncBaseline> {
5211    let mut local_eligibility = local
5212        .map(|view| view.eligibility.clone())
5213        .unwrap_or_default();
5214    if let Some(view) = local {
5215        for path in files.keys() {
5216            local_eligibility
5217                .entry(path.clone())
5218                .or_insert_with(|| !view.policy.keeps_home(path));
5219        }
5220    }
5221    let remote_copy_remains = local_eligibility
5222        .iter()
5223        .filter(|(_, riding)| !**riding)
5224        .filter_map(|(path, _)| {
5225            files
5226                .get(path)
5227                .map(|file| (path.clone(), file.sha256.clone()))
5228        })
5229        .collect();
5230    Ok(V2SyncBaseline {
5231        v: 2,
5232        origin: normalized_origin(&cfg.hub)?,
5233        brain: head.brain_id.clone(),
5234        checkout_id: Some(v2_checkout_id(checkout_id)?),
5235        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5236        commit_hash: head
5237            .pointer
5238            .as_ref()
5239            .map(|pointer| pointer.commit_hash.clone()),
5240        content_root: head
5241            .pointer
5242            .as_ref()
5243            .and_then(|pointer| pointer.content_root.clone()),
5244        asset_root: head
5245            .pointer
5246            .as_ref()
5247            .and_then(|pointer| pointer.asset_root.clone()),
5248        assets,
5249        view_kind: Some(head.view_kind.clone()),
5250        view_revision: Some(head.view_revision.clone()),
5251        projection_sha256: (head.view_kind == "scoped")
5252            .then(|| scoped_projection_sha256(&head.brain_id)),
5253        files,
5254        local_policy_digest: local.map(|view| view.policy.digest.clone()),
5255        local_eligibility,
5256        remote_copy_remains,
5257    })
5258}
5259
5260fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5261    let policy = crate::linkmd_sync_policy::load(store)
5262        .map_err(|message| LinkError::InvalidPack { message })?;
5263    let asset_paths = crate::assets::read_manifest(store)
5264        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5265        .into_iter()
5266        .map(|asset| asset.path)
5267        .collect::<std::collections::BTreeSet<_>>();
5268    let mut result = std::collections::BTreeMap::new();
5269    let mut eligibility = std::collections::BTreeMap::new();
5270    let mut riding_links = Vec::<(String, Vec<String>)>::new();
5271    let mut total = 0_u64;
5272    let mut paths = vec![PathBuf::from("DB.md")];
5273    paths.extend(store.walk()?);
5274    for relative in paths {
5275        let path = relative.to_string_lossy().replace('\\', "/");
5276        // v2 catalogs and asset inventory are materialized/signed separately.
5277        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5278            continue;
5279        }
5280        if asset_paths.contains(&path) {
5281            continue;
5282        }
5283        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5284            path: error.to_string(),
5285        })?;
5286        let riding = !policy.keeps_home(&path);
5287        eligibility.insert(path.clone(), riding);
5288        if !riding {
5289            continue;
5290        }
5291        let remaining = MAX_STORE_BYTES.saturating_sub(total);
5292        let bytes = store.read_bounded(&relative, remaining)?;
5293        total = total
5294            .checked_add(bytes.len() as u64)
5295            .ok_or_else(|| LinkError::PushTooLarge {
5296                detail: "v2 local byte count overflow".to_string(),
5297            })?;
5298        if total > MAX_STORE_BYTES {
5299            return Err(LinkError::PushTooLarge {
5300                detail: format!("{total} uncompressed bytes"),
5301            });
5302        }
5303        if std::str::from_utf8(&bytes).is_err() {
5304            return Err(LinkError::NotUtf8 { path });
5305        }
5306        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5307        riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5308        result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5309    }
5310    let kept_home = eligibility
5311        .iter()
5312        .filter(|(_, riding)| !**riding)
5313        .map(|(path, _)| path.clone())
5314        .collect::<std::collections::BTreeSet<_>>();
5315    let mut withheld_links = riding_links
5316        .into_iter()
5317        .flat_map(|(source, targets)| {
5318            let kept_home = &kept_home;
5319            let policy = &policy;
5320            targets.into_iter().filter_map(move |target| {
5321                let target = format!("{target}.md");
5322                // A kept-home target need not be a file on THIS machine. An
5323                // export omits kept-home content by construction, so a store
5324                // restored from one links to names it does not carry — and
5325                // without this the owner's own export could never be pushed
5326                // back, which is the opposite of owning your data. The policy
5327                // is the owner's statement about where a path lives, and it
5328                // stands whether or not this checkout holds a copy. It
5329                // discloses nothing either way: the name is already written in
5330                // the content that links to it.
5331                (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5332                    V2WithheldLink {
5333                        source: source.clone(),
5334                        target,
5335                    },
5336                )
5337            })
5338        })
5339        .collect::<Vec<_>>();
5340    withheld_links.sort();
5341    withheld_links.dedup();
5342    Ok(V2LocalView {
5343        riding: result,
5344        eligibility,
5345        policy,
5346        withheld_links,
5347    })
5348}
5349
5350#[derive(Debug, Clone, Deserialize)]
5351struct V2DownloadItem {
5352    path: String,
5353    sha256: String,
5354    bytes: u64,
5355    url: String,
5356    method: String,
5357}
5358
5359#[derive(Debug, Deserialize)]
5360struct V2DownloadWindow {
5361    v: u8,
5362    commit: String,
5363    downloads: Vec<V2DownloadItem>,
5364}
5365
5366#[derive(Debug, Deserialize)]
5367struct V2BulkStreamHeader {
5368    v: u8,
5369    path: String,
5370    sha256: String,
5371    bytes: u64,
5372}
5373
5374fn parse_v2_bulk_stream(
5375    bytes: &[u8],
5376    expected: &[(&String, &V2BaselineFile)],
5377) -> LinkResult<Vec<(String, Vec<u8>)>> {
5378    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5379        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5380    }
5381    let mut cursor = V2_BULK_STREAM_MAGIC.len();
5382    let mut result = Vec::with_capacity(expected.len());
5383    for (expected_path, expected_file) in expected {
5384        let length_bytes = bytes
5385            .get(cursor..cursor + 4)
5386            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5387        cursor += 4;
5388        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5389        if header_len == 0 || header_len > 4 * 1024 {
5390            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5391        }
5392        let header_bytes = bytes
5393            .get(cursor..cursor + header_len)
5394            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5395        cursor += header_len;
5396        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5397            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5398        if header.v != 2
5399            || &header.path != *expected_path
5400            || header.sha256 != expected_file.sha256
5401            || header.bytes != expected_file.bytes
5402            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5403        {
5404            return Err(invalid_feed(
5405                "v2 bulk stream frame differs from its proven manifest entry",
5406            ));
5407        }
5408        let body_len = usize::try_from(header.bytes)
5409            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5410        let body = bytes
5411            .get(cursor..cursor + body_len)
5412            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5413        cursor += body_len;
5414        if content_sha256(body) != header.sha256 {
5415            return Err(invalid_feed(
5416                "v2 bulk stream file differs from its proven manifest entry",
5417            ));
5418        }
5419        result.push((header.path, body.to_vec()));
5420    }
5421    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5422        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5423    }
5424    cursor += 4;
5425    if cursor != bytes.len() {
5426        return Err(invalid_feed("v2 bulk stream carries trailing data"));
5427    }
5428    Ok(result)
5429}
5430
5431fn download_v2_bulk_stream(
5432    cfg: &HubConfig,
5433    brain: &str,
5434    pointer: &V2PointerBody,
5435    pending: &[(&String, &V2BaselineFile)],
5436) -> LinkResult<Vec<(String, Vec<u8>)>> {
5437    let claims = pending
5438        .iter()
5439        .map(|(path, file)| {
5440            Ok(json!({
5441                "path": path,
5442                "sha256": file.sha256,
5443                "bytes": file.bytes,
5444                "proof": file.proof.as_ref().ok_or_else(|| {
5445                    invalid_feed("v2 manifest omitted a bulk-stream proof")
5446                })?,
5447            }))
5448        })
5449        .collect::<LinkResult<Vec<_>>>()?;
5450    let raw = request_raw_retryable_read(
5451        cfg,
5452        "POST",
5453        &format!("/api/hub/brains/{brain}/v2/stream"),
5454        Some(&json!({
5455            "commit": pointer.commit_hash,
5456            "files": claims,
5457        })),
5458        Auth::Required,
5459        V2_BULK_STREAM_RESPONSE_BYTES,
5460    )?;
5461    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5462    parse_v2_bulk_stream(&body, pending)
5463}
5464
5465fn request_capped_retryable_read(
5466    cfg: &HubConfig,
5467    method: &str,
5468    path: &str,
5469    body: Option<&Value>,
5470    auth: Auth,
5471    max_response_bytes: u64,
5472) -> LinkResult<HubResponse> {
5473    let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5474    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5475    Ok(HubResponse {
5476        status: raw.status,
5477        body: parsed,
5478    })
5479}
5480
5481fn prepare_v2_downloads(
5482    cfg: &HubConfig,
5483    brain: &str,
5484    pointer: &V2PointerBody,
5485    pending: &[(&String, &V2BaselineFile)],
5486) -> LinkResult<Vec<V2DownloadItem>> {
5487    let mut result = Vec::with_capacity(pending.len());
5488    for chunk in pending.chunks(128) {
5489        let claims = chunk
5490            .iter()
5491            .map(|(path, file)| {
5492                Ok(json!({
5493                    "path": path,
5494                    "sha256": file.sha256,
5495                    "bytes": file.bytes,
5496                    "proof": file.proof.as_ref().ok_or_else(|| {
5497                        invalid_feed("v2 manifest omitted a download proof")
5498                    })?,
5499                }))
5500            })
5501            .collect::<LinkResult<Vec<_>>>()?;
5502        let value = ensure_ok(
5503            request_capped_retryable_read(
5504                cfg,
5505                "POST",
5506                &format!("/api/hub/brains/{brain}/v2/downloads"),
5507                Some(&json!({
5508                    "commit": pointer.commit_hash,
5509                    "files": claims,
5510                })),
5511                Auth::Required,
5512                MAX_FEED_RESPONSE_BYTES,
5513            )?,
5514            "prepare v2 blob downloads",
5515        )?;
5516        let window: V2DownloadWindow = serde_json::from_value(value)
5517            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5518        if window.v != 2
5519            || window.commit != pointer.commit_hash
5520            || window.downloads.len() != chunk.len()
5521        {
5522            return Err(invalid_feed(
5523                "v2 download window is not bound to the requested files",
5524            ));
5525        }
5526        let mut by_path = window
5527            .downloads
5528            .into_iter()
5529            .map(|item| (item.path.clone(), item))
5530            .collect::<std::collections::BTreeMap<_, _>>();
5531        if by_path.len() != chunk.len() {
5532            return Err(invalid_feed("v2 download window repeats a path"));
5533        }
5534        for (path, file) in chunk {
5535            let item = by_path
5536                .remove(*path)
5537                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5538            if item.method != "GET"
5539                || item.sha256 != file.sha256
5540                || item.bytes != file.bytes
5541                || item.url.is_empty()
5542            {
5543                return Err(invalid_feed(
5544                    "v2 download capability differs from its proven file",
5545                ));
5546            }
5547            result.push(item);
5548        }
5549    }
5550    Ok(result)
5551}
5552
5553fn prepare_v2_asset_downloads(
5554    cfg: &HubConfig,
5555    brain: &str,
5556    pointer: &V2PointerBody,
5557    pending: &[(&String, &V2BaselineAsset)],
5558) -> LinkResult<Vec<V2DownloadItem>> {
5559    let mut result = Vec::with_capacity(pending.len());
5560    for chunk in pending.chunks(128) {
5561        let claims = chunk
5562            .iter()
5563            .map(|(path, asset)| {
5564                json!({
5565                    "path": path,
5566                    "sha256": asset.blob_sha256,
5567                    "bytes": asset.bytes,
5568                    "leaf_hash": asset.leaf_hash,
5569                })
5570            })
5571            .collect::<Vec<_>>();
5572        let value = ensure_ok(
5573            request_capped_retryable_read(
5574                cfg,
5575                "POST",
5576                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5577                Some(&json!({
5578                    "commit": pointer.commit_hash,
5579                    "assets": claims,
5580                })),
5581                Auth::Required,
5582                MAX_FEED_RESPONSE_BYTES,
5583            )?,
5584            "prepare v2 asset downloads",
5585        )?;
5586        let window: V2DownloadWindow = serde_json::from_value(value)
5587            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5588        if window.v != 2
5589            || window.commit != pointer.commit_hash
5590            || window.downloads.len() != chunk.len()
5591        {
5592            return Err(invalid_feed(
5593                "v2 asset download window is not bound to the requested assets",
5594            ));
5595        }
5596        let mut by_path = window
5597            .downloads
5598            .into_iter()
5599            .map(|item| (item.path.clone(), item))
5600            .collect::<std::collections::BTreeMap<_, _>>();
5601        if by_path.len() != chunk.len() {
5602            return Err(invalid_feed("v2 asset download window repeats a path"));
5603        }
5604        for (path, asset) in chunk {
5605            let item = by_path
5606                .remove(*path)
5607                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5608            if item.method != "GET"
5609                || item.sha256 != asset.blob_sha256
5610                || item.bytes != asset.bytes
5611                || item.url.is_empty()
5612            {
5613                return Err(invalid_feed(
5614                    "v2 asset download capability differs from its signed leaf",
5615                ));
5616            }
5617            result.push(item);
5618        }
5619    }
5620    Ok(result)
5621}
5622
5623#[cfg(any(unix, windows))]
5624fn stage_v2_asset_download_window(
5625    cfg: &HubConfig,
5626    brain: &str,
5627    pointer: &V2PointerBody,
5628    cache_dir: &Path,
5629    pending: &[(&String, &V2BaselineAsset)],
5630) -> LinkResult<Vec<V2StagedFile>> {
5631    if pending.is_empty() {
5632        return Ok(Vec::new());
5633    }
5634    if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5635        return Err(invalid_feed("v2 asset capability window is oversized"));
5636    }
5637
5638    let mut last_error = None;
5639    for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5640        .iter()
5641        .copied()
5642        .map(Some)
5643        .chain(std::iter::once(None))
5644    {
5645        // This POST rechecks the exact head and current path authority before
5646        // issuing fresh, immutable GET capabilities. Successful prior workers
5647        // are already durable in the content-addressed cache, so retrying the
5648        // complete small window neither redownloads them nor widens access.
5649        let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5650        let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5651        for item in downloads {
5652            match unique.get(&item.sha256) {
5653                Some(prior) if prior.bytes != item.bytes => {
5654                    return Err(invalid_feed(
5655                        "one v2 asset hash has conflicting byte lengths",
5656                    ));
5657                }
5658                Some(_) => {}
5659                None => {
5660                    unique.insert(item.sha256.clone(), item);
5661                }
5662            }
5663        }
5664        let downloads = unique.into_values().collect::<Vec<_>>();
5665        let next = std::sync::atomic::AtomicUsize::new(0);
5666        let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5667        let mut results = std::iter::repeat_with(|| None)
5668            .take(downloads.len())
5669            .collect::<Vec<Option<LinkResult<PathBuf>>>>();
5670        std::thread::scope(|scope| {
5671            let (sender, receiver) = std::sync::mpsc::channel();
5672            for _ in 0..worker_count {
5673                let sender = sender.clone();
5674                let downloads = &downloads;
5675                let next = &next;
5676                scope.spawn(move || loop {
5677                    let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5678                    let Some(item) = downloads.get(index) else {
5679                        break;
5680                    };
5681                    let result = download_presigned_to_cache(
5682                        cfg,
5683                        &item.url,
5684                        cache_dir,
5685                        &item.sha256,
5686                        item.bytes,
5687                    );
5688                    if sender.send((index, result)).is_err() {
5689                        break;
5690                    }
5691                });
5692            }
5693            drop(sender);
5694            for (index, result) in receiver {
5695                results[index] = Some(result);
5696            }
5697        });
5698
5699        let mut failed = None;
5700        for result in results {
5701            match result {
5702                Some(Ok(_)) => {}
5703                Some(Err(error)) if failed.is_none() => failed = Some(error),
5704                Some(Err(_)) => {}
5705                None if failed.is_none() => {
5706                    failed = Some(LinkError::Transport {
5707                        hub: cfg.hub.clone(),
5708                        message: "a bounded v2 asset worker stopped before reporting its result"
5709                            .to_string(),
5710                    });
5711                }
5712                None => {}
5713            }
5714        }
5715        if let Some(error) = failed {
5716            last_error = Some(error);
5717            if let Some(milliseconds) = retry_delay {
5718                std::thread::sleep(std::time::Duration::from_millis(milliseconds));
5719                continue;
5720            }
5721            break;
5722        }
5723
5724        return pending
5725            .iter()
5726            .map(|(path, asset)| {
5727                let source = cache_dir.join(&asset.blob_sha256);
5728                if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
5729                    return Err(invalid_feed(
5730                        "v2 asset download cache omitted a proven blob",
5731                    ));
5732                }
5733                Ok(V2StagedFile {
5734                    path: (*path).clone(),
5735                    source,
5736                    sha256: asset.blob_sha256.clone(),
5737                    bytes: asset.bytes,
5738                })
5739            })
5740            .collect();
5741    }
5742    Err(last_error
5743        .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
5744}
5745
5746fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5747    let bytes = get_presigned(cfg, &item.url)?;
5748    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5749        return Err(invalid_feed("v2 blob differs from its proven path entry"));
5750    }
5751    Ok(bytes)
5752}
5753
5754#[derive(Debug, Clone)]
5755struct V2StagedFile {
5756    path: String,
5757    source: PathBuf,
5758    sha256: String,
5759    bytes: u64,
5760}
5761
5762#[cfg(unix)]
5763fn v2_download_cache_dir(
5764    cfg: &HubConfig,
5765    brain: &str,
5766    pointer: &V2PointerBody,
5767) -> LinkResult<PathBuf> {
5768    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5769}
5770
5771#[cfg(unix)]
5772fn v2_download_cache_dir_for(
5773    cfg: &HubConfig,
5774    brain: &str,
5775    transaction: &str,
5776) -> LinkResult<PathBuf> {
5777    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5778        return Err(invalid_feed("v2 download cache address is invalid"));
5779    }
5780    let path = cfg
5781        .state_dir
5782        .join("downloads")
5783        .join(brain)
5784        .join(transaction);
5785    let directory = open_or_create_dir_nofollow(&path)?;
5786    use std::os::fd::AsRawFd as _;
5787    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5788        return Err(std::io::Error::last_os_error().into());
5789    }
5790    directory.sync_all()?;
5791    Ok(path)
5792}
5793
5794#[cfg(windows)]
5795fn v2_download_cache_dir(
5796    cfg: &HubConfig,
5797    brain: &str,
5798    pointer: &V2PointerBody,
5799) -> LinkResult<PathBuf> {
5800    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5801}
5802
5803#[cfg(windows)]
5804fn v2_download_cache_dir_for(
5805    cfg: &HubConfig,
5806    brain: &str,
5807    transaction: &str,
5808) -> LinkResult<PathBuf> {
5809    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5810        return Err(invalid_feed("v2 download cache address is invalid"));
5811    }
5812    let path = cfg
5813        .state_dir
5814        .join("downloads")
5815        .join(brain)
5816        .join(transaction);
5817    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5818    crate::fsx::open_directory_nofollow(&path)?;
5819    Ok(path)
5820}
5821
5822#[cfg(unix)]
5823fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5824    use std::os::fd::AsRawFd as _;
5825    let parent = cfg.state_dir.join("downloads").join(brain);
5826    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5827        return;
5828    };
5829    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5830        return;
5831    };
5832    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5833    let _ = directory.sync_all();
5834}
5835
5836#[cfg(windows)]
5837fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5838    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5839        return;
5840    }
5841    let parent = cfg.state_dir.join("downloads").join(brain);
5842    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5843        return;
5844    };
5845    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5846}
5847
5848#[cfg(not(any(unix, windows)))]
5849fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5850
5851#[cfg(not(any(unix, windows)))]
5852fn v2_download_cache_dir_for(
5853    _cfg: &HubConfig,
5854    _brain: &str,
5855    _transaction: &str,
5856) -> LinkResult<PathBuf> {
5857    Err(LinkError::UnsupportedPlatform {
5858        operation: "resumable v2 download staging",
5859    })
5860}
5861
5862#[cfg(any(unix, windows))]
5863fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5864    let file = match crate::fsx::open_regular_nofollow(path) {
5865        Ok(file) => file,
5866        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5867        Err(error) => return Err(error.into()),
5868    };
5869    if file.metadata()?.len() != bytes {
5870        return Ok(false);
5871    }
5872    Ok(content_sha256_reader(file)? == sha256)
5873}
5874
5875#[cfg(any(unix, windows))]
5876fn cache_v2_blob_bytes(
5877    cache_dir: &Path,
5878    sha256: &str,
5879    expected_bytes: u64,
5880    bytes: &[u8],
5881) -> LinkResult<PathBuf> {
5882    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5883        return Err(invalid_feed("v2 cached blob differs from its declaration"));
5884    }
5885    let path = cache_dir.join(sha256);
5886    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5887        crate::fsx::write_atomic(&path, bytes)?;
5888    }
5889    Ok(path)
5890}
5891
5892#[cfg(not(any(unix, windows)))]
5893fn cache_v2_blob_bytes(
5894    _cache_dir: &Path,
5895    _sha256: &str,
5896    _expected_bytes: u64,
5897    _bytes: &[u8],
5898) -> LinkResult<PathBuf> {
5899    Err(LinkError::UnsupportedPlatform {
5900        operation: "resumable v2 download staging",
5901    })
5902}
5903
5904#[cfg(unix)]
5905fn download_presigned_to_cache(
5906    cfg: &HubConfig,
5907    url: &str,
5908    cache_dir: &Path,
5909    sha256: &str,
5910    expected_bytes: u64,
5911) -> LinkResult<PathBuf> {
5912    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5913
5914    let target = cache_dir.join(sha256);
5915    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5916        return Ok(target);
5917    }
5918    let directory = open_existing_dir_nofollow(cache_dir)?;
5919    let mut nonce = [0_u8; 16];
5920    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5921        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5922    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5923    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5924    let fd = unsafe {
5925        libc::openat(
5926            directory.as_raw_fd(),
5927            temp.as_ptr(),
5928            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5929            0o600,
5930        )
5931    };
5932    if fd < 0 {
5933        return Err(std::io::Error::last_os_error().into());
5934    }
5935    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5936    let response = match presigned_agent(cfg, url)?.get(url).call() {
5937        Ok(response) => response,
5938        Err(ureq::Error::Status(_, response)) => {
5939            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5940            return Err(LinkError::Http {
5941                what: "v2 direct download",
5942                status: response.status(),
5943                message: "object store rejected the download".to_string(),
5944                code: None,
5945                details: None,
5946            });
5947        }
5948        Err(ureq::Error::Transport(error)) => {
5949            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5950            return Err(LinkError::Transport {
5951                hub: cfg.hub.clone(),
5952                message: error.to_string(),
5953            });
5954        }
5955    };
5956    let mut reader = response
5957        .into_reader()
5958        .take(expected_bytes.saturating_add(1));
5959    let mut digest = Sha256::new();
5960    let mut total = 0_u64;
5961    let mut buffer = [0_u8; 64 * 1024];
5962    // Reading the socket and writing the file are different failures wearing
5963    // the same io::Error: a stalled download is the peer's problem and a full
5964    // disk is this machine's, and an operator who is told only "timed out"
5965    // cannot tell which one to act on.
5966    let write_result = (|| -> LinkResult<()> {
5967        loop {
5968            let read = reader
5969                .read(&mut buffer)
5970                .map_err(|error| LinkError::Transport {
5971                    hub: cfg.hub.clone(),
5972                    message: error.to_string(),
5973                })?;
5974            if read == 0 {
5975                break;
5976            }
5977            total = total.saturating_add(read as u64);
5978            digest.update(&buffer[..read]);
5979            output.write_all(&buffer[..read])?;
5980        }
5981        output.sync_all().map_err(LinkError::from)
5982    })();
5983    if let Err(error) = write_result {
5984        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5985        return Err(error);
5986    }
5987    drop(output);
5988    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5989        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5990        return Err(invalid_feed(
5991            "v2 direct download failed integrity verification",
5992        ));
5993    }
5994    let target_name = c_name(sha256.as_bytes(), sha256)?;
5995    // Replace only a previously inspected cache leaf. Cache bytes are private
5996    // and content-addressed; final install re-verifies them again.
5997    if unsafe {
5998        libc::renameat(
5999            directory.as_raw_fd(),
6000            temp.as_ptr(),
6001            directory.as_raw_fd(),
6002            target_name.as_ptr(),
6003        )
6004    } != 0
6005    {
6006        let error = std::io::Error::last_os_error();
6007        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6008        return Err(error.into());
6009    }
6010    directory.sync_all()?;
6011    Ok(target)
6012}
6013
6014#[cfg(windows)]
6015fn download_presigned_to_cache(
6016    cfg: &HubConfig,
6017    url: &str,
6018    cache_dir: &Path,
6019    sha256: &str,
6020    expected_bytes: u64,
6021) -> LinkResult<PathBuf> {
6022    use std::fs::OpenOptions;
6023
6024    let target = cache_dir.join(sha256);
6025    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6026        return Ok(target);
6027    }
6028    // Keep the checked directory handle alive while the private cache path is
6029    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
6030    // an ancestor can be swapped after validation.
6031    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6032    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6033    let mut output = OpenOptions::new()
6034        .write(true)
6035        .create_new(true)
6036        .open(&temp)?;
6037    let response = match presigned_agent(cfg, url)?.get(url).call() {
6038        Ok(response) => response,
6039        Err(ureq::Error::Status(_, response)) => {
6040            let _ = std::fs::remove_file(&temp);
6041            return Err(LinkError::Http {
6042                what: "v2 direct download",
6043                status: response.status(),
6044                message: "object store rejected the download".to_string(),
6045                code: None,
6046                details: None,
6047            });
6048        }
6049        Err(ureq::Error::Transport(error)) => {
6050            let _ = std::fs::remove_file(&temp);
6051            return Err(LinkError::Transport {
6052                hub: cfg.hub.clone(),
6053                message: error.to_string(),
6054            });
6055        }
6056    };
6057    let mut reader = response
6058        .into_reader()
6059        .take(expected_bytes.saturating_add(1));
6060    let mut digest = Sha256::new();
6061    let mut total = 0_u64;
6062    let mut buffer = [0_u8; 64 * 1024];
6063    // Same split as the cache-fill path: a stalled socket is not a disk error.
6064    let copied = (|| -> LinkResult<()> {
6065        loop {
6066            let read = reader
6067                .read(&mut buffer)
6068                .map_err(|error| LinkError::Transport {
6069                    hub: cfg.hub.clone(),
6070                    message: error.to_string(),
6071                })?;
6072            if read == 0 {
6073                break;
6074            }
6075            total = total.saturating_add(read as u64);
6076            digest.update(&buffer[..read]);
6077            output.write_all(&buffer[..read])?;
6078        }
6079        output.sync_all()?;
6080        Ok(())
6081    })();
6082    if let Err(error) = copied {
6083        let _ = std::fs::remove_file(&temp);
6084        return Err(error);
6085    }
6086    drop(output);
6087    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6088        let _ = std::fs::remove_file(&temp);
6089        return Err(invalid_feed(
6090            "v2 direct download failed integrity verification",
6091        ));
6092    }
6093    if target.exists() {
6094        std::fs::remove_file(&target)?;
6095    }
6096    if let Err(error) = std::fs::rename(&temp, &target) {
6097        let _ = std::fs::remove_file(&temp);
6098        return Err(error.into());
6099    }
6100    Ok(target)
6101}
6102
6103#[cfg(not(any(unix, windows)))]
6104fn download_presigned_to_cache(
6105    _cfg: &HubConfig,
6106    _url: &str,
6107    _cache_dir: &Path,
6108    _sha256: &str,
6109    _expected_bytes: u64,
6110) -> LinkResult<PathBuf> {
6111    Err(LinkError::UnsupportedPlatform {
6112        operation: "resumable v2 download staging",
6113    })
6114}
6115
6116fn download_v2_blobs(
6117    cfg: &HubConfig,
6118    brain: &str,
6119    pointer: &V2PointerBody,
6120    pending: Vec<(&String, &V2BaselineFile)>,
6121) -> LinkResult<Vec<(String, Vec<u8>)>> {
6122    if pending.is_empty() {
6123        return Ok(Vec::new());
6124    }
6125    let expected_order = pending
6126        .iter()
6127        .map(|(path, _)| (*path).clone())
6128        .collect::<Vec<_>>();
6129    let mut streamed = std::collections::BTreeMap::new();
6130    let mut direct = Vec::new();
6131    let mut window = Vec::new();
6132    let mut window_bytes = 0_u64;
6133    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6134                 window_bytes: &mut u64,
6135                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6136     -> LinkResult<()> {
6137        if window.is_empty() {
6138            return Ok(());
6139        }
6140        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6141            if streamed.insert(path, bytes).is_some() {
6142                return Err(invalid_feed("v2 bulk streams repeated a path"));
6143            }
6144        }
6145        window.clear();
6146        *window_bytes = 0;
6147        Ok(())
6148    };
6149    for &(path, file) in &pending {
6150        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6151            flush(&mut window, &mut window_bytes, &mut streamed)?;
6152            direct.push((path, file));
6153            continue;
6154        }
6155        if window.len() == V2_BULK_STREAM_FILES
6156            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6157        {
6158            flush(&mut window, &mut window_bytes, &mut streamed)?;
6159        }
6160        window.push((path, file));
6161        window_bytes += file.bytes;
6162    }
6163    flush(&mut window, &mut window_bytes, &mut streamed)?;
6164
6165    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6166    let next = std::sync::atomic::AtomicUsize::new(0);
6167    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6168    let mut results = std::iter::repeat_with(|| None)
6169        .take(downloads.len())
6170        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6171    std::thread::scope(|scope| {
6172        let (sender, receiver) = std::sync::mpsc::channel();
6173        for _ in 0..worker_count {
6174            let sender = sender.clone();
6175            let downloads = &downloads;
6176            let next = &next;
6177            scope.spawn(move || loop {
6178                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6179                let Some(item) = downloads.get(index) else {
6180                    break;
6181                };
6182                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6183                if sender.send((index, result)).is_err() {
6184                    break;
6185                }
6186            });
6187        }
6188        drop(sender);
6189        for (index, result) in receiver {
6190            results[index] = Some(result);
6191        }
6192    });
6193    for result in results.into_iter().map(|result| {
6194        result.ok_or_else(|| LinkError::Transport {
6195            hub: cfg.hub.clone(),
6196            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6197        })?
6198    }) {
6199        let (path, bytes) = result?;
6200        if streamed.insert(path, bytes).is_some() {
6201            return Err(invalid_feed("v2 download lanes repeated a path"));
6202        }
6203    }
6204    expected_order
6205        .into_iter()
6206        .map(|path| {
6207            streamed
6208                .remove(&path)
6209                .map(|bytes| (path, bytes))
6210                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6211        })
6212        .collect()
6213}
6214
6215/// Resume an exact-head materialization into the private global cache. Each
6216/// verified frame is durable before the next network window begins, so a
6217/// retry skips it and memory never grows with total changed bytes.
6218#[cfg(any(unix, windows))]
6219fn stage_v2_blobs(
6220    cfg: &HubConfig,
6221    brain: &str,
6222    pointer: &V2PointerBody,
6223    pending: Vec<(&String, &V2BaselineFile)>,
6224) -> LinkResult<Vec<V2StagedFile>> {
6225    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6226    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6227    let mut direct = Vec::new();
6228    let mut window = Vec::new();
6229    let mut window_bytes = 0_u64;
6230    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6231                 window_bytes: &mut u64,
6232                 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6233     -> LinkResult<()> {
6234        if window.is_empty() {
6235            return Ok(());
6236        }
6237        let missing = window
6238            .iter()
6239            .filter_map(|(path, file)| {
6240                let target = cache_dir.join(&file.sha256);
6241                match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6242                    Ok(true) => {
6243                        staged.insert(
6244                            (*path).clone(),
6245                            V2StagedFile {
6246                                path: (*path).clone(),
6247                                source: target,
6248                                sha256: file.sha256.clone(),
6249                                bytes: file.bytes,
6250                            },
6251                        );
6252                        None
6253                    }
6254                    Ok(false) => Some(Ok((*path, *file))),
6255                    Err(error) => Some(Err(error)),
6256                }
6257            })
6258            .collect::<LinkResult<Vec<_>>>()?;
6259        if !missing.is_empty() {
6260            for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6261                let file = missing
6262                    .iter()
6263                    .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6264                    .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6265                let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6266                staged.insert(
6267                    path.clone(),
6268                    V2StagedFile {
6269                        path,
6270                        source,
6271                        sha256: file.sha256.clone(),
6272                        bytes: file.bytes,
6273                    },
6274                );
6275            }
6276        }
6277        window.clear();
6278        *window_bytes = 0;
6279        Ok(())
6280    };
6281    for &(path, file) in &pending {
6282        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6283            flush(&mut window, &mut window_bytes, &mut staged)?;
6284            direct.push((path, file));
6285            continue;
6286        }
6287        if window.len() == V2_BULK_STREAM_FILES
6288            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6289        {
6290            flush(&mut window, &mut window_bytes, &mut staged)?;
6291        }
6292        window.push((path, file));
6293        window_bytes += file.bytes;
6294    }
6295    flush(&mut window, &mut window_bytes, &mut staged)?;
6296    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6297        let source =
6298            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6299        staged.insert(
6300            item.path.clone(),
6301            V2StagedFile {
6302                path: item.path,
6303                source,
6304                sha256: item.sha256,
6305                bytes: item.bytes,
6306            },
6307        );
6308    }
6309    pending
6310        .into_iter()
6311        .map(|(path, _)| {
6312            staged
6313                .remove(path)
6314                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6315        })
6316        .collect()
6317}
6318
6319#[cfg(not(any(unix, windows)))]
6320fn stage_v2_blobs(
6321    _cfg: &HubConfig,
6322    _brain: &str,
6323    _pointer: &V2PointerBody,
6324    _pending: Vec<(&String, &V2BaselineFile)>,
6325) -> LinkResult<Vec<V2StagedFile>> {
6326    Err(LinkError::UnsupportedPlatform {
6327        operation: "resumable v2 download staging",
6328    })
6329}
6330
6331const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6332const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6333const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6334
6335#[derive(Debug, Clone, Deserialize, Serialize)]
6336struct V2ConflictCoordinate {
6337    sha256: Option<String>,
6338    bytes: Option<u64>,
6339    file: Option<String>,
6340}
6341
6342#[derive(Debug, Clone, Deserialize, Serialize)]
6343struct V2ConflictFile {
6344    path: String,
6345    base: V2ConflictCoordinate,
6346    local: V2ConflictCoordinate,
6347    remote: V2ConflictCoordinate,
6348}
6349
6350#[derive(Debug, Clone, Deserialize, Serialize)]
6351struct V2ConflictPlan {
6352    v: u8,
6353    class: String,
6354    bundle: String,
6355    brain: String,
6356    origin: String,
6357    created_unix: u64,
6358    expires_unix: u64,
6359    base_seq: Option<u64>,
6360    base_commit: Option<String>,
6361    remote_seq: u64,
6362    remote_commit: Option<String>,
6363    remote_content_root: Option<String>,
6364    view_kind: String,
6365    view_revision: String,
6366    files: Vec<V2ConflictFile>,
6367}
6368
6369fn v2_take_remote_selection(
6370    files: &[V2ConflictFile],
6371    current: &std::collections::BTreeMap<String, V2BaselineFile>,
6372) -> LinkResult<(
6373    std::collections::BTreeMap<String, V2BaselineFile>,
6374    Vec<String>,
6375)> {
6376    let mut selected = std::collections::BTreeMap::new();
6377    let mut deleted = Vec::new();
6378    for file in files {
6379        match (&file.remote.sha256, file.remote.bytes) {
6380            (Some(sha256), Some(bytes)) => {
6381                let proven = current.get(&file.path).ok_or_else(|| {
6382                    invalid_feed("conflict remote coordinate disappeared from the exact head")
6383                })?;
6384                if proven.sha256 != *sha256 || proven.bytes != bytes {
6385                    return Err(invalid_feed(
6386                        "conflict remote coordinate differs from the exact head",
6387                    ));
6388                }
6389                if selected.insert(file.path.clone(), proven.clone()).is_some() {
6390                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6391                }
6392            }
6393            (None, None) => {
6394                if current.contains_key(&file.path) {
6395                    return Err(invalid_feed(
6396                        "conflict remote deletion differs from the exact head",
6397                    ));
6398                }
6399                deleted.push(file.path.clone());
6400            }
6401            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6402        }
6403    }
6404    Ok((selected, deleted))
6405}
6406
6407fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6408    PathBuf::from(".dbmd")
6409        .join("conflicts")
6410        .join(bundle)
6411        .join(suffix)
6412}
6413
6414fn read_historical_conflict_blob(
6415    cfg: &HubConfig,
6416    brain: &str,
6417    baseline: &V2SyncBaseline,
6418    path: &str,
6419    file: &V2BaselineFile,
6420) -> LinkResult<Option<Vec<u8>>> {
6421    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6422        return Ok(None);
6423    };
6424    if seq == 0 {
6425        return Ok(None);
6426    }
6427    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6428    let endpoint = format!(
6429        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6430        file.sha256
6431    );
6432    let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6433    if raw.status == 404 || raw.status == 403 {
6434        return Ok(None);
6435    }
6436    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6437    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6438        return Err(invalid_feed(
6439            "v2 conflict base failed integrity verification",
6440        ));
6441    }
6442    Ok(Some(bytes))
6443}
6444
6445/// Persist a readable content conflict without modifying either side. The
6446/// plan is written last, so an interrupted bundle build is never actionable.
6447fn create_v2_conflict_bundle(
6448    cfg: &HubConfig,
6449    store: &Store,
6450    head: &V2VerifiedHead,
6451    baseline: Option<&V2SyncBaseline>,
6452    local: &std::collections::BTreeMap<String, (String, u64)>,
6453    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6454    paths: &[String],
6455) -> LinkResult<(String, Vec<String>)> {
6456    let conflicts_root = Path::new(".dbmd/conflicts");
6457    store.create_dir_all(conflicts_root)?;
6458    let completed = store
6459        .directory_names(conflicts_root)?
6460        .into_iter()
6461        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6462        .count();
6463    if completed >= V2_CONFLICT_BUNDLE_MAX {
6464        return Err(LinkError::InvalidPack {
6465            message: format!(
6466                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6467            ),
6468        });
6469    }
6470
6471    // A conflict bundle is diagnostic state, not a second whole-brain cache.
6472    // Materialize a deterministic prefix whose remote artifacts fit one
6473    // bounded window. Resolving it exposes the next prefix on the next sync.
6474    let mut selected_paths = Vec::new();
6475    let mut selected_remote_bytes = 0_u64;
6476    for path in paths {
6477        let bytes = remote.get(path).map_or(0, |file| file.bytes);
6478        if !selected_paths.is_empty()
6479            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6480        {
6481            break;
6482        }
6483        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6484        selected_paths.push(path.clone());
6485        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6486            break;
6487        }
6488    }
6489    if selected_paths.is_empty() {
6490        return Err(invalid_feed("content conflict set is empty"));
6491    }
6492    let bundle = crate::ulid::mint();
6493    let bundle_root = v2_conflict_relative(&bundle, "");
6494    store.create_dir_all(&bundle_root.join("files"))?;
6495    let pointer = head.pointer.as_ref();
6496    let remote_bytes = match pointer {
6497        Some(pointer) => download_v2_blobs(
6498            cfg,
6499            &head.brain_id,
6500            pointer,
6501            selected_paths
6502                .iter()
6503                .filter_map(|path| {
6504                    remote
6505                        .get(path)
6506                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6507                        .map(|file| (path, file))
6508                })
6509                .collect(),
6510        )?
6511        .into_iter()
6512        .collect::<std::collections::BTreeMap<_, _>>(),
6513        None => std::collections::BTreeMap::new(),
6514    };
6515
6516    let mut files = Vec::with_capacity(selected_paths.len());
6517    for (index, path) in selected_paths.iter().enumerate() {
6518        let base_file = baseline.and_then(|state| state.files.get(path));
6519        let base_bytes = match (baseline, base_file) {
6520            (Some(state), Some(file)) => {
6521                read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6522            }
6523            _ => None,
6524        };
6525        let local_file = local.get(path);
6526        let remote_file = remote.get(path);
6527        let remote_content = remote_bytes.get(path);
6528        let prefix = format!("files/{index:04}");
6529        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6530        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6531        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6532        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6533            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6534        }
6535        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6536            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6537            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6538                return Err(LinkError::InvalidPack {
6539                    message: format!("local conflict path `{path}` changed while bundling"),
6540                });
6541            }
6542            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6543        }
6544        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6545            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6546        }
6547        files.push(V2ConflictFile {
6548            path: path.clone(),
6549            base: V2ConflictCoordinate {
6550                sha256: base_file.map(|file| file.sha256.clone()),
6551                bytes: base_file.map(|file| file.bytes),
6552                file: base_name,
6553            },
6554            local: V2ConflictCoordinate {
6555                sha256: local_file.map(|(sha256, _)| sha256.clone()),
6556                bytes: local_file.map(|(_, bytes)| *bytes),
6557                file: local_name,
6558            },
6559            remote: V2ConflictCoordinate {
6560                sha256: remote_file.map(|file| file.sha256.clone()),
6561                bytes: remote_file.map(|file| file.bytes),
6562                file: remote_name,
6563            },
6564        });
6565    }
6566    let now = SystemTime::now()
6567        .duration_since(UNIX_EPOCH)
6568        .unwrap_or_default()
6569        .as_secs();
6570    let plan = V2ConflictPlan {
6571        v: 2,
6572        class: "content_resolution_required".to_string(),
6573        bundle: bundle.clone(),
6574        brain: head.brain_id.clone(),
6575        origin: normalized_origin(&cfg.hub)?,
6576        created_unix: now,
6577        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6578        base_seq: baseline.and_then(|state| state.head_seq),
6579        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6580        remote_seq: pointer.map_or(0, |value| value.seq),
6581        remote_commit: pointer.map(|value| value.commit_hash.clone()),
6582        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6583        view_kind: head.view_kind.clone(),
6584        view_revision: head.view_revision.clone(),
6585        files,
6586    };
6587    let mut bytes = serde_json::to_vec_pretty(&plan)
6588        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6589    bytes.push(b'\n');
6590    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6591    Ok((bundle, selected_paths))
6592}
6593
6594fn v2_sync_pull_with_resolution(
6595    cfg: &HubConfig,
6596    requested_brain: &str,
6597    expected_head: V2VerifiedHead,
6598    out: Option<&Path>,
6599    take_remote: Option<&std::collections::BTreeSet<String>>,
6600) -> LinkResult<V2PulledSnapshot> {
6601    let dest = out
6602        .map(Path::to_path_buf)
6603        .unwrap_or_else(|| PathBuf::from(requested_brain));
6604    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6605    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6606    let head = v2_verified_head(cfg, requested_brain)?
6607        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6608    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6609        return Err(LinkError::RemoteAdvancedDuringSync);
6610    }
6611    let remote = files_for_v2_view(
6612        &head,
6613        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6614    );
6615    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
6616    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6617    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6618    let local_store = Store::open_strict(&dest).ok();
6619    // A baseline proves this path is an established checkout. Never reinterpret
6620    // a damaged/edited store marker as an empty clone destination: doing so
6621    // would let the pull half erase the local evidence before the push half can
6622    // reject it (most importantly for the generated scoped-view DB.md).
6623    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6624    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6625    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6626        return Err(LinkError::ScopedViewChanged);
6627    }
6628    if let Some(view) = local_view.as_mut() {
6629        remove_scoped_projection(&head, baseline.as_ref(), view)?;
6630    }
6631    let empty_local = std::collections::BTreeMap::new();
6632    let local = local_view
6633        .as_ref()
6634        .map_or(&empty_local, |view| &view.riding);
6635    let kept_home = |path: &str| {
6636        local_view
6637            .as_ref()
6638            .is_some_and(|view| view.policy.keeps_home(path))
6639    };
6640    let empty_base = std::collections::BTreeMap::new();
6641    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6642    let empty_base_assets = std::collections::BTreeMap::new();
6643    let base_assets = baseline
6644        .as_ref()
6645        .map_or(&empty_base_assets, |state| &state.assets);
6646    let mut local_assets = local_store
6647        .as_ref()
6648        .map(v2_local_asset_records)
6649        .transpose()?
6650        .unwrap_or_default();
6651    let mut content_merge = merge_v2_pulled_records(
6652        base,
6653        &remote,
6654        local,
6655        |file, _| (file.sha256.clone(), file.bytes),
6656        |file, _| (file.sha256.clone(), file.bytes),
6657        kept_home,
6658    );
6659    if let Some(selected) = take_remote {
6660        for path in selected {
6661            if let Some(position) = content_merge
6662                .conflicts
6663                .iter()
6664                .position(|conflict| conflict == path)
6665            {
6666                content_merge.conflicts.remove(position);
6667                content_merge.accept_remote.insert(path.clone());
6668                match remote.get(path) {
6669                    Some(file) => {
6670                        content_merge
6671                            .records
6672                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
6673                    }
6674                    None => {
6675                        content_merge.records.remove(path);
6676                    }
6677                }
6678            } else if !content_merge.accept_remote.contains(path) {
6679                return Err(LinkError::InvalidPack {
6680                    message: format!(
6681                        "take-remote path `{path}` is no longer at its conflict coordinate"
6682                    ),
6683                });
6684            }
6685        }
6686    }
6687    if !content_merge.conflicts.is_empty() {
6688        let mut conflicts = content_merge.conflicts.clone();
6689        conflicts.truncate(100);
6690        if let Some(store) = local_store.as_ref() {
6691            let (bundle, paths) = create_v2_conflict_bundle(
6692                cfg,
6693                store,
6694                &head,
6695                baseline.as_ref(),
6696                local,
6697                &remote,
6698                &conflicts,
6699            )?;
6700            return Err(LinkError::ConflictBundle { bundle, paths });
6701        }
6702        return Err(LinkError::Conflict { paths: conflicts });
6703    }
6704    let asset_merge = merge_v2_pulled_records(
6705        base_assets,
6706        &remote_assets,
6707        &local_assets,
6708        v2_asset_record,
6709        v2_asset_record,
6710        |_| false,
6711    );
6712    if !asset_merge.conflicts.is_empty() {
6713        let mut conflicts = asset_merge.conflicts.clone();
6714        conflicts.truncate(100);
6715        return Err(LinkError::Conflict { paths: conflicts });
6716    }
6717    let pointer = head.pointer.as_ref();
6718    let cache_transaction = pointer.map_or_else(
6719        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6720        |value| value.commit_hash.clone(),
6721    );
6722    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6723    let mut changed = match pointer {
6724        Some(pointer) => stage_v2_blobs(
6725            cfg,
6726            &head.brain_id,
6727            pointer,
6728            remote
6729                .iter()
6730                .filter(|(path, file)| {
6731                    content_merge.accept_remote.contains(*path)
6732                        && local.get(*path).map(|value| value.0.as_str())
6733                            != Some(file.sha256.as_str())
6734                })
6735                .collect(),
6736        )?,
6737        None => Vec::new(),
6738    };
6739    let mut deleted = content_merge
6740        .accept_remote
6741        .iter()
6742        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6743        .cloned()
6744        .collect::<Vec<_>>();
6745    if local_assets != asset_merge.records {
6746        if asset_merge.records.is_empty() {
6747            deleted.push("assets.jsonl".to_string());
6748        } else {
6749            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6750            let sha256 = content_sha256(&bytes);
6751            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6752            changed.push(V2StagedFile {
6753                path: "assets.jsonl".to_string(),
6754                source,
6755                sha256,
6756                bytes: bytes.len() as u64,
6757            });
6758        }
6759    }
6760    if let Some(pointer) = pointer {
6761        let mut pending_assets = Vec::new();
6762        for (path, asset) in &remote_assets {
6763            if asset.disposition != "hosted"
6764                || kept_home(path)
6765                || !asset_merge.accept_remote.contains(path)
6766            {
6767                continue;
6768            }
6769            let already_current = local_store.as_ref().is_some_and(|store| {
6770                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6771                    && store
6772                        .read_bounded(Path::new(path), asset.bytes)
6773                        .ok()
6774                        .is_some_and(|bytes| {
6775                            bytes.len() as u64 == asset.bytes
6776                                && content_sha256(&bytes) == asset.blob_sha256
6777                        })
6778            });
6779            if !already_current {
6780                pending_assets.push((path, asset));
6781            }
6782        }
6783        let mut window = Vec::new();
6784        let mut window_bytes = 0_u64;
6785        let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
6786                     window_bytes: &mut u64,
6787                     changed: &mut Vec<V2StagedFile>|
6788         -> LinkResult<()> {
6789            changed.extend(stage_v2_asset_download_window(
6790                cfg,
6791                &head.brain_id,
6792                pointer,
6793                &cache_dir,
6794                window,
6795            )?);
6796            window.clear();
6797            *window_bytes = 0;
6798            Ok(())
6799        };
6800        for item @ (_, asset) in pending_assets {
6801            if !window.is_empty()
6802                && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
6803                    || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
6804            {
6805                flush(&mut window, &mut window_bytes, &mut changed)?;
6806            }
6807            window.push(item);
6808            window_bytes = window_bytes.saturating_add(asset.bytes);
6809            if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
6810                flush(&mut window, &mut window_bytes, &mut changed)?;
6811            }
6812        }
6813        flush(&mut window, &mut window_bytes, &mut changed)?;
6814    }
6815    for (path, prior) in base_assets {
6816        if remote_assets.contains_key(path)
6817            || kept_home(path)
6818            || !asset_merge.accept_remote.contains(path)
6819        {
6820            continue;
6821        }
6822        let unchanged = local_store.as_ref().is_some_and(|store| {
6823            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6824                && store
6825                    .read_bounded(Path::new(path), prior.bytes)
6826                    .ok()
6827                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6828        });
6829        if unchanged {
6830            deleted.push(path.clone());
6831        }
6832    }
6833    let extra_local = content_merge
6834        .records
6835        .keys()
6836        .filter(|path| !remote.contains_key(*path))
6837        .cloned()
6838        .collect::<Vec<_>>();
6839    if head.view_kind == "scoped" {
6840        for (path, bytes) in [
6841            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6842            (
6843                ".dbmd/view.json".to_string(),
6844                scoped_view_metadata(&head, remote.len())?,
6845            ),
6846        ] {
6847            let sha256 = content_sha256(&bytes);
6848            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6849            changed.push(V2StagedFile {
6850                path,
6851                source,
6852                sha256,
6853                bytes: bytes.len() as u64,
6854            });
6855        }
6856    }
6857    let install_changed = !changed.is_empty() || !deleted.is_empty();
6858    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6859    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6860        let installed_store =
6861            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6862                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6863            })?;
6864        let installed_local = if install_changed {
6865            let mut scanned = v2_local_files(&installed_store)?;
6866            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6867            scanned
6868        } else {
6869            local_view
6870                .take()
6871                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6872        };
6873        if installed_local.riding != content_merge.records {
6874            return Err(LinkError::InvalidPack {
6875                message: "local content changed while installing the v2 pull".to_string(),
6876            });
6877        }
6878        let installed_assets = if install_changed {
6879            v2_local_asset_records(&installed_store)?
6880        } else {
6881            std::mem::take(&mut local_assets)
6882        };
6883        if installed_assets != asset_merge.records {
6884            return Err(LinkError::InvalidPack {
6885                message: "local assets changed while installing the v2 pull".to_string(),
6886            });
6887        }
6888        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6889            installed_local.policy.keeps_home(path)
6890        })
6891            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6892        let final_head = v2_verified_head(cfg, requested_brain)?
6893            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6894        if !same_v2_head(&head, &final_head) {
6895            return Err(LinkError::RemoteAdvancedDuringSync);
6896        }
6897        accept_v2_head(cfg, &final_head)?;
6898        save_v2_baseline(
6899            cfg,
6900            &head.brain_id,
6901            &dest,
6902            &v2_baseline_from_head(
6903                cfg,
6904                &head,
6905                remote.clone(),
6906                remote_assets.clone(),
6907                Some(&installed_local),
6908                baseline
6909                    .as_ref()
6910                    .and_then(|current| current.checkout_id.as_deref()),
6911            )?,
6912        )?;
6913        complete_v2_pull(&dest)?;
6914        Ok((local_dirty, installed_local, installed_assets))
6915    })();
6916    let (local_dirty, installed_local, installed_assets) = match finalized {
6917        Ok(value) => value,
6918        Err(error) => {
6919            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6920                return Err(LinkError::InvalidPack {
6921                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
6922                });
6923            }
6924            return Err(error);
6925        }
6926    };
6927    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6928    let report = PullReport {
6929        brain: head.brain_id.clone(),
6930        slug: requested_brain.to_string(),
6931        head_seq: pointer.map_or(0, |value| value.seq),
6932        files: remote.len() + remote_assets.len(),
6933        dest: dest.to_string_lossy().into_owned(),
6934        extra_local,
6935        sync_status: if local_dirty {
6936            "local_dirty_after_install".to_string()
6937        } else {
6938            "synced".to_string()
6939        },
6940    };
6941    Ok(V2PulledSnapshot {
6942        report,
6943        head,
6944        files: remote,
6945        assets: remote_assets,
6946        local: installed_local,
6947        local_assets: installed_assets,
6948    })
6949}
6950
6951fn v2_sync_pull(
6952    cfg: &HubConfig,
6953    requested_brain: &str,
6954    head: V2VerifiedHead,
6955    out: Option<&Path>,
6956) -> LinkResult<PullReport> {
6957    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6958}
6959
6960fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6961    match remote {
6962        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6963        None => json!({ "kind": "absent" }),
6964    }
6965}
6966
6967fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
6968    match remote {
6969        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
6970        None => json!({ "kind": "absent" }),
6971    }
6972}
6973
6974fn v2_content_withdrawal_operation(
6975    store: &Store,
6976    local_view: &V2LocalView,
6977    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6978    path: &str,
6979    reason: &str,
6980) -> LinkResult<Value> {
6981    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
6982        || path == "DB.md"
6983    {
6984        return Err(LinkError::InvalidPack {
6985            message: format!("content withdrawal path `{path}` is not a record or source"),
6986        });
6987    }
6988    if !local_view.policy.keeps_home(path)
6989        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6990    {
6991        return Err(LinkError::InvalidPack {
6992            message: format!(
6993                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6994            ),
6995        });
6996    }
6997    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
6998        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
6999    })?;
7000    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
7001    Ok(json!({
7002        "op": "withdraw_from_hosting",
7003        "path": path,
7004        "expected": { "kind": "blob", "hash": current.sha256 },
7005        "reason": reason,
7006    }))
7007}
7008
7009fn v2_asset_withdrawal_operation(
7010    store: &Store,
7011    local_view: &V2LocalView,
7012    path: &str,
7013    local: &crate::AssetRecord,
7014    current: &V2BaselineAsset,
7015    reason: &str,
7016) -> LinkResult<Value> {
7017    if !local_view.policy.keeps_home(path)
7018        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7019    {
7020        return Err(LinkError::InvalidPack {
7021            message: format!(
7022                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7023            ),
7024        });
7025    }
7026    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7027    if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
7028        return Err(LinkError::InvalidPack {
7029            message: format!(
7030                "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
7031            ),
7032        });
7033    }
7034    Ok(json!({
7035        "op": "asset_withdraw",
7036        "path": path,
7037        "expected": v2_asset_expected(Some(current)),
7038        "reason": reason,
7039    }))
7040}
7041
7042// A source is immutable, but its exact bytes may move from an intake namespace
7043// to an archive namespace under the distinct `promote_source` capability. A
7044// plain filesystem move appears to sync as one absent old path plus one new
7045// path. Preserve that intent as a protocol rename only when the hash has one
7046// unambiguous source delete and one unambiguous absent-destination source put.
7047// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
7048fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7049    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7050    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7051    for (index, operation) in operations.iter().enumerate() {
7052        match operation.get("op").and_then(Value::as_str) {
7053            Some("delete") => {
7054                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7055                    continue;
7056                };
7057                let Some(hash) = operation
7058                    .get("expected")
7059                    .and_then(|value| value.get("hash"))
7060                    .and_then(Value::as_str)
7061                else {
7062                    continue;
7063                };
7064                if path.starts_with("sources/") {
7065                    deletes
7066                        .entry(hash.to_string())
7067                        .or_default()
7068                        .push((index, path.to_string()));
7069                }
7070            }
7071            Some("put") => {
7072                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7073                    continue;
7074                };
7075                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7076                    continue;
7077                };
7078                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7079                    continue;
7080                };
7081                let destination_absent = operation
7082                    .get("expected")
7083                    .and_then(|value| value.get("kind"))
7084                    .and_then(Value::as_str)
7085                    == Some("absent");
7086                if path.starts_with("sources/") && destination_absent {
7087                    puts.entry(hash.to_string()).or_default().push((
7088                        index,
7089                        path.to_string(),
7090                        bytes,
7091                    ));
7092                }
7093            }
7094            _ => {}
7095        }
7096    }
7097    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7098    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7099    for (hash, source) in deletes {
7100        let Some(destination) = puts.get(&hash) else {
7101            continue;
7102        };
7103        if source.len() != 1 || destination.len() != 1 {
7104            continue;
7105        }
7106        let (delete_index, from) = &source[0];
7107        let (put_index, to, bytes) = &destination[0];
7108        if from == to {
7109            continue;
7110        }
7111        rename_at.insert(
7112            *delete_index,
7113            json!({
7114                "op": "rename",
7115                "from": from,
7116                "to": to,
7117                "expected_from": { "kind": "blob", "hash": hash },
7118                "expected_to": { "kind": "absent" },
7119                "blob": hash,
7120                "bytes": bytes,
7121            }),
7122        );
7123        consumed_puts.insert(*put_index);
7124    }
7125    operations
7126        .into_iter()
7127        .enumerate()
7128        .filter_map(|(index, operation)| {
7129            if let Some(rename) = rename_at.remove(&index) {
7130                Some(rename)
7131            } else if consumed_puts.contains(&index) {
7132                None
7133            } else {
7134                Some(operation)
7135            }
7136        })
7137        .collect()
7138}
7139
7140fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7141    json!({
7142        "blob_sha256": record.sha256,
7143        "bytes": record.bytes,
7144        "media_type": record.media_type,
7145        "wrappers": record.wrappers,
7146        "required": record.required,
7147        "disposition": disposition,
7148    })
7149}
7150
7151/// Apply the exact operation subset generated by this client to an already
7152/// verified manifest. This avoids downloading the same 50k-path listing again
7153/// after an unrebased commit; a rebased response still re-reads the manifest.
7154fn apply_generated_v2_operations(
7155    operations: &[Value],
7156    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7157    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7158    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7159) -> LinkResult<bool> {
7160    let mut asset_changed = false;
7161    for operation in operations {
7162        match operation.get("op").and_then(Value::as_str) {
7163            Some("put") => {
7164                let path = operation
7165                    .get("path")
7166                    .and_then(Value::as_str)
7167                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7168                let sha256 = operation
7169                    .get("blob")
7170                    .and_then(Value::as_str)
7171                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7172                let bytes = operation
7173                    .get("bytes")
7174                    .and_then(Value::as_u64)
7175                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7176                candidate.insert(
7177                    path.to_string(),
7178                    V2BaselineFile {
7179                        sha256: sha256.to_string(),
7180                        bytes,
7181                        proof: None,
7182                    },
7183                );
7184            }
7185            Some("rename") => {
7186                let from = operation
7187                    .get("from")
7188                    .and_then(Value::as_str)
7189                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7190                let to = operation
7191                    .get("to")
7192                    .and_then(Value::as_str)
7193                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7194                let sha256 = operation
7195                    .get("blob")
7196                    .and_then(Value::as_str)
7197                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7198                let bytes = operation
7199                    .get("bytes")
7200                    .and_then(Value::as_u64)
7201                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7202                let expected_from = operation
7203                    .get("expected_from")
7204                    .and_then(|expected| expected.get("hash"))
7205                    .and_then(Value::as_str);
7206                let expected_to_absent = operation
7207                    .get("expected_to")
7208                    .and_then(|expected| expected.get("kind"))
7209                    .and_then(Value::as_str)
7210                    == Some("absent");
7211                if from == to
7212                    || !from.starts_with("sources/")
7213                    || !to.starts_with("sources/")
7214                    || expected_from != Some(sha256)
7215                    || !expected_to_absent
7216                    || candidate.contains_key(to)
7217                {
7218                    return Err(invalid_feed("generated v2 source rename is malformed"));
7219                }
7220                let source = candidate
7221                    .remove(from)
7222                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7223                if source.sha256 != sha256 || source.bytes != bytes {
7224                    return Err(invalid_feed(
7225                        "v2 rename source differs from its exact-byte claim",
7226                    ));
7227                }
7228                candidate.insert(
7229                    to.to_string(),
7230                    V2BaselineFile {
7231                        sha256: sha256.to_string(),
7232                        bytes,
7233                        proof: None,
7234                    },
7235                );
7236            }
7237            Some("delete" | "withdraw_from_hosting") => {
7238                let path = operation
7239                    .get("path")
7240                    .and_then(Value::as_str)
7241                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7242                candidate.remove(path);
7243            }
7244            Some("asset_delete") => {
7245                let path = operation
7246                    .get("path")
7247                    .and_then(Value::as_str)
7248                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7249                candidate_assets.remove(path);
7250                asset_changed = true;
7251            }
7252            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7253                let path = operation
7254                    .get("path")
7255                    .and_then(Value::as_str)
7256                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7257                let record = local_assets
7258                    .get(path)
7259                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7260                let disposition =
7261                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7262                        "withheld"
7263                    } else {
7264                        operation
7265                            .get("asset")
7266                            .and_then(|asset| asset.get("disposition"))
7267                            .and_then(Value::as_str)
7268                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7269                    };
7270                candidate_assets.insert(
7271                    path.to_string(),
7272                    V2BaselineAsset {
7273                        blob_sha256: record.sha256.clone(),
7274                        bytes: record.bytes,
7275                        media_type: record.media_type.clone(),
7276                        wrappers: record.wrappers.clone(),
7277                        required: record.required,
7278                        disposition: disposition.to_string(),
7279                        // The server assigns the signed leaf address. Callers
7280                        // that need an exact asset manifest re-read it below.
7281                        leaf_hash: String::new(),
7282                    },
7283                );
7284                asset_changed = true;
7285            }
7286            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7287        }
7288    }
7289    Ok(asset_changed)
7290}
7291
7292fn v2_riding_matches_remote(
7293    local: &std::collections::BTreeMap<String, (String, u64)>,
7294    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7295    keeps_home: impl Fn(&str) -> bool,
7296) -> bool {
7297    remote.iter().all(|(path, file)| {
7298        keeps_home(path)
7299            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7300    }) && local.iter().all(|(path, (hash, _))| {
7301        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7302    })
7303}
7304
7305#[derive(Debug, Clone)]
7306struct V2ResolutionOverride {
7307    expected_remote: Option<String>,
7308    selected_local: Option<String>,
7309}
7310
7311#[derive(Debug, Clone)]
7312struct V2UploadSource {
7313    path: String,
7314    bytes: u64,
7315}
7316
7317struct V2SyncPushOptions<'a> {
7318    resume_local_policy: bool,
7319    bulk_confirmation: Option<&'a V2BulkConfirmation>,
7320    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7321    pulled: Option<V2PulledSnapshot>,
7322    withdrawal_paths: &'a [String],
7323    withdrawal_reason: Option<&'a str>,
7324}
7325
7326fn verify_v2_upload_source(
7327    store: &Store,
7328    path: &str,
7329    sha256: &str,
7330    expected_bytes: u64,
7331) -> LinkResult<()> {
7332    let file = store.open_regular(Path::new(path))?;
7333    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7334        return Err(LinkError::InvalidPack {
7335            message: format!("local path `{path}` changed during sync planning"),
7336        });
7337    }
7338    Ok(())
7339}
7340
7341/// One reserved object waiting to be staged, held until its whole batch can be
7342/// uploaded together.
7343struct V2PendingUpload<'a> {
7344    url: String,
7345    headers: Value,
7346    sha256: String,
7347    source: &'a V2UploadSource,
7348}
7349
7350/// How many objects a migration stages at once. Bounded so a first push does
7351/// not behave like a burst against the object store, while still being far
7352/// faster than one-at-a-time.
7353/// Workers staging one batch. With a shared pooled connection the cost of an
7354/// object is the PUT itself rather than a handshake, so a wider pool converts
7355/// directly into throughput on a migration.
7356const V2_UPLOAD_CONCURRENCY: usize = 16;
7357
7358/// Stage a reserved batch, uploading up to `V2_UPLOAD_CONCURRENCY` objects at a
7359/// time and verifying each against the store after it lands. The first failure
7360/// is returned and the remaining work is abandoned.
7361fn upload_v2_batch_concurrently(
7362    cfg: &HubConfig,
7363    store: &Store,
7364    pending: &[V2PendingUpload<'_>],
7365) -> LinkResult<()> {
7366    if pending.is_empty() {
7367        return Ok(());
7368    }
7369    let urls = pending
7370        .iter()
7371        .map(|task| task.url.as_str())
7372        .collect::<Vec<_>>();
7373    let shared = shared_staging_agent(cfg, &urls);
7374    if pending.len() == 1 {
7375        let task = &pending[0];
7376        put_presigned_source(
7377            cfg,
7378            &task.url,
7379            &task.headers,
7380            store,
7381            task.source,
7382            shared.as_ref(),
7383        )?;
7384        return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7385    }
7386    let next = std::sync::atomic::AtomicUsize::new(0);
7387    let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7388    let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7389    std::thread::scope(|scope| {
7390        for _ in 0..workers {
7391            scope.spawn(|| loop {
7392                if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7393                    return;
7394                }
7395                let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7396                let Some(task) = pending.get(index) else {
7397                    return;
7398                };
7399                let outcome = put_presigned_source(
7400                    cfg,
7401                    &task.url,
7402                    &task.headers,
7403                    store,
7404                    task.source,
7405                    shared.as_ref(),
7406                )
7407                .and_then(|()| {
7408                    verify_v2_upload_source(
7409                        store,
7410                        &task.source.path,
7411                        &task.sha256,
7412                        task.source.bytes,
7413                    )
7414                });
7415                if let Err(error) = outcome {
7416                    if let Ok(mut guard) = failure.lock() {
7417                        guard.get_or_insert(error);
7418                    }
7419                    return;
7420                }
7421            });
7422        }
7423    });
7424    match failure.into_inner() {
7425        Ok(Some(error)) => Err(error),
7426        Ok(None) => Ok(()),
7427        Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7428    }
7429}
7430
7431fn put_presigned_source(
7432    cfg: &HubConfig,
7433    raw: &str,
7434    headers: &Value,
7435    store: &Store,
7436    source: &V2UploadSource,
7437    shared: Option<&ureq::Agent>,
7438) -> LinkResult<()> {
7439    put_presigned_source_with_budget(
7440        cfg,
7441        raw,
7442        headers,
7443        store,
7444        source,
7445        shared,
7446        std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7447    )
7448}
7449
7450fn put_presigned_source_with_budget(
7451    cfg: &HubConfig,
7452    raw: &str,
7453    headers: &Value,
7454    store: &Store,
7455    source: &V2UploadSource,
7456    shared: Option<&ureq::Agent>,
7457    total_budget: std::time::Duration,
7458) -> LinkResult<()> {
7459    // The URL's own safety checks run either way; only the connection pool is
7460    // shared, so a batch never trades a check for a warm socket.
7461    let owned = match shared {
7462        Some(_) => {
7463            checked_presigned_url(cfg, raw)?;
7464            None
7465        }
7466        None => Some(presigned_agent(cfg, raw)?),
7467    };
7468    let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7469    let deadline = std::time::Instant::now()
7470        .checked_add(total_budget)
7471        .ok_or_else(upload_deadline_error)?;
7472    let mut attempt = 0;
7473    let result = loop {
7474        let file = store.open_regular(Path::new(&source.path))?;
7475        if file.metadata()?.len() != source.bytes {
7476            return Err(LinkError::InvalidPack {
7477                message: format!("local path `{}` changed before upload", source.path),
7478            });
7479        }
7480        // The reservation already states content-length, lowercased. The HTTP
7481        // client de-duplicates headers by exact name, not case, so setting
7482        // `Content-Length` here as well leaves both on the wire and an edge
7483        // rejects the request as malformed. Supply it only when absent.
7484        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7485        let mut has_content_length = false;
7486        if let Some(map) = headers.as_object() {
7487            for (name, value) in map {
7488                if let Some(value) = value.as_str() {
7489                    has_content_length |= name.eq_ignore_ascii_case("content-length");
7490                    req = req.set(name, value);
7491                }
7492            }
7493        }
7494        if !has_content_length {
7495            req = req.set("Content-Length", &source.bytes.to_string());
7496        }
7497        match req.send(file) {
7498            // Every transport failure is retryable here, not only the
7499            // pre-request kinds: staging an object is idempotent (fixed content
7500            // address, exact length and checksum, write-once precondition), the
7501            // file is reopened on each attempt, and a migration holds
7502            // connections long enough that a mid-request drop is ordinary.
7503            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7504                attempt += 1;
7505            }
7506            // A migration stages tens of thousands of immutable objects, and an
7507            // edge or object store may refuse a single one under sustained
7508            // load. The upload is content-addressed and precondition-guarded,
7509            // so replaying it is safe; failing the whole push on one refusal is
7510            // not. 412 already means "someone else stored these exact bytes".
7511            Err(ureq::Error::Status(status, _))
7512                if status != 412
7513                    && is_retryable_upload_status(status)
7514                    && wait_for_upload_retry(deadline, attempt) =>
7515            {
7516                attempt += 1;
7517            }
7518            result => break result,
7519        }
7520    };
7521    match result {
7522        Ok(response) if (200..300).contains(&response.status()) => {
7523            drain_presigned_response(response);
7524            Ok(())
7525        }
7526        Ok(response) => {
7527            // The object store states its own reason (checksum mismatch,
7528            // precondition, expiry). Carrying a bounded excerpt turns an
7529            // unactionable refusal into one an operator can diagnose; the
7530            // coordinate is the store path, never file content.
7531            let status = response.status();
7532            let detail = response
7533                .into_string()
7534                .ok()
7535                .map(|body| body.chars().take(400).collect::<String>())
7536                .filter(|body| !body.trim().is_empty());
7537            Err(LinkError::Http {
7538                what: "v2 changed-byte upload",
7539                status,
7540                message: match detail {
7541                    Some(body) => format!(
7542                        "object store rejected the upload of `{}`: {}",
7543                        source.path,
7544                        body.replace('\n', " ")
7545                    ),
7546                    None => format!("object store rejected the upload of `{}`", source.path),
7547                },
7548                code: None,
7549                details: None,
7550            })
7551        }
7552        Err(error) => match error {
7553            ureq::Error::Status(412, _) => Ok(()),
7554            ureq::Error::Status(_, response) => {
7555                let status = response.status();
7556                let detail = response
7557                    .into_string()
7558                    .ok()
7559                    .map(|body| body.chars().take(400).collect::<String>())
7560                    .filter(|body| !body.trim().is_empty());
7561                Err(LinkError::Http {
7562                    what: "v2 changed-byte upload",
7563                    status,
7564                    message: match detail {
7565                        Some(body) => format!(
7566                            "object store rejected the upload of `{}`: {}",
7567                            source.path,
7568                            body.replace('\n', " ")
7569                        ),
7570                        None => {
7571                            format!("object store rejected the upload of `{}`", source.path)
7572                        }
7573                    },
7574                    code: None,
7575                    details: None,
7576                })
7577            }
7578            ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7579        },
7580    }
7581}
7582
7583/// The request as it is hashed and signed. A staged push carries its operations
7584/// by address rather than inline, so the signed view restores them: the request
7585/// hash covers the change itself, never the transport that carried it.
7586fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7587    if body.get("operations").is_some() {
7588        return body.clone();
7589    }
7590    let mut value = body.clone();
7591    if let Some(map) = value.as_object_mut() {
7592        map.remove("staged_change");
7593        map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7594    }
7595    value
7596}
7597
7598/// Open one staging window, waiting out a busy hub rather than abandoning the
7599/// push. The call reserves capacity and mints URLs; it commits nothing, so
7600/// asking again after a refusal costs only the wait.
7601fn reserve_upload_window(
7602    cfg: &HubConfig,
7603    path: &str,
7604    body: &Value,
7605    what: &'static str,
7606) -> LinkResult<Value> {
7607    let mut attempt = 0;
7608    loop {
7609        let pause = |attempt: usize| {
7610            std::thread::sleep(std::time::Duration::from_millis(
7611                RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7612            ));
7613        };
7614        match request(cfg, "POST", path, Some(body), Auth::Required) {
7615            // A migration opens dozens of windows over tens of minutes, so one
7616            // response that never arrives is ordinary rather than final. The
7617            // call reserves capacity and mints URLs; it commits nothing, so a
7618            // dropped or timed-out attempt is worth asking again.
7619            Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7620                pause(attempt);
7621                attempt += 1;
7622            }
7623            Err(error) => return Err(error),
7624            Ok(response) => {
7625                if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7626                    pause(attempt);
7627                    attempt += 1;
7628                    continue;
7629                }
7630                return ensure_ok(response, what);
7631            }
7632        }
7633    }
7634}
7635
7636/// The exact bytes of a staged change. The hub refuses a manifest carrying any
7637/// other key, so this is the whole contract: operations, and the blob
7638/// references they need, and nothing else.
7639fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7640    let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7641        .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7642    if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7643        return Err(LinkError::PushTooLarge {
7644            detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7645        });
7646    }
7647    Ok(bytes)
7648}
7649
7650/// State one change's operations and blob references through the same
7651/// content-addressed lane its bytes already ride.
7652///
7653/// A first push of a real store names one operation and one blob reference per
7654/// file, which alone outgrows the bounded commit request. Staging that metadata
7655/// and addressing it by hash keeps the push a single atomic commit: the hub
7656/// verifies the address, splices the manifest in, and validates exactly what it
7657/// would have validated inline. The alternative — many partial commits — would
7658/// make a failed migration leave a half-populated brain readable.
7659fn stage_v2_change(
7660    cfg: &HubConfig,
7661    requested_brain: &str,
7662    operations: &[Value],
7663    blobs: Value,
7664) -> LinkResult<Value> {
7665    let bytes = v2_change_manifest(operations, blobs)?;
7666    let sha256 = content_sha256(&bytes);
7667    let reserved = reserve_upload_window(
7668        cfg,
7669        &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7670        &json!({
7671            "blobs": [{
7672                "sha256": sha256,
7673                "bytes": bytes.len(),
7674                "kind": "staged_change",
7675            }],
7676        }),
7677        "stage the v2 change",
7678    )?;
7679    let items = reserved
7680        .get("uploads")
7681        .and_then(Value::as_array)
7682        .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7683    let [item] = items.as_slice() else {
7684        return Err(invalid_feed(
7685            "v2 change staging response changed the requested set",
7686        ));
7687    };
7688    let reservation_id = item
7689        .get("reservation_id")
7690        .and_then(Value::as_str)
7691        .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7692    if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7693        || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7694        || !crate::ulid::is_ulid(reservation_id)
7695    {
7696        return Err(invalid_feed("v2 change staging item is inconsistent"));
7697    }
7698    match item.get("status").and_then(Value::as_str) {
7699        Some("upload") => put_presigned(
7700            cfg,
7701            item.get("url")
7702                .and_then(Value::as_str)
7703                .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7704            item.get("headers").unwrap_or(&Value::Null),
7705            &bytes,
7706        )?,
7707        Some("already_present") => {}
7708        _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7709    }
7710    Ok(json!({
7711        "sha256": sha256,
7712        "bytes": bytes.len(),
7713        "reservation_id": reservation_id,
7714    }))
7715}
7716
7717/// Move a commit request's operations and blob references off the wire body
7718/// when they no longer fit in it. Idempotent by construction: the mutation ID
7719/// is derived from the change, never from how the change was transported.
7720fn stage_oversized_v2_change(
7721    cfg: &HubConfig,
7722    requested_brain: &str,
7723    operations: &[Value],
7724    body: &mut Value,
7725) -> LinkResult<()> {
7726    if body.to_string().len() <= MAX_PUSH_BYTES {
7727        return Ok(());
7728    }
7729    let staged = stage_v2_change(
7730        cfg,
7731        requested_brain,
7732        operations,
7733        body.get("blobs")
7734            .cloned()
7735            .unwrap_or(Value::Array(Vec::new())),
7736    )?;
7737    let map = body
7738        .as_object_mut()
7739        .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7740    map.remove("operations");
7741    map.remove("blobs");
7742    map.insert("staged_change".to_string(), staged);
7743    Ok(())
7744}
7745
7746fn v2_sync_push(
7747    cfg: &HubConfig,
7748    requested_brain: &str,
7749    store: &Store,
7750    head: V2VerifiedHead,
7751    options: V2SyncPushOptions<'_>,
7752) -> LinkResult<Value> {
7753    let V2SyncPushOptions {
7754        resume_local_policy,
7755        bulk_confirmation,
7756        resolution,
7757        pulled,
7758        withdrawal_paths,
7759        withdrawal_reason,
7760    } = options;
7761    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7762    let head = v2_verified_head(cfg, requested_brain)?
7763        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7764    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7765        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7766            Some(snapshot) => (
7767                snapshot.files,
7768                snapshot.assets,
7769                Some(snapshot.local),
7770                Some(snapshot.local_assets),
7771            ),
7772            None => (
7773                files_for_v2_view(
7774                    &head,
7775                    v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7776                ),
7777                v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7778                None,
7779                None,
7780            ),
7781        };
7782    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7783    ensure_v2_view_compatible(&head, baseline.as_ref())?;
7784    if head.view_kind == "scoped" && baseline.is_none() {
7785        return Err(LinkError::ScopedViewChanged);
7786    }
7787    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7788    let local = &local_view.riding;
7789    let local_assets = match carried_local_assets {
7790        Some(assets) => assets,
7791        None => v2_local_asset_records(store)?,
7792    };
7793    if withdrawal_paths.len() > MAX_PUSH_FILES {
7794        return Err(LinkError::PushTooLarge {
7795            detail: "too many explicit withdrawal paths".to_string(),
7796        });
7797    }
7798    let withdrawal_reason = if withdrawal_paths.is_empty() {
7799        None
7800    } else {
7801        let reason = withdrawal_reason
7802            .map(str::trim)
7803            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7804            .ok_or_else(|| LinkError::InvalidPack {
7805                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7806            })?;
7807        Some(reason)
7808    };
7809    let mut withdrawals = withdrawal_paths
7810        .iter()
7811        .map(|path| {
7812            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7813                path: error.to_string(),
7814            })
7815        })
7816        .collect::<LinkResult<Vec<_>>>()?;
7817    withdrawals.sort();
7818    withdrawals.dedup();
7819    if withdrawals.len() != withdrawal_paths.len() {
7820        return Err(LinkError::InvalidPack {
7821            message: "explicit withdrawal paths must be unique".to_string(),
7822        });
7823    }
7824    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7825    let mut consumed_withdrawals = BTreeSet::new();
7826    if let Some(previous) = baseline.as_ref() {
7827        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7828            && !resume_local_policy
7829        {
7830            let mut newly_eligible = previous
7831                .local_eligibility
7832                .iter()
7833                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7834                .map(|(path, _)| path.clone())
7835                .collect::<Vec<_>>();
7836            if !newly_eligible.is_empty() {
7837                newly_eligible.truncate(100);
7838                return Err(LinkError::LocalPolicyTransition {
7839                    paths: newly_eligible,
7840                });
7841            }
7842        }
7843    }
7844    let base = match baseline.as_ref() {
7845        Some(state) => &state.files,
7846        None if remote.is_empty() => &remote,
7847        None => {
7848            let mut conflicts = remote
7849                .iter()
7850                .filter(|(path, file)| {
7851                    local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7852                })
7853                .map(|(path, _)| path.clone())
7854                .collect::<Vec<_>>();
7855            if !conflicts.is_empty() {
7856                conflicts.truncate(100);
7857                let (bundle, paths) =
7858                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7859                return Err(LinkError::ConflictBundle { bundle, paths });
7860            }
7861            &remote
7862        }
7863    };
7864    let all_paths = base
7865        .keys()
7866        .chain(remote.keys())
7867        .chain(local.keys())
7868        .cloned()
7869        .collect::<std::collections::BTreeSet<_>>();
7870    let mut conflicts = Vec::new();
7871    let mut operations = Vec::new();
7872    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7873    for path in all_paths {
7874        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7875        let remote_file = remote.get(&path);
7876        let remote_hash = remote_file.map(|file| file.sha256.as_str());
7877        let local_file = local.get(&path);
7878        let local_hash = local_file.map(|file| file.0.as_str());
7879        if local_hash == base_hash {
7880            continue;
7881        }
7882        if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
7883            continue;
7884        }
7885        if local_view.policy.keeps_home(&path) {
7886            // Kept-home is a local transfer exclusion, never an implicit
7887            // delete of the company's already-hosted coordinate.
7888            continue;
7889        }
7890        if remote_hash != base_hash && local_hash != remote_hash {
7891            let explicitly_resolved = resolution
7892                .and_then(|allowed| allowed.get(&path))
7893                .is_some_and(|selected| {
7894                    selected.expected_remote.as_deref() == remote_hash
7895                        && selected.selected_local.as_deref() == local_hash
7896                });
7897            if !explicitly_resolved {
7898                conflicts.push(path);
7899                continue;
7900            }
7901        }
7902        match local_file {
7903            Some((sha256, byte_count)) => {
7904                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7905                operations.push(json!({
7906                    "op": "put",
7907                    "path": path,
7908                    "expected": v2_expected(remote_file),
7909                    "blob": sha256,
7910                    "bytes": byte_count,
7911                }));
7912                upload_sources
7913                    .entry(sha256.clone())
7914                    .or_insert_with(|| V2UploadSource {
7915                        path: path.clone(),
7916                        bytes: *byte_count,
7917                    });
7918            }
7919            None => {
7920                let Some(current) = remote_file else {
7921                    continue;
7922                };
7923                operations.push(json!({
7924                    "op": "delete",
7925                    "path": path,
7926                    "expected": { "kind": "blob", "hash": current.sha256 },
7927                }));
7928            }
7929        }
7930    }
7931    operations = infer_exact_source_promotions(operations);
7932    for path in &withdrawals {
7933        if local_assets.contains_key(path) {
7934            continue;
7935        }
7936        operations.push(v2_content_withdrawal_operation(
7937            store,
7938            &local_view,
7939            &remote,
7940            path,
7941            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7942        )?);
7943        consumed_withdrawals.insert(path.clone());
7944    }
7945    if !conflicts.is_empty() {
7946        conflicts.truncate(100);
7947        let (bundle, paths) = create_v2_conflict_bundle(
7948            cfg,
7949            store,
7950            &head,
7951            baseline.as_ref(),
7952            local,
7953            &remote,
7954            &conflicts,
7955        )?;
7956        return Err(LinkError::ConflictBundle { bundle, paths });
7957    }
7958    let base_assets = match baseline.as_ref() {
7959        Some(state) => &state.assets,
7960        None if remote_assets.is_empty() => &remote_assets,
7961        None => {
7962            let mismatched = remote_assets.iter().any(|(path, remote)| {
7963                local_assets.get(path) != Some(&v2_asset_record(remote, path))
7964            }) || local_assets.len() != remote_assets.len();
7965            if mismatched {
7966                return Err(LinkError::Conflict {
7967                    paths: vec!["assets.jsonl".to_string()],
7968                });
7969            }
7970            &remote_assets
7971        }
7972    };
7973    let asset_paths = base_assets
7974        .keys()
7975        .chain(remote_assets.keys())
7976        .chain(local_assets.keys())
7977        .cloned()
7978        .collect::<std::collections::BTreeSet<_>>();
7979    let mut asset_policy_transitions = Vec::new();
7980    for path in asset_paths {
7981        let base_record = base_assets
7982            .get(&path)
7983            .map(|asset| v2_asset_record(asset, &path));
7984        let remote = remote_assets.get(&path);
7985        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
7986        let local_record = local_assets.get(&path);
7987        if withdrawal_set.contains(&path) {
7988            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
7989                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
7990            })?;
7991            let current = remote.ok_or_else(|| LinkError::InvalidPack {
7992                message: format!(
7993                    "asset withdrawal path `{path}` has no readable hosted coordinate"
7994                ),
7995            })?;
7996            operations.push(v2_asset_withdrawal_operation(
7997                store,
7998                &local_view,
7999                &path,
8000                record,
8001                current,
8002                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8003            )?);
8004            consumed_withdrawals.insert(path.clone());
8005            continue;
8006        }
8007        let mut raw_present = false;
8008        let mut disposition = "withheld";
8009        let mut resumes_hosting = false;
8010        if let Some(record) = local_record {
8011            crate::linkmd_v2::normalize_path(&record.path)
8012                .map_err(|error| invalid_feed(error.to_string()))?;
8013            let kept_home = local_view.policy.keeps_home(&path);
8014            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8015            disposition = if kept_home || !raw_present {
8016                "withheld"
8017            } else {
8018                "hosted"
8019            };
8020            if !raw_present && record.required && !kept_home {
8021                return Err(LinkError::InvalidPack {
8022                    message: format!("required asset {path} is missing"),
8023                });
8024            }
8025            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8026        }
8027        if local_record == base_record.as_ref() && !resumes_hosting {
8028            continue;
8029        }
8030        if remote_record != base_record && local_record != remote_record.as_ref() {
8031            conflicts.push(path);
8032            continue;
8033        }
8034        let Some(record) = local_record else {
8035            if let Some(remote) = remote {
8036                operations.push(json!({
8037                    "op": "asset_delete",
8038                    "path": path,
8039                    "expected": v2_asset_expected(Some(remote)),
8040                }));
8041            }
8042            continue;
8043        };
8044        let raw = if raw_present {
8045            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8046            Some(())
8047        } else {
8048            None
8049        };
8050        let op = if resumes_hosting {
8051            if !resume_local_policy {
8052                asset_policy_transitions.push(path);
8053                continue;
8054            }
8055            "asset_resume"
8056        } else {
8057            "asset_put"
8058        };
8059        operations.push(json!({
8060            "op": op,
8061            "path": path,
8062            "expected": v2_asset_expected(remote),
8063            "asset": v2_asset_value(record, disposition),
8064        }));
8065        if disposition == "hosted" {
8066            raw.expect("hosted asset was checked present");
8067            upload_sources
8068                .entry(record.sha256.clone())
8069                .or_insert_with(|| V2UploadSource {
8070                    path: path.clone(),
8071                    bytes: record.bytes,
8072                });
8073        }
8074    }
8075    if consumed_withdrawals != withdrawal_set {
8076        let missing = withdrawal_set
8077            .difference(&consumed_withdrawals)
8078            .next()
8079            .expect("different withdrawal sets have one member");
8080        return Err(LinkError::InvalidPack {
8081            message: format!(
8082                "withdrawal path `{missing}` is not a readable content or asset coordinate"
8083            ),
8084        });
8085    }
8086    if !conflicts.is_empty() {
8087        conflicts.truncate(100);
8088        return Err(LinkError::Conflict { paths: conflicts });
8089    }
8090    if !asset_policy_transitions.is_empty() {
8091        asset_policy_transitions.truncate(100);
8092        return Err(LinkError::LocalPolicyTransition {
8093            paths: asset_policy_transitions,
8094        });
8095    }
8096    let touched_sources = operations
8097        .iter()
8098        .filter_map(
8099            |operation| match operation.get("op").and_then(Value::as_str) {
8100                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
8101                Some("rename") => operation.get("to").and_then(Value::as_str),
8102                _ => None,
8103            },
8104        )
8105        .collect::<std::collections::BTreeSet<_>>();
8106    let withheld_links = local_view
8107        .withheld_links
8108        .iter()
8109        .filter(|link| touched_sources.contains(link.source.as_str()))
8110        .collect::<Vec<_>>();
8111    let checkout_pseudonym = v2_checkout_id(
8112        baseline
8113            .as_ref()
8114            .and_then(|current| current.checkout_id.as_deref()),
8115    )?;
8116    let checkout_id = if withheld_links.is_empty() {
8117        None
8118    } else {
8119        Some(checkout_pseudonym.clone())
8120    };
8121    if operations.is_empty() {
8122        let final_head = v2_verified_head(cfg, requested_brain)?
8123            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8124        if !same_v2_head(&head, &final_head) {
8125            return Err(LinkError::RemoteAdvancedDuringSync);
8126        }
8127        let mut final_local = v2_local_files(store)?;
8128        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8129        let final_assets = v2_local_asset_records(store)?;
8130        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8131        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8132            final_local.policy.keeps_home(path)
8133        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8134        let next = v2_baseline_from_head(
8135            cfg,
8136            &head,
8137            remote,
8138            remote_assets,
8139            Some(&final_local),
8140            Some(&checkout_pseudonym),
8141        )?;
8142        let split_count = next.remote_copy_remains.len();
8143        accept_v2_head(cfg, &final_head)?;
8144        if !local_changed && !remote_ahead {
8145            refresh_scoped_view_marker(store, &head, next.files.len())?;
8146            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8147        }
8148        return Ok(json!({
8149            "v": 2,
8150            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8151            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8152            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8153            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8154            "local_policy": {
8155                "remote_copy_remains": split_count,
8156            },
8157        }));
8158    }
8159    let includes_contract = operations
8160        .iter()
8161        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8162    let rebase = if head.pointer.is_none() || includes_contract {
8163        "strict"
8164    } else {
8165        "disjoint"
8166    };
8167    let base_value = head.pointer.as_ref().map(|pointer| {
8168        json!({
8169            "seq": pointer.seq,
8170            "commit_hash": pointer.commit_hash,
8171            "content_root": pointer.content_root,
8172            "asset_root": pointer.asset_root,
8173        })
8174    });
8175    // The mutation coordinate is stable for the exact observed base and local
8176    // operation set. A lost response can therefore be retried without a
8177    // duplicate commit/proposal even across process restarts.
8178    let entropy = format!(
8179        "{}\0{}\0{}\0{}\0{}\0{}",
8180        normalized_origin(&cfg.hub)?,
8181        head.brain_id,
8182        serde_json::to_string(&base_value).unwrap_or_default(),
8183        serde_json::to_string(&operations).unwrap_or_default(),
8184        serde_json::to_string(&withheld_links).unwrap_or_default(),
8185        checkout_id.as_deref().unwrap_or("")
8186    );
8187    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8188    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8189        total
8190            .checked_add(source.bytes)
8191            .ok_or_else(|| LinkError::PushTooLarge {
8192                detail: "v2 changed-byte total overflow".to_string(),
8193            })
8194    })?;
8195    let inline = changed_bytes <= 3 * 1024 * 1024;
8196    let inline_blobs = if inline {
8197        upload_sources
8198            .iter()
8199            .map(|(sha256, source)| {
8200                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8201                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8202                    return Err(LinkError::InvalidPack {
8203                        message: format!("local path `{}` changed before upload", source.path),
8204                    });
8205                }
8206                Ok(json!({
8207                    "sha256": sha256,
8208                    "bytes": source.bytes,
8209                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8210                }))
8211            })
8212            .collect::<LinkResult<Vec<_>>>()?
8213    } else {
8214        Vec::new()
8215    };
8216    let mut body = json!({
8217        "mutation_id": mutation_id,
8218        "base": base_value,
8219        "rebase": rebase,
8220        "reason": "dbmd sync",
8221        "operations": operations,
8222        "blobs": inline_blobs,
8223    });
8224    if !withheld_links.is_empty() {
8225        body["withheld_links"] = serde_json::to_value(&withheld_links)
8226            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8227        body["checkout_id"] =
8228            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8229    }
8230    if let Some(confirmation) = bulk_confirmation {
8231        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8232            return Err(LinkError::InvalidPack {
8233                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8234                    .to_string(),
8235            });
8236        }
8237        // A preview is bound to the exact observed head. Confirmation can
8238        // therefore never use disjoint rebase semantics, even when the normal
8239        // small-mutation path could.
8240        body["rebase"] = Value::String("strict".to_string());
8241        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8242        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8243    }
8244    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8245        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8246        for operation in &operations {
8247            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8248                return Err(invalid_feed("v2 upload operation has no kind"));
8249            };
8250            let hash = match kind {
8251                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8252                "asset_put" | "asset_resume" => operation
8253                    .get("asset")
8254                    .and_then(|asset| asset.get("blob_sha256"))
8255                    .and_then(Value::as_str),
8256                _ => None,
8257            };
8258            let Some(hash) = hash else { continue };
8259            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8260            if kind == "rename" {
8261                for field in ["from", "to"] {
8262                    coordinates.insert(
8263                        operation
8264                            .get(field)
8265                            .and_then(Value::as_str)
8266                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8267                            .to_string(),
8268                    );
8269                }
8270            } else {
8271                let path = operation
8272                    .get("path")
8273                    .and_then(Value::as_str)
8274                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8275                coordinates.insert(if kind.starts_with("asset_") {
8276                    format!("assets/{path}")
8277                } else {
8278                    path.to_string()
8279                });
8280            }
8281        }
8282        let declarations = upload_sources
8283            .iter()
8284            .map(|(sha256, source)| {
8285                json!({
8286                    "sha256": sha256,
8287                    "bytes": source.bytes,
8288                    "coordinates": coordinates_by_hash
8289                        .get(sha256)
8290                        .into_iter()
8291                        .flatten()
8292                        .collect::<Vec<_>>(),
8293                })
8294            })
8295            .collect::<Vec<_>>();
8296        let mut references = Vec::with_capacity(upload_sources.len());
8297        let mut seen = std::collections::BTreeSet::new();
8298        let mut reserved_count = 0usize;
8299        let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8300        // A reservation's presigned URL is short-lived, so each batch's bytes
8301        // ride before the next batch is reserved. Reserving every blob first
8302        // and uploading afterwards expires the earliest URLs on a real store.
8303        for batch in batch_upload_declarations(declarations) {
8304            let batch_len = batch.len();
8305            let reserved = reserve_upload_window(
8306                cfg,
8307                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8308                &json!({ "blobs": batch }),
8309                "prepare v2 changed-byte uploads",
8310            )?;
8311            let items = reserved
8312                .get("uploads")
8313                .and_then(Value::as_array)
8314                .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8315            if items.len() != batch_len {
8316                return Err(invalid_feed(
8317                    "v2 upload reservation response changed the requested set",
8318                ));
8319            }
8320            reserved_count += items.len();
8321            for item in items {
8322                let sha256 = item
8323                    .get("sha256")
8324                    .and_then(Value::as_str)
8325                    .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8326                let source = upload_sources
8327                    .get(sha256)
8328                    .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8329                let declared_bytes = item
8330                    .get("bytes")
8331                    .and_then(Value::as_u64)
8332                    .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8333                let reservation_id = item
8334                    .get("reservation_id")
8335                    .and_then(Value::as_str)
8336                    .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8337                let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8338                    invalid_feed("v2 upload reservation has no coordinate binding")
8339                })?;
8340                let returned_coordinates = item
8341                    .get("coordinates")
8342                    .and_then(Value::as_array)
8343                    .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8344                if declared_bytes != source.bytes
8345                    || !crate::ulid::is_ulid(reservation_id)
8346                    || !seen.insert(sha256.to_string())
8347                    || returned_coordinates.len() != expected_coordinates.len()
8348                    || returned_coordinates
8349                        .iter()
8350                        .zip(expected_coordinates)
8351                        .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8352                {
8353                    return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8354                }
8355                match item.get("status").and_then(Value::as_str) {
8356                    Some("upload") => {
8357                        let url = item
8358                            .get("url")
8359                            .and_then(Value::as_str)
8360                            .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8361                        pending_uploads.push(V2PendingUpload {
8362                            url: url.to_string(),
8363                            headers: item.get("headers").cloned().unwrap_or(Value::Null),
8364                            sha256: sha256.to_string(),
8365                            source,
8366                        });
8367                    }
8368                    Some("already_present") => {}
8369                    _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8370                }
8371                references.push(json!({
8372                    "sha256": sha256,
8373                    "bytes": source.bytes,
8374                    "reservation_id": reservation_id,
8375                }));
8376            }
8377            // Stage this batch's bytes concurrently. Sequential PUTs move a real
8378            // store at a few objects a second, which both takes hours and pushes
8379            // the batch past its reservation's upload window; the object store
8380            // is content-addressed and precondition-guarded, so parallel writes
8381            // stay safe.
8382            upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8383            pending_uploads.clear();
8384        }
8385        if reserved_count != upload_sources.len() {
8386            return Err(invalid_feed(
8387                "v2 upload reservation response changed the requested set",
8388            ));
8389        }
8390        body["blobs"] = Value::Array(references);
8391    }
8392    stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8393    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8394    let mut candidate_hub_signer: Option<String> = None;
8395    let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8396    let bulk_preview_required = !(200..300).contains(&response.status)
8397        && response.body.as_ref().is_some_and(|value| {
8398            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8399                || value
8400                    .get("details")
8401                    .and_then(|details| details.get("code"))
8402                    .and_then(Value::as_str)
8403                    == Some("bulk_preview_required")
8404        });
8405    if bulk_preview_required && bulk_confirmation.is_none() {
8406        body["rebase"] = Value::String("strict".to_string());
8407        body["preview_only"] = Value::Bool(true);
8408        let preview = ensure_ok(
8409            request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8410            "v2 bulk preview",
8411        )?;
8412        let preview_code = preview.get("code").and_then(Value::as_str);
8413        let required = preview.get("required").and_then(Value::as_bool);
8414        if preview.get("v").and_then(Value::as_u64) != Some(2)
8415            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8416            || !matches!(
8417                preview_code,
8418                Some("bulk_preview_created" | "bulk_preview_not_required")
8419            )
8420            || required.is_none()
8421        {
8422            return Err(invalid_feed(
8423                "bulk preview response is not bound to the requested mutation",
8424            ));
8425        }
8426        if required == Some(true) {
8427            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8428            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8429            if preview_code != Some("bulk_preview_created")
8430                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8431                || preview_digest.is_none_or(|value| !is_sha256(value))
8432                || preview.get("expires_at").and_then(Value::as_str).is_none()
8433                || !preview.get("impact").is_some_and(Value::is_object)
8434            {
8435                return Err(invalid_feed("bulk preview receipt is malformed"));
8436            }
8437            return Err(LinkError::BulkPreviewRequired { preview });
8438        }
8439        if preview_code != Some("bulk_preview_not_required") {
8440            return Err(invalid_feed("bulk preview requirement is inconsistent"));
8441        }
8442        // The rolling activity window can expire between the refusal and
8443        // preview. Retry once, still pinned strictly to the observed head.
8444        body.as_object_mut()
8445            .expect("v2 commit request is an object")
8446            .remove("preview_only");
8447        response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8448    }
8449    let mut result = ensure_ok(response, "v2 sync push")?;
8450    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8451        if let Some(object) = result.as_object_mut() {
8452            object.insert(
8453                "sync_status".to_string(),
8454                Value::String("proposal_pending".to_string()),
8455            );
8456        }
8457        return Ok(result);
8458    }
8459    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8460        let request_id = result
8461            .get("request_id")
8462            .and_then(Value::as_str)
8463            .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8464            .to_string();
8465        let challenge = result
8466            .get("signing_challenge")
8467            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8468        let mut expected_candidate = remote.clone();
8469        let mut expected_candidate_assets = remote_assets.clone();
8470        apply_generated_v2_operations(
8471            &operations,
8472            &local_assets,
8473            &mut expected_candidate,
8474            &mut expected_candidate_assets,
8475        )?;
8476        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8477            cfg,
8478            &head,
8479            &expected_candidate,
8480            &expected_candidate_assets,
8481            &mutation_id,
8482            &v2_signed_request_view(&body, &operations),
8483            challenge,
8484        )?;
8485        body["signing_challenge_id"] = Value::String(challenge_id);
8486        body["signature_base64url"] = Value::String(signature);
8487        candidate_hub_signer = Some(actor_signer);
8488        result = ensure_ok(
8489            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8490            "v2 self-custody commit",
8491        )?;
8492    }
8493    let refreshed = v2_verified_head(cfg, requested_brain)?
8494        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8495    if candidate_hub_signer
8496        .as_ref()
8497        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8498    {
8499        return Err(invalid_feed(
8500            "self-custody actor signer differs from the committed hub pointer signer",
8501        ));
8502    }
8503    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8504    if refreshed
8505        .pointer
8506        .as_ref()
8507        .map(|pointer| pointer.commit_hash.as_str())
8508        != accepted_hash
8509    {
8510        return Err(LinkError::RemoteAdvancedDuringSync);
8511    }
8512    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8513    let rebased = result
8514        .get("rebased")
8515        .and_then(Value::as_bool)
8516        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8517    let (refreshed_files, refreshed_assets) = if rebased {
8518        (
8519            files_for_v2_view(
8520                &refreshed,
8521                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8522            ),
8523            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8524        )
8525    } else {
8526        let asset_changed = apply_generated_v2_operations(
8527            &operations,
8528            &local_assets,
8529            &mut remote,
8530            &mut remote_assets,
8531        )?;
8532        let assets = if asset_changed {
8533            // Asset leaf hashes are server-generated signed coordinates; only
8534            // asset-changing commits need this usually tiny post-commit read.
8535            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8536        } else {
8537            remote_assets
8538        };
8539        (remote, assets)
8540    };
8541    let mut final_local = v2_local_files(store)?;
8542    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8543    let final_assets = v2_local_asset_records(store)?;
8544    let local_dirty = final_local.riding != local_view.riding
8545        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8546            final_local.policy.keeps_home(path)
8547        })
8548        || final_assets != local_assets
8549        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8550    let next = v2_baseline_from_head(
8551        cfg,
8552        &refreshed,
8553        refreshed_files,
8554        refreshed_assets,
8555        Some(&final_local),
8556        Some(&checkout_pseudonym),
8557    )?;
8558    let split_count = next.remote_copy_remains.len();
8559    accept_v2_head(cfg, &refreshed)?;
8560    if !local_dirty {
8561        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8562        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8563    }
8564    if let Some(object) = result.as_object_mut() {
8565        object.insert(
8566            "local_policy".to_string(),
8567            json!({ "remote_copy_remains": split_count }),
8568        );
8569        object.insert(
8570            "sync_status".to_string(),
8571            Value::String(if local_dirty {
8572                "remote_committed_local_dirty".to_string()
8573            } else {
8574                "synced".to_string()
8575            }),
8576        );
8577    }
8578    Ok(result)
8579}
8580
8581/// Negotiate v2 and send only local changes. A v1 hub retains the existing
8582/// whole-snapshot behavior until its brain advertises the new profile.
8583pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8584    sync_push_incremental_with_policy(cfg, brain, store, false)
8585}
8586
8587/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
8588/// path that became eligible because local policy was weakened or removed.
8589pub fn sync_push_incremental_with_policy(
8590    cfg: &HubConfig,
8591    brain: &str,
8592    store: &Store,
8593    resume_local_policy: bool,
8594) -> LinkResult<Value> {
8595    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8596}
8597
8598/// Incremental push with both local-policy adoption and an optional exact
8599/// permissioned bulk-preview confirmation.
8600pub fn sync_push_incremental_with_options(
8601    cfg: &HubConfig,
8602    brain: &str,
8603    store: &Store,
8604    resume_local_policy: bool,
8605    bulk_confirmation: Option<&V2BulkConfirmation>,
8606) -> LinkResult<Value> {
8607    sync_push_incremental_with_controls(
8608        cfg,
8609        brain,
8610        store,
8611        resume_local_policy,
8612        bulk_confirmation,
8613        &[],
8614        None,
8615    )
8616}
8617
8618/// Incremental push with explicit permissioned current-host withdrawal.
8619pub fn sync_push_incremental_with_controls(
8620    cfg: &HubConfig,
8621    brain: &str,
8622    store: &Store,
8623    resume_local_policy: bool,
8624    bulk_confirmation: Option<&V2BulkConfirmation>,
8625    withdrawal_paths: &[String],
8626    withdrawal_reason: Option<&str>,
8627) -> LinkResult<Value> {
8628    require_safe_ref(brain)?;
8629    if let Some(head) = v2_verified_head(cfg, brain)? {
8630        return v2_sync_push(
8631            cfg,
8632            brain,
8633            store,
8634            head,
8635            V2SyncPushOptions {
8636                resume_local_policy,
8637                bulk_confirmation,
8638                resolution: None,
8639                pulled: None,
8640                withdrawal_paths,
8641                withdrawal_reason,
8642            },
8643        );
8644    }
8645    if !withdrawal_paths.is_empty() {
8646        return Err(LinkError::InvalidPack {
8647            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8648        });
8649    }
8650    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8651}
8652
8653/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
8654/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
8655/// a damaged `DB.md` makes strict store-open fail.
8656pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8657    require_safe_ref(brain)?;
8658    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8659}
8660
8661#[cfg(windows)]
8662fn legacy_sync_push_incremental(
8663    _cfg: &HubConfig,
8664    _brain: &str,
8665    _store: &Store,
8666    _resume_local_policy: bool,
8667    _bulk_confirmation: Option<&V2BulkConfirmation>,
8668) -> LinkResult<Value> {
8669    Err(LinkError::UnsupportedPlatform {
8670        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8671    })
8672}
8673
8674#[cfg(not(windows))]
8675fn legacy_sync_push_incremental(
8676    cfg: &HubConfig,
8677    brain: &str,
8678    store: &Store,
8679    resume_local_policy: bool,
8680    bulk_confirmation: Option<&V2BulkConfirmation>,
8681) -> LinkResult<Value> {
8682    if resume_local_policy || bulk_confirmation.is_some() {
8683        return Err(LinkError::InvalidPack {
8684            message: "v2 sync options require a link.md v2 brain".to_string(),
8685        });
8686    }
8687    let files = collect_push_files(store)?;
8688    sync_push(cfg, brain, &files)
8689}
8690
8691/// One explicit resolution for a previously preserved private conflict bundle.
8692#[derive(Debug, Clone)]
8693pub enum V2ConflictChoice {
8694    KeepLocal,
8695    TakeRemote,
8696    From(PathBuf),
8697}
8698
8699fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8700    if !crate::ulid::is_ulid(bundle) {
8701        return Err(LinkError::InvalidPack {
8702            message: "conflict bundle must be a lowercase ULID".to_string(),
8703        });
8704    }
8705    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8706    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8707        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8708    if plan.v != 2
8709        || plan.class != "content_resolution_required"
8710        || plan.bundle != bundle
8711        || !crate::ulid::is_ulid(&plan.brain)
8712        || plan.files.is_empty()
8713        || plan.files.len() > 100
8714        || plan.files.iter().any(|file| {
8715            crate::linkmd_v2::normalize_path(&file.path).is_err()
8716                || [&file.base, &file.local, &file.remote]
8717                    .into_iter()
8718                    .any(|coordinate| {
8719                        coordinate
8720                            .sha256
8721                            .as_deref()
8722                            .is_some_and(|hash| !is_sha256(hash))
8723                            || coordinate.file.as_deref().is_some_and(|name| {
8724                                name.starts_with('/')
8725                                    || name
8726                                        .split('/')
8727                                        .any(|part| part.is_empty() || part == "." || part == "..")
8728                            })
8729                    })
8730        })
8731    {
8732        return Err(invalid_feed("private conflict plan failed validation"));
8733    }
8734    Ok(plan)
8735}
8736
8737/// Inspect or prune private conflict-control state. Ordinary pruning removes
8738/// only expired completed bundles and interrupted bundles with no `plan.json`;
8739/// corrupt completed plans fail closed. `all` is an explicit local discard of
8740/// every well-addressed bundle and never changes hosted brain data.
8741pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8742    require_hardened_filesystem("private conflict maintenance")?;
8743    if all && !prune {
8744        return Err(LinkError::InvalidPack {
8745            message: "discarding all conflict bundles requires prune=true".to_string(),
8746        });
8747    }
8748    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8749        message: format!("conflict checkout is not a valid db.md store: {error}"),
8750    })?;
8751    let _transaction = store.transaction()?;
8752    let root = Path::new(".dbmd/conflicts");
8753    let names = match store.directory_names(root) {
8754        Ok(names) => names,
8755        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8756        Err(error) => return Err(error.into()),
8757    };
8758    let now = SystemTime::now()
8759        .duration_since(UNIX_EPOCH)
8760        .unwrap_or_default()
8761        .as_secs();
8762    let mut bundles = Vec::new();
8763    let mut pruned = 0_u64;
8764    for name in names {
8765        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8766            continue;
8767        };
8768        let plan_path = v2_conflict_relative(bundle, "plan.json");
8769        let plan_exists = store.regular_file_exists(&plan_path)?;
8770        let expired = if plan_exists {
8771            match load_v2_conflict_plan(&store, bundle) {
8772                Ok(plan) => plan.expires_unix < now,
8773                Err(error) if all => {
8774                    let _ = error;
8775                    true
8776                }
8777                Err(error) => return Err(error),
8778            }
8779        } else {
8780            true
8781        };
8782        if prune && (all || expired) {
8783            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8784            pruned += 1;
8785            continue;
8786        }
8787        bundles.push(json!({
8788            "bundle": bundle,
8789            "complete": plan_exists,
8790            "expired": expired,
8791        }));
8792    }
8793    Ok(json!({
8794        "v": 2,
8795        "class": "private_conflict_state",
8796        "bundles": bundles.len(),
8797        "pruned": pruned,
8798        "items": bundles,
8799    }))
8800}
8801
8802/// Resolve one exact private conflict plan. Drift never lowers a precondition:
8803/// the remote head/view and every original local coordinate are rechecked
8804/// before either an explicit local install or a fresh normal commit.
8805pub fn sync_resolve_conflict(
8806    cfg: &HubConfig,
8807    checkout: &Path,
8808    bundle: &str,
8809    choice: V2ConflictChoice,
8810    bulk_confirmation: Option<&V2BulkConfirmation>,
8811) -> LinkResult<Value> {
8812    require_hardened_filesystem("conflict resolution")?;
8813    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8814        message: format!("conflict checkout is not a valid db.md store: {error}"),
8815    })?;
8816    let plan = load_v2_conflict_plan(&store, bundle)?;
8817    if plan.origin != normalized_origin(&cfg.hub)? {
8818        return Err(invalid_feed(
8819            "conflict bundle belongs to another hub origin",
8820        ));
8821    }
8822    let now = SystemTime::now()
8823        .duration_since(UNIX_EPOCH)
8824        .unwrap_or_default()
8825        .as_secs();
8826    if now > plan.expires_unix {
8827        return Err(LinkError::InvalidPack {
8828            message: "conflict bundle expired; rerun sync to obtain current coordinates"
8829                .to_string(),
8830        });
8831    }
8832    let head = v2_verified_head(cfg, &plan.brain)?
8833        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8834    let pointer = head.pointer.as_ref();
8835    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8836        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8837        || pointer.and_then(|value| value.content_root.as_deref())
8838            != plan.remote_content_root.as_deref()
8839        || head.view_kind != plan.view_kind
8840        || head.view_revision != plan.view_revision
8841    {
8842        return Err(LinkError::RemoteAdvancedDuringSync);
8843    }
8844
8845    // A resolution never overwrites a post-bundle local edit.
8846    for file in &plan.files {
8847        let actual = match store.regular_file_exists(Path::new(&file.path))? {
8848            true => Some(content_sha256(&store.read_bounded(
8849                Path::new(&file.path),
8850                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8851            )?)),
8852            false => None,
8853        };
8854        if actual.as_deref() != file.local.sha256.as_deref() {
8855            return Err(LinkError::InvalidPack {
8856                message: format!(
8857                    "local conflict path `{}` changed after the bundle was created",
8858                    file.path
8859                ),
8860            });
8861        }
8862    }
8863
8864    let from_source = match &choice {
8865        V2ConflictChoice::From(source) => Some(source.clone()),
8866        _ => None,
8867    };
8868    let result = match choice {
8869        V2ConflictChoice::TakeRemote => {
8870            if bulk_confirmation.is_some() {
8871                return Err(LinkError::InvalidPack {
8872                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8873                });
8874            }
8875            // Re-read the exact current manifest so every selected remote byte
8876            // retains its signed inclusion proof. The private bundle is useful
8877            // for inspection, but is never promoted into live state as trust.
8878            let current_remote =
8879                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8880            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
8881            let selected = plan
8882                .files
8883                .iter()
8884                .map(|file| file.path.clone())
8885                .collect::<std::collections::BTreeSet<_>>();
8886            serde_json::to_value(
8887                v2_sync_pull_with_resolution(
8888                    cfg,
8889                    &plan.brain,
8890                    head,
8891                    Some(checkout),
8892                    Some(&selected),
8893                )?
8894                .report,
8895            )
8896            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8897        }
8898        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8899            if let Some(source) = from_source.as_ref() {
8900                if plan.files.len() != 1 {
8901                    return Err(LinkError::InvalidPack {
8902                        message: "--from requires a bundle with exactly one conflict".to_string(),
8903                    });
8904                }
8905                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8906                if std::str::from_utf8(&candidate).is_err() {
8907                    return Err(LinkError::NotUtf8 {
8908                        path: source.display().to_string(),
8909                    });
8910                }
8911                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8912            }
8913            let refreshed_store =
8914                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8915                    message: format!("resolved checkout is not a valid db.md store: {error}"),
8916                })?;
8917            let mut overrides = std::collections::BTreeMap::new();
8918            for file in &plan.files {
8919                let selected_local = match refreshed_store
8920                    .regular_file_exists(Path::new(&file.path))?
8921                {
8922                    true => Some(content_sha256(
8923                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8924                    )),
8925                    false => None,
8926                };
8927                overrides.insert(
8928                    file.path.clone(),
8929                    V2ResolutionOverride {
8930                        expected_remote: file.remote.sha256.clone(),
8931                        selected_local,
8932                    },
8933                );
8934            }
8935            v2_sync_push(
8936                cfg,
8937                &plan.brain,
8938                &refreshed_store,
8939                head,
8940                V2SyncPushOptions {
8941                    resume_local_policy: true,
8942                    bulk_confirmation,
8943                    resolution: Some(&overrides),
8944                    pulled: None,
8945                    withdrawal_paths: &[],
8946                    withdrawal_reason: None,
8947                },
8948            )?
8949        }
8950    };
8951
8952    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
8953        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8954            message: format!("resolved checkout is not a valid db.md store: {error}"),
8955        })?;
8956        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8957    }
8958    Ok(json!({
8959        "v": 2,
8960        "class": "auto_converged",
8961        "bundle": bundle,
8962        "receipt": result,
8963    }))
8964}
8965
8966/// Converge one established permissioned-v2 checkout in both directions.
8967///
8968/// The pull half first performs the ordinary three-way conflict check and
8969/// crash-safe install against the accepted baseline. The push half then opens
8970/// that exact installed checkout, takes its local transaction guard, computes
8971/// a fresh three-way delta, and submits it with mandatory preconditions. A
8972/// remote movement between halves is therefore a normal rebase/conflict input,
8973/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
8974/// its whole-pack replacement semantics are never presented as granular
8975/// convergence.
8976pub fn sync_converge(
8977    cfg: &HubConfig,
8978    brain: &str,
8979    checkout: &Path,
8980    resume_local_policy: bool,
8981) -> LinkResult<Value> {
8982    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
8983}
8984
8985/// Bidirectional convergence with optional exact bulk-preview confirmation.
8986pub fn sync_converge_with_options(
8987    cfg: &HubConfig,
8988    brain: &str,
8989    checkout: &Path,
8990    resume_local_policy: bool,
8991    bulk_confirmation: Option<&V2BulkConfirmation>,
8992) -> LinkResult<Value> {
8993    sync_converge_with_controls(
8994        cfg,
8995        brain,
8996        checkout,
8997        resume_local_policy,
8998        bulk_confirmation,
8999        &[],
9000        None,
9001    )
9002}
9003
9004/// Bidirectional convergence with optional exact withdrawal intents.
9005pub fn sync_converge_with_controls(
9006    cfg: &HubConfig,
9007    brain: &str,
9008    checkout: &Path,
9009    resume_local_policy: bool,
9010    bulk_confirmation: Option<&V2BulkConfirmation>,
9011    withdrawal_paths: &[String],
9012    withdrawal_reason: Option<&str>,
9013) -> LinkResult<Value> {
9014    require_hardened_filesystem("bidirectional sync")?;
9015    require_safe_ref(brain)?;
9016    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9017        message:
9018            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9019                .to_string(),
9020    })?;
9021    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9022    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9023        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9024    })?;
9025    let _transaction = store.transaction()?;
9026    let pulled_report = pulled.report.clone();
9027    let pulled_head = pulled.head.clone();
9028    let mut result = v2_sync_push(
9029        cfg,
9030        brain,
9031        &store,
9032        pulled_head,
9033        V2SyncPushOptions {
9034            resume_local_policy,
9035            bulk_confirmation,
9036            resolution: None,
9037            pulled: Some(pulled),
9038            withdrawal_paths,
9039            withdrawal_reason,
9040        },
9041    )?;
9042    if let Some(object) = result.as_object_mut() {
9043        object.insert("pulled_files".to_string(), json!(pulled_report.files));
9044        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9045        object.insert(
9046            "mode".to_string(),
9047            Value::String("bidirectional".to_string()),
9048        );
9049    }
9050    Ok(result)
9051}
9052
9053/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
9054/// exported path is safety-gated before it touches disk; files are written
9055/// atomically; nothing local is ever deleted (locals the export lacks are
9056/// *reported* in `extra_local` instead). Returns the report; rebuilding the
9057/// local index catalog afterwards is the caller's (cheap, optional) step.
9058pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9059    require_hardened_filesystem("sync pull")?;
9060    require_safe_ref(brain)?;
9061    if let Some(head) = v2_verified_head(cfg, brain)? {
9062        return v2_sync_pull(cfg, brain, head, out);
9063    }
9064    legacy_sync_pull(cfg, brain, out)
9065}
9066
9067#[cfg(windows)]
9068fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9069    Err(LinkError::UnsupportedPlatform {
9070        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9071    })
9072}
9073
9074#[cfg(not(windows))]
9075fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9076    let remote = verified_remote_head(cfg, brain, false)?;
9077    if !remote.head.verified {
9078        return Err(invalid_feed(
9079            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9080        ));
9081    }
9082    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9083    let path = format!(
9084        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9085        remote.head.seq
9086    );
9087    let body = ensure_ok(
9088        request(cfg, "GET", &path, None, Auth::Required)?,
9089        "sync pull",
9090    )?;
9091    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9092        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9093    {
9094        return Err(invalid_feed(
9095            "export response is not bound to the verified snapshot token",
9096        ));
9097    }
9098
9099    let remote_slug = body
9100        .get("slug")
9101        .and_then(Value::as_str)
9102        .filter(|slug| is_safe_slug(slug));
9103    let slug = remote_slug
9104        .or_else(|| is_safe_slug(brain).then_some(brain))
9105        .unwrap_or("brain")
9106        .to_string();
9107    let brain_id = body
9108        .get("brain")
9109        .and_then(Value::as_str)
9110        .unwrap_or(&remote.head.brain)
9111        .to_string();
9112    if brain_id != remote.head.brain {
9113        return Err(invalid_feed(
9114            "export response names a different brain than the verified head",
9115        ));
9116    }
9117    let head_seq = remote.head.seq;
9118    let dest: PathBuf = match out {
9119        Some(p) => p.to_path_buf(),
9120        None => PathBuf::from(&slug),
9121    };
9122    let entries = if head_seq == 0 {
9123        let files = body
9124            .get("files")
9125            .and_then(Value::as_array)
9126            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
9127        if !files.is_empty() || body.get("url").is_some() {
9128            return Err(invalid_feed(
9129                "empty signed feed cannot authorize non-empty exported content",
9130            ));
9131        }
9132        Vec::new()
9133    } else {
9134        let signed_head = remote
9135            .head_entry
9136            .as_ref()
9137            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
9138        let expected = &signed_head.entry.pack_sha256;
9139        if !is_sha256(expected) {
9140            return Err(invalid_feed(
9141                "signed head carries an invalid snapshot pack digest",
9142            ));
9143        }
9144        if let Some(url) = body.get("url").and_then(Value::as_str) {
9145            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
9146                return Err(invalid_feed(
9147                    "export pack digest does not match the signed head entry",
9148                ));
9149            }
9150            let bytes = get_presigned(cfg, url)?;
9151            let actual = format!("{:x}", Sha256::digest(&bytes));
9152            if actual != *expected {
9153                return Err(LinkError::InvalidPack {
9154                    message: "downloaded pack does not match the signed snapshot digest"
9155                        .to_string(),
9156                });
9157            }
9158            let entries = parse_store_pack(bytes)?;
9159            if signed_head.entry.kind == "push" {
9160                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9161            }
9162            entries
9163        } else {
9164            if signed_head.entry.kind != "push" {
9165                return Err(invalid_feed(
9166                    "delta snapshots must export the exact signed pack",
9167                ));
9168            }
9169            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
9170                invalid_feed("verified snapshot export carried neither a pack nor files")
9171            })?;
9172            let mut entries = Vec::with_capacity(files.len());
9173            for file in files {
9174                let path = file
9175                    .get("path")
9176                    .and_then(Value::as_str)
9177                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
9178                let content = file
9179                    .get("content")
9180                    .and_then(Value::as_str)
9181                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
9182                entries.push((path.to_string(), content.as_bytes().to_vec()));
9183            }
9184            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9185            entries
9186        }
9187    };
9188
9189    // Gate the complete manifest before the first filesystem mutation.
9190    let mut seen = std::collections::HashSet::new();
9191    for (path, _) in &entries {
9192        if !safe_store_rel_path(path) {
9193            return Err(LinkError::UnsafePath { path: path.clone() });
9194        }
9195        if !seen.insert(path) {
9196            return Err(LinkError::InvalidPack {
9197                message: format!("duplicate path `{path}`"),
9198            });
9199        }
9200    }
9201    // Compute divergence against the still-live tree. The staged clone keeps
9202    // these extra locals byte-for-byte while overlaying the signed snapshot.
9203    let pulled: std::collections::BTreeSet<&str> =
9204        entries.iter().map(|(p, _)| p.as_str()).collect();
9205    let mut extra_local = Vec::new();
9206    if let Ok(store) = Store::open(&dest) {
9207        if let Ok(walked) = store.walk() {
9208            for rel in walked {
9209                let rel_str = rel.to_string_lossy().replace('\\', "/");
9210                if !pulled.contains(rel_str.as_str()) {
9211                    extra_local.push(rel_str);
9212                }
9213            }
9214        }
9215    }
9216    #[cfg(unix)]
9217    install_pulled_snapshot(&dest, &entries)?;
9218
9219    Ok(PullReport {
9220        brain: brain_id,
9221        slug,
9222        head_seq,
9223        files: entries.len(),
9224        dest: dest.to_string_lossy().into_owned(),
9225        extra_local,
9226        sync_status: "synced".to_string(),
9227    })
9228}
9229
9230#[cfg(unix)]
9231fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
9232    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
9233        path: display.to_string(),
9234    })
9235}
9236
9237#[cfg(unix)]
9238fn open_dir_at(
9239    parent: std::os::fd::RawFd,
9240    name: &std::ffi::CStr,
9241    display: &str,
9242) -> LinkResult<std::fs::File> {
9243    use std::os::fd::FromRawFd as _;
9244    let fd = unsafe {
9245        libc::openat(
9246            parent,
9247            name.as_ptr(),
9248            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9249        )
9250    };
9251    if fd < 0 {
9252        return Err(LinkError::UnsafePath {
9253            path: display.to_string(),
9254        });
9255    }
9256    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9257}
9258
9259/// Open (and, where absent, create) a directory path without following a
9260/// symlink in any component. The returned directory capability remains bound
9261/// to the opened inode even if an attacker renames or replaces an ancestor.
9262#[cfg(unix)]
9263fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9264    use std::os::fd::AsRawFd as _;
9265
9266    // macOS exposes a few system-owned compatibility symlinks at the root.
9267    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
9268    // caller-controlled ancestor.
9269    #[cfg(target_os = "macos")]
9270    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9271        .into_iter()
9272        .find_map(|(alias, real)| {
9273            path.strip_prefix(alias)
9274                .ok()
9275                .map(|rest| Path::new(real).join(rest))
9276        })
9277        .unwrap_or_else(|| path.to_path_buf());
9278    #[cfg(not(target_os = "macos"))]
9279    let normalized = path.to_path_buf();
9280
9281    let start = if normalized.is_absolute() {
9282        std::fs::File::open("/")?
9283    } else {
9284        std::fs::File::open(".")?
9285    };
9286    let mut directory = start;
9287    for component in normalized.components() {
9288        use std::path::Component;
9289        let name = match component {
9290            Component::RootDir | Component::CurDir => continue,
9291            Component::Normal(name) => name,
9292            Component::ParentDir | Component::Prefix(_) => {
9293                return Err(LinkError::UnsafePath {
9294                    path: path.display().to_string(),
9295                });
9296            }
9297        };
9298        use std::os::unix::ffi::OsStrExt as _;
9299        let name = c_name(name.as_bytes(), &path.display().to_string())?;
9300        if create {
9301            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9302            if made != 0 {
9303                let error = std::io::Error::last_os_error();
9304                if error.raw_os_error() != Some(libc::EEXIST) {
9305                    return Err(error.into());
9306                }
9307            }
9308        }
9309        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9310    }
9311    Ok(directory)
9312}
9313
9314#[cfg(unix)]
9315fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9316    open_dir_path_nofollow(path, true)
9317}
9318
9319#[cfg(unix)]
9320fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9321    open_dir_path_nofollow(path, false)
9322}
9323
9324#[cfg(unix)]
9325fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9326    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9327    let result =
9328        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9329    if result == 0 {
9330        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9331    }
9332    let error = std::io::Error::last_os_error();
9333    if error.kind() == std::io::ErrorKind::NotFound {
9334        Ok(None)
9335    } else {
9336        Err(error.into())
9337    }
9338}
9339
9340#[cfg(unix)]
9341fn create_dir_exclusive_at(
9342    parent: std::os::fd::RawFd,
9343    name: &std::ffi::CStr,
9344    display: &str,
9345) -> LinkResult<std::fs::File> {
9346    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9347    if made != 0 {
9348        return Err(LinkError::UnsafePath {
9349            path: display.to_string(),
9350        });
9351    }
9352    open_dir_at(parent, name, display)
9353}
9354
9355#[cfg(unix)]
9356fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9357    use std::os::fd::AsRawFd as _;
9358
9359    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9360    if duplicate < 0 {
9361        return Err(std::io::Error::last_os_error().into());
9362    }
9363    let stream = unsafe { libc::fdopendir(duplicate) };
9364    if stream.is_null() {
9365        let error = std::io::Error::last_os_error();
9366        unsafe {
9367            libc::close(duplicate);
9368        }
9369        return Err(error.into());
9370    }
9371    let mut names = Vec::new();
9372    loop {
9373        let entry = unsafe { libc::readdir(stream) };
9374        if entry.is_null() {
9375            break;
9376        }
9377        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9378        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9379            names.push(raw.to_owned());
9380        }
9381    }
9382    if unsafe { libc::closedir(stream) } != 0 {
9383        return Err(std::io::Error::last_os_error().into());
9384    }
9385    Ok(names)
9386}
9387
9388/// Remove an entry tree relative to a held directory capability. Symlinks are
9389/// unlinked, never traversed, even when an old mirror contains hostile names.
9390#[cfg(unix)]
9391fn remove_tree_at(
9392    parent: std::os::fd::RawFd,
9393    name: &std::ffi::CStr,
9394    display: &str,
9395) -> LinkResult<()> {
9396    use std::os::fd::AsRawFd as _;
9397
9398    match entry_is_dir_at(parent, name)? {
9399        None => return Ok(()),
9400        Some(false) => {
9401            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9402                return Err(std::io::Error::last_os_error().into());
9403            }
9404        }
9405        Some(true) => {
9406            let directory = open_dir_at(parent, name, display)?;
9407            for child in directory_entry_names(&directory)? {
9408                let child_display =
9409                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9410                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9411            }
9412            drop(directory);
9413            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9414                return Err(std::io::Error::last_os_error().into());
9415            }
9416        }
9417    }
9418    Ok(())
9419}
9420
9421/// Clone a live destination into a private sibling stage without following
9422/// any symlink. Regular files are streamed between held descriptors; symlinks
9423/// are reproduced as links, never opened. Special files are refused.
9424#[cfg(unix)]
9425fn clone_tree_contents(
9426    source: &std::fs::File,
9427    destination: &std::fs::File,
9428    display: &str,
9429) -> LinkResult<()> {
9430    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9431
9432    for name in directory_entry_names(source)? {
9433        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9434        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9435        if unsafe {
9436            libc::fstatat(
9437                source.as_raw_fd(),
9438                name.as_ptr(),
9439                &mut stat,
9440                libc::AT_SYMLINK_NOFOLLOW,
9441            )
9442        } != 0
9443        {
9444            return Err(std::io::Error::last_os_error().into());
9445        }
9446        match stat.st_mode & libc::S_IFMT {
9447            libc::S_IFDIR => {
9448                if unsafe {
9449                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9450                } != 0
9451                {
9452                    return Err(std::io::Error::last_os_error().into());
9453                }
9454                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9455                let destination_child =
9456                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9457                clone_tree_contents(&source_child, &destination_child, &child_display)?;
9458                destination_child.sync_all()?;
9459            }
9460            libc::S_IFREG => {
9461                let source_fd = unsafe {
9462                    libc::openat(
9463                        source.as_raw_fd(),
9464                        name.as_ptr(),
9465                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9466                    )
9467                };
9468                if source_fd < 0 {
9469                    return Err(std::io::Error::last_os_error().into());
9470                }
9471                let destination_fd = unsafe {
9472                    libc::openat(
9473                        destination.as_raw_fd(),
9474                        name.as_ptr(),
9475                        libc::O_WRONLY
9476                            | libc::O_CREAT
9477                            | libc::O_EXCL
9478                            | libc::O_CLOEXEC
9479                            | libc::O_NOFOLLOW,
9480                        (stat.st_mode & 0o777) as libc::c_uint,
9481                    )
9482                };
9483                if destination_fd < 0 {
9484                    unsafe {
9485                        libc::close(source_fd);
9486                    }
9487                    return Err(std::io::Error::last_os_error().into());
9488                }
9489                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9490                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9491                std::io::copy(&mut input, &mut output)?;
9492                output.sync_all()?;
9493            }
9494            libc::S_IFLNK => {
9495                let mut target = vec![0_u8; 4097];
9496                let length = unsafe {
9497                    libc::readlinkat(
9498                        source.as_raw_fd(),
9499                        name.as_ptr(),
9500                        target.as_mut_ptr().cast(),
9501                        target.len(),
9502                    )
9503                };
9504                if length < 0 || length as usize >= target.len() {
9505                    return Err(LinkError::UnsafePath {
9506                        path: child_display,
9507                    });
9508                }
9509                target.truncate(length as usize);
9510                let target = c_name(&target, &child_display)?;
9511                if unsafe {
9512                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9513                } != 0
9514                {
9515                    return Err(std::io::Error::last_os_error().into());
9516                }
9517            }
9518            _ => {
9519                return Err(LinkError::UnsafePath {
9520                    path: child_display,
9521                });
9522            }
9523        }
9524    }
9525    destination.sync_all()?;
9526    Ok(())
9527}
9528
9529#[cfg(target_os = "linux")]
9530fn install_stage_at(
9531    parent: std::os::fd::RawFd,
9532    stage: &std::ffi::CStr,
9533    dest: &std::ffi::CStr,
9534    dest_exists: bool,
9535) -> LinkResult<()> {
9536    let flags = if dest_exists {
9537        libc::RENAME_EXCHANGE
9538    } else {
9539        libc::RENAME_NOREPLACE
9540    };
9541    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
9542    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
9543    // atomic exchange/no-replace boundary compiles for glibc and musl.
9544    let result = unsafe {
9545        libc::syscall(
9546            libc::SYS_renameat2,
9547            parent,
9548            stage.as_ptr(),
9549            parent,
9550            dest.as_ptr(),
9551            flags,
9552        )
9553    };
9554    if result == 0 {
9555        Ok(())
9556    } else {
9557        Err(std::io::Error::last_os_error().into())
9558    }
9559}
9560
9561#[cfg(target_os = "macos")]
9562fn install_stage_at(
9563    parent: std::os::fd::RawFd,
9564    stage: &std::ffi::CStr,
9565    dest: &std::ffi::CStr,
9566    dest_exists: bool,
9567) -> LinkResult<()> {
9568    let flags = if dest_exists {
9569        libc::RENAME_SWAP
9570    } else {
9571        libc::RENAME_EXCL
9572    };
9573    let result =
9574        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9575    if result == 0 {
9576        Ok(())
9577    } else {
9578        Err(std::io::Error::last_os_error().into())
9579    }
9580}
9581
9582#[cfg(unix)]
9583fn write_pull_entries_beneath_dir(
9584    root: &std::fs::File,
9585    entries: &[(String, Vec<u8>)],
9586) -> LinkResult<()> {
9587    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9588
9589    for (path, content) in entries {
9590        let components: Vec<&str> = path.split('/').collect();
9591        let (leaf, parents) = components
9592            .split_last()
9593            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9594        let mut directory = root.try_clone()?;
9595        for component in parents {
9596            let name = c_name(component.as_bytes(), path)?;
9597            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9598            if made != 0 {
9599                let error = std::io::Error::last_os_error();
9600                if error.raw_os_error() != Some(libc::EEXIST) {
9601                    return Err(error.into());
9602                }
9603            }
9604            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9605        }
9606
9607        let leaf_name = c_name(leaf.as_bytes(), path)?;
9608        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9609        let inspected = unsafe {
9610            libc::fstatat(
9611                directory.as_raw_fd(),
9612                leaf_name.as_ptr(),
9613                &mut existing,
9614                libc::AT_SYMLINK_NOFOLLOW,
9615            )
9616        };
9617        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9618            return Err(LinkError::UnsafePath { path: path.clone() });
9619        }
9620
9621        let nonce = std::time::SystemTime::now()
9622            .duration_since(std::time::UNIX_EPOCH)
9623            .unwrap_or_default()
9624            .as_nanos();
9625        let temp_name = format!(
9626            ".dbmd-pull-{}-{nonce}-{}",
9627            std::process::id(),
9628            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9629        );
9630        let temp = c_name(temp_name.as_bytes(), path)?;
9631        let fd = unsafe {
9632            libc::openat(
9633                directory.as_raw_fd(),
9634                temp.as_ptr(),
9635                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9636                0o600,
9637            )
9638        };
9639        if fd < 0 {
9640            return Err(std::io::Error::last_os_error().into());
9641        }
9642        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9643        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9644            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9645            return Err(error.into());
9646        }
9647        drop(file);
9648        let renamed = unsafe {
9649            libc::renameat(
9650                directory.as_raw_fd(),
9651                temp.as_ptr(),
9652                directory.as_raw_fd(),
9653                leaf_name.as_ptr(),
9654            )
9655        };
9656        if renamed != 0 {
9657            let error = std::io::Error::last_os_error();
9658            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9659            return Err(error.into());
9660        }
9661        directory.sync_all()?;
9662    }
9663    root.sync_all()?;
9664    Ok(())
9665}
9666
9667#[cfg(unix)]
9668fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9669    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9670
9671    let path = &entry.path;
9672    let components: Vec<&str> = path.split('/').collect();
9673    let (leaf, parents) = components
9674        .split_last()
9675        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9676    let mut directory = root.try_clone()?;
9677    for component in parents {
9678        let name = c_name(component.as_bytes(), path)?;
9679        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9680        if made != 0 {
9681            let error = std::io::Error::last_os_error();
9682            if error.raw_os_error() != Some(libc::EEXIST) {
9683                return Err(error.into());
9684            }
9685        }
9686        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9687    }
9688    let leaf_name = c_name(leaf.as_bytes(), path)?;
9689    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9690    if unsafe {
9691        libc::fstatat(
9692            directory.as_raw_fd(),
9693            leaf_name.as_ptr(),
9694            &mut existing,
9695            libc::AT_SYMLINK_NOFOLLOW,
9696        )
9697    } == 0
9698        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9699    {
9700        return Err(LinkError::UnsafePath { path: path.clone() });
9701    }
9702    let nonce = SystemTime::now()
9703        .duration_since(UNIX_EPOCH)
9704        .unwrap_or_default()
9705        .as_nanos();
9706    let temp_name = format!(
9707        ".dbmd-pull-{}-{nonce}-{}",
9708        std::process::id(),
9709        content_sha256(path.as_bytes())
9710    );
9711    let temp = c_name(temp_name.as_bytes(), path)?;
9712    let fd = unsafe {
9713        libc::openat(
9714            directory.as_raw_fd(),
9715            temp.as_ptr(),
9716            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9717            0o600,
9718        )
9719    };
9720    if fd < 0 {
9721        return Err(std::io::Error::last_os_error().into());
9722    }
9723    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9724    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9725    let mut digest = Sha256::new();
9726    let mut total = 0_u64;
9727    let mut buffer = [0_u8; 64 * 1024];
9728    let copied = (|| -> std::io::Result<()> {
9729        loop {
9730            let read = input.read(&mut buffer)?;
9731            if read == 0 {
9732                break;
9733            }
9734            total = total.saturating_add(read as u64);
9735            if total > entry.bytes {
9736                return Err(std::io::Error::new(
9737                    std::io::ErrorKind::InvalidData,
9738                    "staged sync source grew beyond its verified length",
9739                ));
9740            }
9741            digest.update(&buffer[..read]);
9742            output.write_all(&buffer[..read])?;
9743        }
9744        Ok(())
9745    })();
9746    if let Err(error) = copied {
9747        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9748        return Err(error.into());
9749    }
9750    drop(output);
9751    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9752        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9753        return Err(invalid_feed(
9754            "private staged sync source failed final integrity verification",
9755        ));
9756    }
9757    if unsafe {
9758        libc::renameat(
9759            directory.as_raw_fd(),
9760            temp.as_ptr(),
9761            directory.as_raw_fd(),
9762            leaf_name.as_ptr(),
9763        )
9764    } != 0
9765    {
9766        let error = std::io::Error::last_os_error();
9767        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9768        return Err(error.into());
9769    }
9770    Ok(())
9771}
9772
9773#[cfg(unix)]
9774fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9775    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9776
9777    let path = &entry.path;
9778    let components: Vec<&str> = path.split('/').collect();
9779    let (leaf, parents) = components
9780        .split_last()
9781        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9782    let mut directory = root.try_clone()?;
9783    for component in parents {
9784        directory = open_dir_at(
9785            directory.as_raw_fd(),
9786            &c_name(component.as_bytes(), path)?,
9787            path,
9788        )?;
9789    }
9790    let leaf = c_name(leaf.as_bytes(), path)?;
9791    let fd = unsafe {
9792        libc::openat(
9793            directory.as_raw_fd(),
9794            leaf.as_ptr(),
9795            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9796        )
9797    };
9798    if fd < 0 {
9799        return Err(std::io::Error::last_os_error().into());
9800    }
9801    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9802    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9803        return Err(invalid_feed(
9804            "private pull stage changed before its durability barrier",
9805        ));
9806    }
9807    file.sync_all()?;
9808    Ok(())
9809}
9810
9811#[cfg(unix)]
9812fn run_pull_source_workers(
9813    root: &std::fs::File,
9814    entries: &[V2StagedFile],
9815    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9816) -> LinkResult<()> {
9817    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9818
9819    let next = AtomicUsize::new(0);
9820    let failed = AtomicBool::new(false);
9821    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9822    let mut first_error = None;
9823    std::thread::scope(|scope| {
9824        let (sender, receiver) = std::sync::mpsc::channel();
9825        for _ in 0..worker_count {
9826            let sender = sender.clone();
9827            let next = &next;
9828            let failed = &failed;
9829            scope.spawn(move || {
9830                while !failed.load(Ordering::Acquire) {
9831                    let index = next.fetch_add(1, Ordering::Relaxed);
9832                    let Some(entry) = entries.get(index) else {
9833                        break;
9834                    };
9835                    let result = operation(root, entry);
9836                    if result.is_err() {
9837                        failed.store(true, Ordering::Release);
9838                    }
9839                    if sender.send(result).is_err() {
9840                        break;
9841                    }
9842                }
9843            });
9844        }
9845        drop(sender);
9846        for result in receiver {
9847            if let Err(error) = result {
9848                if first_error.is_none() {
9849                    first_error = Some(error);
9850                }
9851            }
9852        }
9853    });
9854    if let Some(error) = first_error {
9855        return Err(error);
9856    }
9857    if next.load(Ordering::Relaxed) < entries.len() {
9858        return Err(invalid_feed(
9859            "a bounded pull worker stopped before reporting every file",
9860        ));
9861    }
9862    Ok(())
9863}
9864
9865#[cfg(unix)]
9866fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9867    use std::os::fd::AsRawFd as _;
9868
9869    for name in directory_entry_names(root)? {
9870        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9871            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9872            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9873            sync_pull_directory_tree(&child, &child_display)?;
9874        }
9875    }
9876    root.sync_all()?;
9877    Ok(())
9878}
9879
9880#[cfg(unix)]
9881fn write_pull_sources_beneath_dir(
9882    root: &std::fs::File,
9883    entries: &[V2StagedFile],
9884) -> LinkResult<()> {
9885    // The stage is still private and unreachable as the checkout here. Write
9886    // and verify every leaf first so the filesystem can coalesce dirty data,
9887    // then re-open/re-hash/fsync leaves through the held root capability. Only
9888    // after every file barrier succeeds do we flush each directory once. This
9889    // preserves the same crash durability as file-fsync + directory-fsync per
9890    // path without serializing 50,000 redundant directory barriers.
9891    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9892    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9893    sync_pull_directory_tree(root, "v2 pull stage")
9894}
9895
9896#[cfg(unix)]
9897fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9898    use std::os::fd::AsRawFd as _;
9899    for path in paths {
9900        if !safe_store_rel_path(path) {
9901            return Err(LinkError::UnsafePath { path: path.clone() });
9902        }
9903        let components = path.split('/').collect::<Vec<_>>();
9904        let Some((leaf, parents)) = components.split_last() else {
9905            return Err(LinkError::UnsafePath { path: path.clone() });
9906        };
9907        let mut directory = root.try_clone()?;
9908        let mut missing = false;
9909        for component in parents {
9910            let name = c_name(component.as_bytes(), path)?;
9911            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9912                None => {
9913                    missing = true;
9914                    break;
9915                }
9916                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9917                Some(true) => {
9918                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9919                }
9920            }
9921        }
9922        if missing {
9923            continue;
9924        }
9925        let leaf = c_name(leaf.as_bytes(), path)?;
9926        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9927            None => {}
9928            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9929            Some(false) => {
9930                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9931                    return Err(std::io::Error::last_os_error().into());
9932                }
9933                directory.sync_all()?;
9934            }
9935        }
9936    }
9937    Ok(())
9938}
9939
9940#[cfg(unix)]
9941fn install_pulled_delta(
9942    dest: &Path,
9943    entries: &[(String, Vec<u8>)],
9944    deleted: &[String],
9945    rebuild_indexes: bool,
9946) -> LinkResult<()> {
9947    use ring::rand::SecureRandom as _;
9948    use std::os::fd::AsRawFd as _;
9949    use std::os::unix::ffi::OsStrExt as _;
9950
9951    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9952    let name = dest
9953        .file_name()
9954        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9955        .ok_or_else(|| LinkError::UnsafePath {
9956            path: dest.display().to_string(),
9957        })?;
9958    let parent_dir = open_or_create_dir_nofollow(parent)?;
9959    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9960    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9961        None => false,
9962        Some(true) => true,
9963        Some(false) => {
9964            return Err(LinkError::UnsafePath {
9965                path: dest.display().to_string(),
9966            });
9967        }
9968    };
9969
9970    let mut nonce = [0_u8; 16];
9971    ring::rand::SystemRandom::new()
9972        .fill(&mut nonce)
9973        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9974    let stage_label = format!(
9975        ".{}.dbmd-pull-stage-{}",
9976        name.to_string_lossy(),
9977        URL_SAFE_NO_PAD.encode(nonce)
9978    );
9979    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9980    let stage_dir = create_dir_exclusive_at(
9981        parent_dir.as_raw_fd(),
9982        &stage_name,
9983        &dest.display().to_string(),
9984    )?;
9985
9986    let prepared = (|| -> LinkResult<()> {
9987        if dest_exists {
9988            let live = open_dir_at(
9989                parent_dir.as_raw_fd(),
9990                &dest_name,
9991                &dest.display().to_string(),
9992            )?;
9993            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9994        }
9995        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9996        write_pull_entries_beneath_dir(&stage_dir, entries)?;
9997        if rebuild_indexes {
9998            let stage_store =
9999                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10000                    .map_err(|error| LinkError::InvalidPack {
10001                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10002                    })?;
10003            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10004                LinkError::InvalidPack {
10005                    message: format!("could not materialize v2 local catalogs: {error}"),
10006                }
10007            })?;
10008        }
10009        stage_dir.sync_all()?;
10010        Ok(())
10011    })();
10012    if let Err(error) = prepared {
10013        let _ = remove_tree_at(
10014            parent_dir.as_raw_fd(),
10015            &stage_name,
10016            &dest.display().to_string(),
10017        );
10018        return Err(error);
10019    }
10020
10021    if let Err(error) =
10022        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10023    {
10024        let _ = remove_tree_at(
10025            parent_dir.as_raw_fd(),
10026            &stage_name,
10027            &dest.display().to_string(),
10028        );
10029        return Err(error);
10030    }
10031    parent_dir.sync_all()?;
10032    if dest_exists {
10033        // Commit already happened atomically. Cleanup is best-effort so a
10034        // failure cannot be reported as a failed pull after the live tree
10035        // changed; a crash may leave only this private old-tree sibling.
10036        let _ = remove_tree_at(
10037            parent_dir.as_raw_fd(),
10038            &stage_name,
10039            &dest.display().to_string(),
10040        );
10041        let _ = parent_dir.sync_all();
10042    }
10043    Ok(())
10044}
10045
10046#[cfg(unix)]
10047fn install_pulled_delta_sources(
10048    dest: &Path,
10049    entries: &[V2StagedFile],
10050    deleted: &[String],
10051    rebuild_indexes: bool,
10052    _previous: Option<&V2SyncBaseline>,
10053    _next: &V2VerifiedHead,
10054) -> LinkResult<()> {
10055    use ring::rand::SecureRandom as _;
10056    use std::os::fd::AsRawFd as _;
10057    use std::os::unix::ffi::OsStrExt as _;
10058
10059    // An established checkout is updated only at the changed coordinates
10060    // through the durable pull journal shared with Windows. Initial clones
10061    // still use a private complete stage and one atomic directory install.
10062    if let Ok(store) = Store::open_strict(dest) {
10063        return install_established_v2_delta(
10064            store,
10065            entries,
10066            deleted,
10067            rebuild_indexes,
10068            _previous,
10069            _next,
10070        );
10071    }
10072
10073    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10074    let name = dest
10075        .file_name()
10076        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10077        .ok_or_else(|| LinkError::UnsafePath {
10078            path: dest.display().to_string(),
10079        })?;
10080    let parent_dir = open_or_create_dir_nofollow(parent)?;
10081    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10082    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10083        None => false,
10084        Some(true) => true,
10085        Some(false) => {
10086            return Err(LinkError::UnsafePath {
10087                path: dest.display().to_string(),
10088            })
10089        }
10090    };
10091    let mut nonce = [0_u8; 16];
10092    ring::rand::SystemRandom::new()
10093        .fill(&mut nonce)
10094        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10095    let stage_label = format!(
10096        ".{}.dbmd-pull-stage-{}",
10097        name.to_string_lossy(),
10098        URL_SAFE_NO_PAD.encode(nonce)
10099    );
10100    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10101    let stage_dir = create_dir_exclusive_at(
10102        parent_dir.as_raw_fd(),
10103        &stage_name,
10104        &dest.display().to_string(),
10105    )?;
10106    let prepared = (|| -> LinkResult<()> {
10107        if dest_exists {
10108            let live = open_dir_at(
10109                parent_dir.as_raw_fd(),
10110                &dest_name,
10111                &dest.display().to_string(),
10112            )?;
10113            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10114        }
10115        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10116        write_pull_sources_beneath_dir(&stage_dir, entries)?;
10117        if rebuild_indexes {
10118            let stage_store =
10119                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10120                    .map_err(|error| LinkError::InvalidPack {
10121                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10122                    })?;
10123            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10124                LinkError::InvalidPack {
10125                    message: format!("could not materialize v2 local catalogs: {error}"),
10126                }
10127            })?;
10128        }
10129        stage_dir.sync_all()?;
10130        Ok(())
10131    })();
10132    if let Err(error) = prepared {
10133        let _ = remove_tree_at(
10134            parent_dir.as_raw_fd(),
10135            &stage_name,
10136            &dest.display().to_string(),
10137        );
10138        return Err(error);
10139    }
10140    if let Err(error) =
10141        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10142    {
10143        let _ = remove_tree_at(
10144            parent_dir.as_raw_fd(),
10145            &stage_name,
10146            &dest.display().to_string(),
10147        );
10148        return Err(error);
10149    }
10150    parent_dir.sync_all()?;
10151    if dest_exists {
10152        let _ = remove_tree_at(
10153            parent_dir.as_raw_fd(),
10154            &stage_name,
10155            &dest.display().to_string(),
10156        );
10157        let _ = parent_dir.sync_all();
10158    }
10159    Ok(())
10160}
10161
10162#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10163struct V2PullCoordinate {
10164    head_seq: Option<u64>,
10165    commit_hash: Option<String>,
10166    view_kind: Option<String>,
10167    view_revision: Option<String>,
10168}
10169
10170#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10171struct V2PullFileCoordinate {
10172    sha256: String,
10173    bytes: u64,
10174}
10175
10176#[derive(Debug, Clone, Deserialize, Serialize)]
10177struct V2PullJournalEntry {
10178    path: String,
10179    old: Option<V2PullFileCoordinate>,
10180    new: Option<V2PullFileCoordinate>,
10181    backup: Option<String>,
10182}
10183
10184#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10185#[serde(rename_all = "snake_case")]
10186enum V2PullPhase {
10187    Preparing,
10188    Ready,
10189}
10190
10191#[derive(Debug, Clone, Deserialize, Serialize)]
10192struct V2PullJournal {
10193    v: u8,
10194    phase: V2PullPhase,
10195    brain: String,
10196    previous: V2PullCoordinate,
10197    next: V2PullCoordinate,
10198    backup_dir: String,
10199    entries: Vec<V2PullJournalEntry>,
10200}
10201
10202const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
10203
10204fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
10205    V2PullCoordinate {
10206        head_seq: baseline.and_then(|value| value.head_seq),
10207        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
10208        view_kind: baseline.and_then(|value| value.view_kind.clone()),
10209        view_revision: baseline.and_then(|value| value.view_revision.clone()),
10210    }
10211}
10212
10213fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
10214    V2PullCoordinate {
10215        head_seq: head.pointer.as_ref().map(|value| value.seq),
10216        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
10217        view_kind: Some(head.view_kind.clone()),
10218        view_revision: Some(head.view_revision.clone()),
10219    }
10220}
10221
10222fn v2_pull_file_coordinate(
10223    store: &Store,
10224    path: &str,
10225    limit: u64,
10226) -> LinkResult<Option<V2PullFileCoordinate>> {
10227    let file = match store.open_regular(Path::new(path)) {
10228        Ok(file) => file,
10229        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10230        Err(error) => return Err(error.into()),
10231    };
10232    let bytes = file.metadata()?.len();
10233    if bytes > limit || bytes > MAX_STORE_BYTES {
10234        return Err(invalid_feed(
10235            "pull transaction file exceeds its declared bound",
10236        ));
10237    }
10238    Ok(Some(V2PullFileCoordinate {
10239        sha256: content_sha256_reader(file)?,
10240        bytes,
10241    }))
10242}
10243
10244fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
10245    let mut bytes = serde_json::to_vec_pretty(journal)
10246        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
10247    bytes.push(b'\n');
10248    Ok(bytes)
10249}
10250
10251fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10252    let backup_prefix = ".dbmd/pull-backup-";
10253    let suffix = journal
10254        .backup_dir
10255        .strip_prefix(backup_prefix)
10256        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10257    let mut paths = std::collections::BTreeSet::new();
10258    if journal.v != 1
10259        || !crate::ulid::is_ulid(&journal.brain)
10260        || !crate::ulid::is_ulid(suffix)
10261        || journal.entries.is_empty()
10262        || journal.entries.len() > MAX_PUSH_FILES + 4
10263        || journal.previous == journal.next
10264    {
10265        return Err(invalid_feed("v2 pull journal failed validation"));
10266    }
10267    for (index, entry) in journal.entries.iter().enumerate() {
10268        if !safe_store_rel_path(&entry.path)
10269            || entry.path == V2_PULL_JOURNAL
10270            || entry.path.starts_with(backup_prefix)
10271            || !paths.insert(entry.path.clone())
10272            || (entry.old.is_none() && entry.new.is_none())
10273            || entry
10274                .old
10275                .iter()
10276                .chain(entry.new.iter())
10277                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10278            || entry.backup.as_deref()
10279                != entry
10280                    .old
10281                    .as_ref()
10282                    .map(|_| format!("{index:08x}"))
10283                    .as_deref()
10284        {
10285            return Err(invalid_feed("v2 pull journal entry failed validation"));
10286        }
10287    }
10288    Ok(())
10289}
10290
10291fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10292    #[cfg(unix)]
10293    {
10294        use std::os::unix::fs::PermissionsExt as _;
10295        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10296            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10297                return Err(invalid_feed(
10298                    "v2 pull journal is accessible to group/other; set mode 0600",
10299                ));
10300            }
10301            Ok(_) => {}
10302            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10303            Err(error) => return Err(error.into()),
10304        }
10305    }
10306    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10307        Ok(bytes) => bytes,
10308        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10309        Err(error) => return Err(error.into()),
10310    };
10311    let journal: V2PullJournal =
10312        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10313    validate_v2_pull_journal(&journal)?;
10314    Ok(Some(journal))
10315}
10316
10317fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10318    // The journal is the only recovery authority. Remove it first only after
10319    // rollback or baseline commit is durable; an orphan private backup is safe
10320    // and can be pruned, while a journal without its backups is not recoverable.
10321    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10322        Ok(()) => {}
10323        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10324        Err(error) => return Err(error.into()),
10325    }
10326    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10327        Ok(()) => Ok(()),
10328        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10329        Err(error) => Err(error.into()),
10330    }
10331}
10332
10333fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10334    let names = match store.directory_names(Path::new(".dbmd")) {
10335        Ok(names) => names,
10336        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10337        Err(error) => return Err(error.into()),
10338    };
10339    for name in names {
10340        let Some(name) = name.to_str() else {
10341            continue;
10342        };
10343        let Some(suffix) = name.strip_prefix("pull-backup-") else {
10344            continue;
10345        };
10346        if crate::ulid::is_ulid(suffix) {
10347            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10348        }
10349    }
10350    Ok(())
10351}
10352
10353fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10354    // Validate every live coordinate and every backup before the first restore.
10355    for entry in &journal.entries {
10356        let limit = entry
10357            .old
10358            .as_ref()
10359            .into_iter()
10360            .chain(entry.new.iter())
10361            .map(|value| value.bytes)
10362            .max()
10363            .unwrap_or(0);
10364        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10365        if current != entry.old && current != entry.new {
10366            return Err(LinkError::InvalidPack {
10367                message: format!(
10368                    "cannot recover interrupted pull because `{}` changed afterward",
10369                    entry.path
10370                ),
10371            });
10372        }
10373        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10374            let path = Path::new(&journal.backup_dir).join(backup);
10375            let file = store.open_regular(&path)?;
10376            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10377                return Err(invalid_feed("v2 pull recovery backup failed verification"));
10378            }
10379        }
10380    }
10381    for entry in journal.entries.iter().rev() {
10382        match (&entry.old, &entry.backup) {
10383            (Some(old), Some(backup)) => {
10384                let bytes =
10385                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10386                store.write_atomic(Path::new(&entry.path), &bytes)?;
10387            }
10388            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10389                store.remove_file(Path::new(&entry.path))?;
10390            }
10391            (None, None) => {}
10392            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10393        }
10394    }
10395    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10396        message: format!("could not rebuild catalogs after pull recovery: {error}"),
10397    })?;
10398    cleanup_v2_pull_journal(store, journal)
10399}
10400
10401fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10402    let Ok(store) = Store::open_strict(dest) else {
10403        return Ok(());
10404    };
10405    if let Some(journal) = load_v2_pull_journal(&store)? {
10406        if journal.brain != brain {
10407            return Err(invalid_feed("v2 pull journal belongs to another brain"));
10408        }
10409        if journal.phase == V2PullPhase::Preparing {
10410            cleanup_v2_pull_journal(&store, &journal)?;
10411        } else {
10412            let baseline = load_v2_baseline(cfg, brain, dest)?;
10413            let current = v2_pull_baseline_coordinate(baseline.as_ref());
10414            if current == journal.next {
10415                cleanup_v2_pull_journal(&store, &journal)?;
10416            } else {
10417                if current != journal.previous {
10418                    return Err(invalid_feed(
10419                        "cannot recover interrupted pull because its baseline changed afterward",
10420                    ));
10421                }
10422                rollback_v2_pull(&store, &journal)?;
10423            }
10424        }
10425    }
10426    // Cleanup removes the journal first after the state is durably old or new.
10427    // A hard kill in the tiny interval before deleting its backup directory can
10428    // therefore leave only an inert, private orphan. The per-brain operation
10429    // lock is held by the caller, so no live pull can own one here.
10430    prune_orphan_v2_pull_backups(&store)
10431}
10432
10433fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10434    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10435        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10436    })?;
10437    if let Some(journal) = load_v2_pull_journal(&store)? {
10438        cleanup_v2_pull_journal(&store, &journal)?;
10439    }
10440    Ok(())
10441}
10442
10443#[cfg(windows)]
10444fn install_windows_initial_sources(
10445    dest: &Path,
10446    entries: &[V2StagedFile],
10447    rebuild_indexes: bool,
10448) -> LinkResult<()> {
10449    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10450    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10451        path: dest.display().to_string(),
10452    })?;
10453    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10454    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10455        return Err(LinkError::UnsafePath {
10456            path: dest.display().to_string(),
10457        });
10458    }
10459    let stage_name = format!(
10460        ".{}.dbmd-pull-stage-{}",
10461        name.to_string_lossy(),
10462        crate::ulid::mint()
10463    );
10464    let stage_path = parent.join(&stage_name);
10465    let stage_capability =
10466        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10467    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10468    let prepared = (|| -> LinkResult<()> {
10469        for entry in entries {
10470            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10471            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10472                return Err(invalid_feed(
10473                    "private staged sync source failed final integrity verification",
10474                ));
10475            }
10476            stage.write_atomic(Path::new(&entry.path), &bytes)?;
10477        }
10478        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10479            .map_err(|error| LinkError::InvalidPack {
10480                message: format!("v2 staging tree is not a valid db.md store: {error}"),
10481            })?;
10482        if rebuild_indexes {
10483            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10484                message: format!("could not materialize v2 local catalogs: {error}"),
10485            })?;
10486        }
10487        Ok(())
10488    })();
10489    if let Err(error) = prepared {
10490        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10491        return Err(error);
10492    }
10493    crate::fsx::rename_directory_beneath(
10494        &parent_capability,
10495        Path::new(&stage_name),
10496        Path::new(name),
10497    )?;
10498    Ok(())
10499}
10500
10501fn install_established_v2_delta(
10502    store: Store,
10503    entries: &[V2StagedFile],
10504    deleted: &[String],
10505    rebuild_indexes: bool,
10506    previous: Option<&V2SyncBaseline>,
10507    next: &V2VerifiedHead,
10508) -> LinkResult<()> {
10509    if load_v2_pull_journal(&store)?.is_some() {
10510        return Err(invalid_feed(
10511            "an interrupted pull must be recovered before installing",
10512        ));
10513    }
10514    let mut sources = std::collections::BTreeMap::new();
10515    for entry in entries {
10516        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10517            return Err(invalid_feed("pull mutation repeats a path"));
10518        }
10519    }
10520    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10521    paths.extend(deleted.iter().cloned());
10522    paths.sort();
10523    paths.dedup();
10524    if paths.is_empty() {
10525        return Ok(());
10526    }
10527    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10528    let mut journal = V2PullJournal {
10529        v: 1,
10530        phase: V2PullPhase::Preparing,
10531        brain: next.brain_id.clone(),
10532        previous: v2_pull_baseline_coordinate(previous),
10533        next: v2_pull_head_coordinate(next),
10534        backup_dir: backup_dir.clone(),
10535        entries: Vec::with_capacity(paths.len()),
10536    };
10537    for path in &paths {
10538        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10539        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10540            sha256: entry.sha256.clone(),
10541            bytes: entry.bytes,
10542        });
10543        if old == new {
10544            continue;
10545        }
10546        let index = journal.entries.len();
10547        journal.entries.push(V2PullJournalEntry {
10548            path: path.clone(),
10549            backup: old.as_ref().map(|_| format!("{index:08x}")),
10550            old,
10551            new,
10552        });
10553    }
10554    if journal.entries.is_empty() {
10555        return Ok(());
10556    }
10557    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10558        entry
10559            .old
10560            .as_ref()
10561            .map_or(Some(total), |old| total.checked_add(old.bytes))
10562    });
10563    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10564        return Err(LinkError::InvalidPack {
10565            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10566        });
10567    }
10568    validate_v2_pull_journal(&journal)?;
10569    store.write_private_atomic_new(
10570        Path::new(V2_PULL_JOURNAL),
10571        &v2_pull_journal_bytes(&journal)?,
10572    )?;
10573    let prepared = (|| -> LinkResult<()> {
10574        store.create_private_dir_all(Path::new(&backup_dir))?;
10575        for entry in &journal.entries {
10576            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10577                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10578                if content_sha256(&bytes) != old.sha256 {
10579                    return Err(invalid_feed("live pull source changed during backup"));
10580                }
10581                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10582            }
10583        }
10584        journal.phase = V2PullPhase::Ready;
10585        store.write_private_atomic(
10586            Path::new(V2_PULL_JOURNAL),
10587            &v2_pull_journal_bytes(&journal)?,
10588        )?;
10589        Ok(())
10590    })();
10591    if let Err(error) = prepared {
10592        let cleanup = cleanup_v2_pull_journal(&store, &journal);
10593        return match cleanup {
10594            Ok(()) => Err(error),
10595            Err(cleanup) => Err(LinkError::InvalidPack {
10596                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10597            }),
10598        };
10599    }
10600    let installed = (|| -> LinkResult<()> {
10601        for entry in &journal.entries {
10602            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10603                return Err(LinkError::InvalidPack {
10604                    message: format!("local path `{}` changed during pull", entry.path),
10605                });
10606            }
10607            if let Some(source) = sources.get(&entry.path) {
10608                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10609                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10610                    return Err(invalid_feed(
10611                        "private staged sync source failed final integrity verification",
10612                    ));
10613                }
10614                store.write_atomic(Path::new(&entry.path), &bytes)?;
10615            } else if entry.old.is_some() {
10616                store.remove_file(Path::new(&entry.path))?;
10617            }
10618        }
10619        if rebuild_indexes {
10620            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10621                message: format!("could not materialize v2 local catalogs: {error}"),
10622            })?;
10623        }
10624        Ok(())
10625    })();
10626    if let Err(error) = installed {
10627        return match rollback_v2_pull(&store, &journal) {
10628            Ok(()) => Err(error),
10629            Err(rollback) => Err(LinkError::InvalidPack {
10630                message: format!("{error}; durable pull rollback also failed: {rollback}"),
10631            }),
10632        };
10633    }
10634    Ok(())
10635}
10636
10637#[cfg(windows)]
10638fn install_pulled_delta_sources(
10639    dest: &Path,
10640    entries: &[V2StagedFile],
10641    deleted: &[String],
10642    rebuild_indexes: bool,
10643    previous: Option<&V2SyncBaseline>,
10644    next: &V2VerifiedHead,
10645) -> LinkResult<()> {
10646    match Store::open_strict(dest) {
10647        Ok(store) => {
10648            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10649        }
10650        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10651    }
10652}
10653
10654#[cfg(not(any(unix, windows)))]
10655fn install_pulled_delta_sources(
10656    _dest: &Path,
10657    _entries: &[V2StagedFile],
10658    _deleted: &[String],
10659    _rebuild_indexes: bool,
10660    _previous: Option<&V2SyncBaseline>,
10661    _next: &V2VerifiedHead,
10662) -> LinkResult<()> {
10663    Err(LinkError::UnsupportedPlatform {
10664        operation: "atomic v2 pull install",
10665    })
10666}
10667
10668#[cfg(unix)]
10669fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10670    install_pulled_delta(dest, entries, &[], false)
10671}
10672
10673#[cfg(not(windows))]
10674fn is_safe_slug(slug: &str) -> bool {
10675    !slug.is_empty()
10676        && slug.len() <= 63
10677        && !slug.starts_with('-')
10678        && !slug.ends_with('-')
10679        && slug
10680            .bytes()
10681            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10682}
10683
10684fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10685    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10686}
10687
10688fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10689    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10690}
10691
10692fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10693    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10694}
10695
10696fn preflight_zip_central_directory(
10697    bytes: &[u8],
10698    offset: usize,
10699    size: usize,
10700    count: u64,
10701) -> LinkResult<()> {
10702    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10703    let end = offset
10704        .checked_add(size)
10705        .filter(|end| *end <= bytes.len())
10706        .ok_or_else(|| LinkError::InvalidPack {
10707            message: "ZIP central directory is out of bounds".to_string(),
10708        })?;
10709    let mut cursor = offset;
10710    for _ in 0..count {
10711        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10712            return Err(LinkError::InvalidPack {
10713                message: "ZIP central directory entry count is inconsistent".to_string(),
10714            });
10715        }
10716        if le_u16(bytes, cursor + 34) != Some(0) {
10717            return Err(LinkError::InvalidPack {
10718                message: "multi-disk ZIP archives are not supported".to_string(),
10719            });
10720        }
10721        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10722            total.checked_add(le_u16(bytes, cursor + at)? as usize)
10723        });
10724        cursor = cursor
10725            .checked_add(46)
10726            .and_then(|fixed| fixed.checked_add(variable?))
10727            .filter(|cursor| *cursor <= end)
10728            .ok_or_else(|| LinkError::InvalidPack {
10729                message: "ZIP central directory entry is truncated".to_string(),
10730            })?;
10731    }
10732    if cursor != end {
10733        return Err(LinkError::InvalidPack {
10734            message: "ZIP central directory size is inconsistent".to_string(),
10735        });
10736    }
10737    Ok(())
10738}
10739
10740/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
10741/// metadata object per central-directory entry. Supports ordinary EOCD and the
10742/// Zip64 locator/record emitted for >65,535-entry archives.
10743fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10744    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10745    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10746    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10747    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10748    let eocd = bytes[search_start..]
10749        .windows(4)
10750        .rposition(|window| window == EOCD_SIG)
10751        .map(|offset| search_start + offset)
10752        .ok_or_else(|| LinkError::InvalidPack {
10753            message: "ZIP has no end-of-central-directory record".to_string(),
10754        })?;
10755    let invalid_end = || LinkError::InvalidPack {
10756        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10757    };
10758    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10759    if eocd
10760        .checked_add(22)
10761        .and_then(|end| end.checked_add(comment_len))
10762        != Some(bytes.len())
10763    {
10764        // Do not fall back to an earlier signature. ZipArchive does, and a
10765        // fake low-count EOCD appended after a real Zip64 entry-count bomb
10766        // would otherwise bypass this allocation preflight.
10767        return Err(invalid_end());
10768    }
10769    let disk = le_u16(bytes, eocd + 4);
10770    let central_disk = le_u16(bytes, eocd + 6);
10771    if disk != Some(0) || central_disk != Some(0) {
10772        return Err(LinkError::InvalidPack {
10773            message: "multi-disk ZIP archives are not supported".to_string(),
10774        });
10775    }
10776    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10777    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10778    if entries_on_disk != ordinary {
10779        return Err(LinkError::InvalidPack {
10780            message: "multi-disk ZIP archives are not supported".to_string(),
10781        });
10782    }
10783    let zip64_locator = eocd
10784        .checked_sub(20)
10785        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10786    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10787        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10788        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10789        if central_offset
10790            .checked_add(central_size)
10791            .filter(|end| *end == eocd)
10792            .is_none()
10793        {
10794            return Err(invalid_end());
10795        }
10796        (ordinary as u64, central_offset, central_size)
10797    } else {
10798        let Some(locator) = zip64_locator else {
10799            return Err(invalid_end());
10800        };
10801        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10802            return Err(LinkError::InvalidPack {
10803                message: "multi-disk ZIP64 archives are not supported".to_string(),
10804            });
10805        }
10806        let record = le_u64(bytes, locator + 8)
10807            .and_then(|offset| usize::try_from(offset).ok())
10808            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10809            .ok_or_else(|| LinkError::InvalidPack {
10810                message: "ZIP64 archive has an invalid end record".to_string(),
10811            })?;
10812        let record_size = le_u64(bytes, record + 4)
10813            .and_then(|size| usize::try_from(size).ok())
10814            .filter(|size| *size >= 44)
10815            .ok_or_else(invalid_end)?;
10816        if record
10817            .checked_add(12)
10818            .and_then(|end| end.checked_add(record_size))
10819            != Some(locator)
10820            || le_u32(bytes, record + 16) != Some(0)
10821            || le_u32(bytes, record + 20) != Some(0)
10822        {
10823            return Err(invalid_end());
10824        }
10825        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10826        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10827        let central_size = le_u64(bytes, record + 40)
10828            .and_then(|size| usize::try_from(size).ok())
10829            .ok_or_else(invalid_end)?;
10830        let central_offset = le_u64(bytes, record + 48)
10831            .and_then(|offset| usize::try_from(offset).ok())
10832            .ok_or_else(invalid_end)?;
10833        if zip64_on_disk != zip64_total
10834            || central_offset
10835                .checked_add(central_size)
10836                .filter(|end| *end == record)
10837                .is_none()
10838        {
10839            return Err(invalid_end());
10840        }
10841        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10842        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10843        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10844            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10845        {
10846            return Err(invalid_end());
10847        }
10848        (zip64_total, central_offset, central_size)
10849    };
10850    if count == 0 || count > max_entries as u64 {
10851        return Err(LinkError::InvalidPack {
10852            message: format!("invalid file count {count}"),
10853        });
10854    }
10855    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10856    Ok(())
10857}
10858
10859fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10860    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10861    let mut archive =
10862        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10863            message: format!("ZIP parse failed: {err}"),
10864        })?;
10865    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10866        return Err(LinkError::InvalidPack {
10867            message: format!("invalid file count {}", archive.len()),
10868        });
10869    }
10870    let mut total = 0u64;
10871    let mut seen = std::collections::HashSet::new();
10872    let mut entries = Vec::with_capacity(archive.len());
10873    for index in 0..archive.len() {
10874        let mut file = archive
10875            .by_index(index)
10876            .map_err(|err| LinkError::InvalidPack {
10877                message: format!("ZIP entry failed: {err}"),
10878            })?;
10879        if file.is_dir() {
10880            continue;
10881        }
10882        let path = file.name().to_string();
10883        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10884            return Err(LinkError::UnsafePath { path });
10885        }
10886        if file
10887            .unix_mode()
10888            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10889        {
10890            return Err(LinkError::InvalidPack {
10891                message: format!("non-file entry `{path}`"),
10892            });
10893        }
10894        if !seen.insert(path.clone()) {
10895            return Err(LinkError::InvalidPack {
10896                message: format!("duplicate path `{path}`"),
10897            });
10898        }
10899        let remaining = MAX_STORE_BYTES.saturating_sub(total);
10900        if file.size() > remaining {
10901            return Err(LinkError::InvalidPack {
10902                message: "expanded content exceeds the 512 MB limit".to_string(),
10903            });
10904        }
10905        let mut content = Vec::new();
10906        (&mut file)
10907            .take(remaining + 1)
10908            .read_to_end(&mut content)
10909            .map_err(|err| LinkError::InvalidPack {
10910                message: format!("could not decompress `{path}`: {err}"),
10911            })?;
10912        if content.len() as u64 > remaining {
10913            return Err(LinkError::InvalidPack {
10914                message: "expanded content exceeds the 512 MB limit".to_string(),
10915            });
10916        }
10917        if content.len() as u64 != file.size() {
10918            return Err(LinkError::InvalidPack {
10919                message: format!("length mismatch for `{path}`"),
10920            });
10921        }
10922        total += content.len() as u64;
10923        entries.push((path, content));
10924    }
10925    if entries.is_empty() {
10926        return Err(LinkError::InvalidPack {
10927            message: "pack contains no files".to_string(),
10928        });
10929    }
10930    Ok(entries)
10931}
10932
10933fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
10934    let mut expected = std::collections::BTreeMap::new();
10935    for file in signed {
10936        if !safe_store_rel_path(&file.path) {
10937            return Err(LinkError::UnsafePath {
10938                path: file.path.clone(),
10939            });
10940        }
10941        if !is_sha256(&file.sha256)
10942            || expected
10943                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10944                .is_some()
10945        {
10946            return Err(invalid_feed(
10947                "signed snapshot manifest contains an invalid or duplicate file",
10948            ));
10949        }
10950    }
10951    if expected.len() != entries.len() {
10952        return Err(invalid_feed(
10953            "downloaded pack file set differs from the signed snapshot manifest",
10954        ));
10955    }
10956    for (path, bytes) in entries {
10957        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
10958            return Err(invalid_feed(format!(
10959                "downloaded pack contains unsigned path `{path}`"
10960            )));
10961        };
10962        if *declared_bytes != bytes.len() as u64
10963            || *sha256 != format!("{:x}", Sha256::digest(bytes))
10964        {
10965            return Err(invalid_feed(format!(
10966                "downloaded file `{path}` differs from its signed manifest"
10967            )));
10968        }
10969    }
10970    Ok(())
10971}
10972
10973/// Collect the files a push sends: the store's owned text — `DB.md`,
10974/// `assets.jsonl` when present, and every content `.md` under `records/` and
10975/// `sources/` (the store walk, which already excludes hidden dirs like
10976/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
10977/// derives its own index, and local history stays local). Returns
10978/// `(store-relative path, content)` pairs, path-sorted.
10979pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
10980    require_hardened_filesystem("sync push")?;
10981    preflight_push_ownership(store)?;
10982    let mut out: Vec<(String, String)> = Vec::new();
10983    let mut total = 0u64;
10984
10985    let mut read_text = |rel: &str| -> LinkResult<String> {
10986        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
10987        total = total
10988            .checked_add(bytes.len() as u64)
10989            .ok_or_else(|| LinkError::PushTooLarge {
10990                detail: "uncompressed byte count overflow".to_string(),
10991            })?;
10992        if total > MAX_STORE_BYTES {
10993            return Err(LinkError::PushTooLarge {
10994                detail: format!("{total} uncompressed bytes"),
10995            });
10996        }
10997        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
10998            path: rel.to_string(),
10999        })
11000    };
11001
11002    out.push(("DB.md".to_string(), read_text("DB.md")?));
11003    if store
11004        .regular_file_exists(Path::new("assets.jsonl"))
11005        .unwrap_or(false)
11006    {
11007        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11008    }
11009
11010    for rel in store.walk()? {
11011        let rel_str = rel.to_string_lossy().replace('\\', "/");
11012        if !safe_store_rel_path(&rel_str) {
11013            // A locally-legal name outside the hub's portable charset cannot
11014            // travel this wire; refusing beats silently dropping it.
11015            return Err(LinkError::UnsafePath { path: rel_str });
11016        }
11017        let content = read_text(&rel_str)?;
11018        out.push((rel_str, content));
11019    }
11020
11021    out.sort_by(|a, b| a.0.cmp(&b.0));
11022    Ok(out)
11023}
11024
11025/// Refuse to build a destructive whole-store snapshot from an ambiguous local
11026/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
11027/// silently omitted them could delete the hosted copies of those paths.
11028fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11029    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11030        return Err(LinkError::from(std::io::Error::new(
11031            std::io::ErrorKind::PermissionDenied,
11032            format!("cannot push: nested db.md store at {}", nested.display()),
11033        )));
11034    }
11035
11036    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11037        return Err(LinkError::from(std::io::Error::new(
11038            std::io::ErrorKind::PermissionDenied,
11039            format!(
11040                "cannot push: {} is a symlink outside the store ownership model",
11041                symlink.display()
11042            ),
11043        )));
11044    }
11045    Ok(())
11046}
11047
11048/// Push `files` to `brain` as a whole-store snapshot — the hub's push
11049/// semantics: the hosted copy becomes exactly this set (pull first if the
11050/// hosted side may have records the local copy lacks). Client-side caps
11051/// mirror the hub's JSON-path limits so an oversized push fails before the
11052/// upload.
11053pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11054    require_safe_ref(brain)?;
11055    let remote = verified_remote_head(cfg, brain, false)?;
11056    if files.len() > MAX_PUSH_FILES {
11057        return Err(LinkError::PushTooLarge {
11058            detail: format!("{} files", files.len()),
11059        });
11060    }
11061    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11062    if raw_total > MAX_STORE_BYTES {
11063        return Err(LinkError::PushTooLarge {
11064            detail: format!("{raw_total} uncompressed bytes"),
11065        });
11066    }
11067
11068    // Self-custody (a brain key is configured): the JSON fast path is
11069    // hub-signed by construction, so every push goes through the pack flow
11070    // with a locally signed entry — the hub verifies and can never sign.
11071    if cfg.brain_key.is_none() {
11072        let body = json!({
11073            "files": files
11074                .iter()
11075                .map(|(p, c)| json!({ "path": p, "content": c }))
11076                .collect::<Vec<_>>(),
11077        });
11078        if body.to_string().len() <= MAX_PUSH_BYTES {
11079            let path = format!("/api/hub/brains/{brain}/push");
11080            let pushed = ensure_ok(
11081                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11082                "sync push",
11083            )?;
11084            return Ok(pushed);
11085        }
11086    }
11087
11088    let pack = build_store_pack(files)?;
11089    if pack.len() as u64 > MAX_PACK_BYTES {
11090        return Err(LinkError::PushTooLarge {
11091            detail: format!("{} pack bytes", pack.len()),
11092        });
11093    }
11094    let sha256 = format!("{:x}", Sha256::digest(&pack));
11095    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11096    if let Some(key) = &cfg.brain_key {
11097        if !remote.head.verified {
11098            return Err(invalid_feed(
11099                "self-custody push requires a fully verified, unscoped feed head",
11100            ));
11101        }
11102        let identity = remote
11103            .identity
11104            .as_ref()
11105            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
11106        let current_multikey = format!("ed25519:{}", identity.fingerprint);
11107        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
11108            return Err(invalid_feed(
11109                "configured brain key is not the verified current brain identity",
11110            ));
11111        }
11112        // The verified state pins seq + prev; a concurrent writer surfaces as
11113        // the hub's 422 on commit (re-run to retry against the new head).
11114        let next_seq = remote
11115            .head
11116            .seq
11117            .checked_add(1)
11118            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
11119        let mut manifest: Vec<WireFeedFile> = files
11120            .iter()
11121            .map(|(path, content)| WireFeedFile {
11122                path: path.clone(),
11123                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
11124                bytes: content.len() as u64,
11125            })
11126            .collect();
11127        manifest.sort_by(|a, b| a.path.cmp(&b.path));
11128        let ts = crate::now()
11129            .with_timezone(&chrono::Utc)
11130            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11131            .to_string();
11132        let entry = self_custody_entry(
11133            key,
11134            next_seq,
11135            ts,
11136            &sha256,
11137            &manifest,
11138            remote.head.feed_hash.as_deref(),
11139        )?;
11140        meta["entry"] = Value::String(entry);
11141    }
11142    let presigned = ensure_ok(
11143        request(
11144            cfg,
11145            "POST",
11146            &format!("/api/hub/brains/{brain}/packs/presign"),
11147            Some(&meta),
11148            Auth::Required,
11149        )?,
11150        "prepare pack upload",
11151    )?;
11152    let url = presigned
11153        .get("url")
11154        .and_then(Value::as_str)
11155        .ok_or_else(|| LinkError::InvalidPack {
11156            message: "the hub returned no upload URL".to_string(),
11157        })?;
11158    put_presigned(
11159        cfg,
11160        url,
11161        presigned.get("headers").unwrap_or(&Value::Null),
11162        &pack,
11163    )?;
11164    let committed = ensure_ok(
11165        request(
11166            cfg,
11167            "POST",
11168            &format!("/api/hub/brains/{brain}/packs/commit"),
11169            Some(&meta),
11170            Auth::Required,
11171        )?,
11172        "commit pack",
11173    )?;
11174    Ok(committed)
11175}
11176
11177fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
11178    const LOCAL_HEADER: u32 = 0x0403_4b50;
11179    const CENTRAL_HEADER: u32 = 0x0201_4b50;
11180    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
11181    const VERSION_20: u16 = 20;
11182    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
11183    const UTF8_FLAG: u16 = 1 << 11;
11184    const STORED: u16 = 0;
11185    const DOS_TIME_MIDNIGHT: u16 = 0;
11186    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
11187    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
11188
11189    struct CentralEntry<'a> {
11190        name: &'a [u8],
11191        crc32: u32,
11192        size: u32,
11193        local_offset: u32,
11194    }
11195
11196    fn push_u16(out: &mut Vec<u8>, value: u16) {
11197        out.extend_from_slice(&value.to_le_bytes());
11198    }
11199
11200    fn push_u32(out: &mut Vec<u8>, value: u32) {
11201        out.extend_from_slice(&value.to_le_bytes());
11202    }
11203
11204    if files.is_empty() {
11205        return Err(LinkError::InvalidPack {
11206            message: "cannot create an empty snapshot pack".to_string(),
11207        });
11208    }
11209    if files.len() > u16::MAX as usize {
11210        return Err(LinkError::PushTooLarge {
11211            detail: format!(
11212                "{} files (canonical ZIP32 packs cap at {})",
11213                files.len(),
11214                u16::MAX
11215            ),
11216        });
11217    }
11218
11219    let mut sorted: Vec<_> = files.iter().collect();
11220    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
11221    let mut previous: Option<&str> = None;
11222    for (path, content) in &sorted {
11223        if !safe_store_rel_path(path) {
11224            return Err(LinkError::UnsafePath {
11225                path: (*path).clone(),
11226            });
11227        }
11228        if previous == Some(path.as_str()) {
11229            return Err(LinkError::InvalidPack {
11230                message: format!("duplicate path `{path}`"),
11231            });
11232        }
11233        previous = Some(path.as_str());
11234        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
11235            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11236        })?;
11237    }
11238
11239    let mut out = Vec::new();
11240    let mut central = Vec::with_capacity(sorted.len());
11241    for (path, content) in sorted {
11242        let name = path.as_bytes();
11243        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
11244            message: format!("ZIP entry name is too long: `{path}`"),
11245        })?;
11246        let bytes = content.as_bytes();
11247        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
11248            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11249        })?;
11250        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11251            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11252        })?;
11253        let crc32 = crc32fast::hash(bytes);
11254
11255        // Canonical local header: sizes and CRC are known up front. Bit 3 is
11256        // deliberately clear, so there is no trailing data descriptor.
11257        push_u32(&mut out, LOCAL_HEADER);
11258        push_u16(&mut out, VERSION_20);
11259        push_u16(&mut out, UTF8_FLAG);
11260        push_u16(&mut out, STORED);
11261        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11262        push_u16(&mut out, DOS_DATE_1980_01_01);
11263        push_u32(&mut out, crc32);
11264        push_u32(&mut out, size);
11265        push_u32(&mut out, size);
11266        push_u16(&mut out, name_len);
11267        push_u16(&mut out, 0); // no local extra data
11268        out.extend_from_slice(name);
11269        out.extend_from_slice(bytes);
11270
11271        central.push(CentralEntry {
11272            name,
11273            crc32,
11274            size,
11275            local_offset,
11276        });
11277    }
11278
11279    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11280        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11281    })?;
11282    for entry in &central {
11283        push_u32(&mut out, CENTRAL_HEADER);
11284        push_u16(&mut out, MADE_BY_UNIX_20);
11285        push_u16(&mut out, VERSION_20);
11286        push_u16(&mut out, UTF8_FLAG);
11287        push_u16(&mut out, STORED);
11288        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11289        push_u16(&mut out, DOS_DATE_1980_01_01);
11290        push_u32(&mut out, entry.crc32);
11291        push_u32(&mut out, entry.size);
11292        push_u32(&mut out, entry.size);
11293        push_u16(&mut out, entry.name.len() as u16);
11294        push_u16(&mut out, 0); // no central extra data
11295        push_u16(&mut out, 0); // no file comment
11296        push_u16(&mut out, 0); // disk number
11297        push_u16(&mut out, 0); // internal attributes
11298        push_u32(&mut out, UNIX_REGULAR_0600);
11299        push_u32(&mut out, entry.local_offset);
11300        out.extend_from_slice(entry.name);
11301    }
11302    let central_size = u32::try_from(out.len())
11303        .ok()
11304        .and_then(|end| end.checked_sub(central_offset))
11305        .ok_or_else(|| LinkError::PushTooLarge {
11306            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11307        })?;
11308    let entry_count = central.len() as u16;
11309
11310    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11311    push_u16(&mut out, 0); // this disk
11312    push_u16(&mut out, 0); // central directory disk
11313    push_u16(&mut out, entry_count);
11314    push_u16(&mut out, entry_count);
11315    push_u32(&mut out, central_size);
11316    push_u32(&mut out, central_offset);
11317    push_u16(&mut out, 0); // no archive comment
11318
11319    if out.len() > u32::MAX as usize {
11320        return Err(LinkError::PushTooLarge {
11321            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11322        });
11323    }
11324    Ok(out)
11325}
11326
11327// ─────────────────────────────────────────────────────────────────────────────
11328// grant — issue / list / revoke capabilities (owner-side)
11329// ─────────────────────────────────────────────────────────────────────────────
11330
11331/// The two capabilities a v0 hub enforces.
11332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11333pub enum Capability {
11334    /// Read the granted slice.
11335    Read,
11336    /// Read and push (whole-store; a path-scoped grant is read-only).
11337    Write,
11338}
11339
11340impl Capability {
11341    /// The wire form.
11342    pub fn as_str(self) -> &'static str {
11343        match self {
11344            Capability::Read => "read",
11345            Capability::Write => "write",
11346        }
11347    }
11348}
11349
11350/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
11351/// by email in v0 (the protocol's near-term simplification; key-named
11352/// grantees arrive with the signing layer). `scope` is a store-path prefix
11353/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
11354/// revoked.
11355pub fn grant_issue(
11356    cfg: &HubConfig,
11357    brain: &str,
11358    grantee: &str,
11359    can: Capability,
11360    scope: Option<&str>,
11361    until: Option<&str>,
11362) -> LinkResult<Value> {
11363    require_safe_ref(brain)?;
11364    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
11365    // multikey holder (link.md §6 cross-party keys — no hub account; the
11366    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
11367    // hub principal named by email.
11368    let is_key_grantee = URL_SAFE_NO_PAD
11369        .decode(grantee)
11370        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11371        .unwrap_or(false);
11372    if let Some(head) = v2_verified_head(cfg, brain)? {
11373        if is_key_grantee {
11374            let scope = scope.unwrap_or("");
11375            let preset = match can {
11376                Capability::Read => "viewer",
11377                Capability::Write => "editor",
11378            };
11379            let entropy = format!(
11380                "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11381                normalized_origin(&cfg.hub)?,
11382                head.brain_id,
11383                head.control_revision,
11384                grantee,
11385                preset,
11386                scope,
11387                until.unwrap_or("")
11388            );
11389            let mut body = json!({
11390                "context": "external",
11391                "expected_control_revision": head.control_revision,
11392                "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11393                "preset": preset,
11394                "principal_kind": "key",
11395                "public_key": grantee,
11396                "scope": scope,
11397                "scope_kind": "prefix",
11398            });
11399            if let Some(value) = until {
11400                body["expires_at"] = json!(value);
11401            }
11402            let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11403            let response = ensure_ok(
11404                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11405                "v2 grant issue",
11406            )?;
11407            let expected_fingerprint = identity_fingerprint(grantee)?;
11408            if response.get("v").and_then(Value::as_u64) != Some(2)
11409                || response
11410                    .get("id")
11411                    .and_then(Value::as_str)
11412                    .is_none_or(|id| !crate::ulid::is_ulid(id))
11413                || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11414                || response.get("principal_id").and_then(Value::as_str)
11415                    != Some(expected_fingerprint.as_str())
11416                || response
11417                    .get("control_revision")
11418                    .and_then(Value::as_str)
11419                    .is_none_or(|value| !is_sha256(value))
11420            {
11421                return Err(invalid_feed(
11422                    "v2 grant issue response is not authority-bound",
11423                ));
11424            }
11425            return Ok(response);
11426        }
11427        // Account invitations remain a product workflow. The compatibility
11428        // endpoint resolves email/account identity, then commits a native v2
11429        // grant with the same control-plane recovery boundary. The v2 head
11430        // above is already the authenticated authority checkpoint; never
11431        // fall through to the retired v1 feed verifier for this request.
11432        let mut body = json!({ "email": grantee, "capability": can.as_str() });
11433        if let Some(value) = scope {
11434            body["scopePrefix"] = json!(value);
11435        }
11436        if let Some(value) = until {
11437            body["expiresAt"] = json!(value);
11438        }
11439        let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11440        return ensure_ok(
11441            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11442            "account grant issue",
11443        );
11444    }
11445    let _ = verified_remote_head(cfg, brain, false)?;
11446    let mut body = if is_key_grantee {
11447        json!({ "keySpki": grantee, "capability": can.as_str() })
11448    } else {
11449        json!({ "email": grantee, "capability": can.as_str() })
11450    };
11451    if let Some(s) = scope {
11452        body["scopePrefix"] = json!(s);
11453    }
11454    if let Some(u) = until {
11455        body["expiresAt"] = json!(u);
11456    }
11457    let path = format!("/api/hub/brains/{brain}/grants");
11458    ensure_ok(
11459        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11460        "grant issue",
11461    )
11462}
11463
11464/// List the active grants (and pending invites) on `brain`. Owner-side.
11465pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11466    require_safe_ref(brain)?;
11467    if let Some(head) = v2_verified_head(cfg, brain)? {
11468        let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11469        let response = ensure_ok(
11470            request(cfg, "GET", &path, None, Auth::Required)?,
11471            "v2 grant list",
11472        )?;
11473        if response.get("v").and_then(Value::as_u64) != Some(2)
11474            || response.get("control_revision").and_then(Value::as_str)
11475                != Some(head.control_revision.as_str())
11476            || !response.get("grants").is_some_and(Value::is_array)
11477        {
11478            return Err(invalid_feed(
11479                "v2 grant list is not bound to the verified authority",
11480            ));
11481        }
11482        return Ok(response);
11483    }
11484    let _ = verified_remote_head(cfg, brain, false)?;
11485    let path = format!("/api/hub/brains/{brain}/grants");
11486    ensure_ok(
11487        request(cfg, "GET", &path, None, Auth::Required)?,
11488        "grant list",
11489    )
11490}
11491
11492/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
11493/// is soft on the hub (the audit trail survives).
11494pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11495    require_safe_ref(brain)?;
11496    require_safe_grant_id(grant_id)?;
11497    if let Some(head) = v2_verified_head(cfg, brain)? {
11498        let entropy = format!(
11499            "{}\0{}\0{}\0{}",
11500            normalized_origin(&cfg.hub)?,
11501            head.brain_id,
11502            head.control_revision,
11503            grant_id
11504        );
11505        let body = json!({
11506            "expected_control_revision": head.control_revision,
11507            "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11508        });
11509        let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11510        let response = ensure_ok(
11511            request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11512            "v2 grant revoke",
11513        )?;
11514        if response.get("v").and_then(Value::as_u64) != Some(2)
11515            || response.get("id").and_then(Value::as_str) != Some(grant_id)
11516            || response.get("revoked").and_then(Value::as_bool) != Some(true)
11517            || response
11518                .get("control_revision")
11519                .and_then(Value::as_str)
11520                .is_none_or(|value| !is_sha256(value))
11521        {
11522            return Err(invalid_feed(
11523                "v2 grant revocation response is not authority-bound",
11524            ));
11525        }
11526        return Ok(response);
11527    }
11528    let _ = verified_remote_head(cfg, brain, false)?;
11529    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11530    ensure_ok(
11531        request(cfg, "DELETE", &path, None, Auth::Required)?,
11532        "grant revoke",
11533    )
11534}
11535
11536// ─────────────────────────────────────────────────────────────────────────────
11537// permissioned v2 proposals — inspect, accept, reject
11538// ─────────────────────────────────────────────────────────────────────────────
11539
11540#[derive(Debug)]
11541struct VerifiedV2Proposal {
11542    value: Value,
11543    changes: Value,
11544    blobs: Vec<(String, u64, String)>,
11545}
11546
11547fn require_proposal_id(id: &str) -> LinkResult<()> {
11548    if crate::ulid::is_ulid(id) {
11549        Ok(())
11550    } else {
11551        Err(invalid_feed("proposal id is not a lowercase ULID"))
11552    }
11553}
11554
11555fn verified_v2_proposal(
11556    cfg: &HubConfig,
11557    head: &V2VerifiedHead,
11558    proposal_id: &str,
11559) -> LinkResult<VerifiedV2Proposal> {
11560    require_proposal_id(proposal_id)?;
11561    if head.view_kind != "full" {
11562        return Err(invalid_feed(
11563            "proposal review requires a full readable view",
11564        ));
11565    }
11566    let path = format!(
11567        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11568        head.brain_id
11569    );
11570    let value = ensure_ok(
11571        request_capped(
11572            cfg,
11573            "GET",
11574            &path,
11575            None,
11576            Auth::Required,
11577            MAX_FEED_RESPONSE_BYTES,
11578        )?,
11579        "v2 proposal",
11580    )?;
11581    verify_v2_proposal_value(head, proposal_id, value)
11582}
11583
11584fn verify_v2_proposal_value(
11585    head: &V2VerifiedHead,
11586    proposal_id: &str,
11587    value: Value,
11588) -> LinkResult<VerifiedV2Proposal> {
11589    if value.get("v").and_then(Value::as_u64) != Some(2) {
11590        return Err(invalid_feed("proposal response has an invalid version"));
11591    }
11592    let proposal = value
11593        .get("proposal")
11594        .and_then(Value::as_object)
11595        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11596    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11597        return Err(invalid_feed("proposal response changed its id"));
11598    }
11599    let payload_hash = proposal
11600        .get("payload_sha256")
11601        .and_then(Value::as_str)
11602        .filter(|hash| is_sha256(hash))
11603        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11604    let clear_hash = proposal
11605        .get("clear_sha256")
11606        .and_then(Value::as_str)
11607        .filter(|hash| is_sha256(hash))
11608        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11609    let submission_hash = proposal
11610        .get("submission_claim_sha256")
11611        .and_then(Value::as_str)
11612        .filter(|hash| is_sha256(hash))
11613        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11614    let submission = STANDARD
11615        .decode(
11616            proposal
11617                .get("submission_claim_base64")
11618                .and_then(Value::as_str)
11619                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11620        )
11621        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11622    let submission_value: Value = serde_json::from_slice(&submission)
11623        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11624    if crate::linkmd_v2::canonical_bytes(&submission_value)
11625        .map_err(|error| invalid_feed(error.to_string()))?
11626        != submission
11627        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11628            .map_err(|error| invalid_feed(error.to_string()))?
11629            != submission_hash
11630    {
11631        return Err(invalid_feed(
11632            "proposal submission claim is not canonical or addressed",
11633        ));
11634    }
11635    let envelope = submission_value
11636        .as_object()
11637        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11638    let claim = envelope
11639        .get("claim")
11640        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11641    let claim_object = claim
11642        .as_object()
11643        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11644    let actor_root = claim_object
11645        .get("actor_root")
11646        .and_then(Value::as_object)
11647        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11648    let public_key = envelope
11649        .get("public_key")
11650        .and_then(Value::as_str)
11651        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11652    let fingerprint = envelope
11653        .get("fingerprint")
11654        .and_then(Value::as_str)
11655        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11656    let signature = envelope
11657        .get("sig")
11658        .and_then(Value::as_str)
11659        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11660    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11661        .map_err(|error| invalid_feed(error.to_string()))?;
11662    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11663    let signer = format!("{fingerprint}:{public_key}");
11664    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11665    let grants = actor_root.get("grants").and_then(Value::as_array);
11666    let grants_are_canonical = grants.is_some_and(|items| {
11667        let mut prior: Option<&str> = None;
11668        items.iter().all(|item| {
11669            let Some(grant) = item.as_str() else {
11670                return false;
11671            };
11672            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11673                return false;
11674            }
11675            prior = Some(grant);
11676            true
11677        })
11678    });
11679    let optional_actor_field = |name: &str| {
11680        actor_root.get(name).is_some_and(|value| {
11681            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11682        })
11683    };
11684    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11685    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11686        || format!("{:x}", Sha256::digest(&der)) != fingerprint
11687        || head
11688            .trust
11689            .hub_signer
11690            .as_ref()
11691            .is_some_and(|known| known != &signer)
11692        || !matches!(
11693            actor_class,
11694            Some(
11695                "user"
11696                    | "owned_agent"
11697                    | "foreign_key"
11698                    | "curation"
11699                    | "inbox"
11700                    | "restore"
11701                    | "migration"
11702                    | "operator_recovery"
11703            )
11704        )
11705        || actor_root
11706            .get("principal")
11707            .and_then(Value::as_str)
11708            .is_none_or(|value| value.is_empty())
11709        || actor_root
11710            .get("credential")
11711            .and_then(Value::as_str)
11712            .is_none_or(|value| value.is_empty())
11713        || !optional_actor_field("organization")
11714        || !optional_actor_field("role")
11715        || !grants_are_canonical
11716        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11717        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11718        || !claim_object
11719            .get("mutation_id")
11720            .and_then(Value::as_str)
11721            .is_some_and(|value| {
11722                !value.is_empty()
11723                    && value.len() <= 128
11724                    && value.chars().enumerate().all(|(index, char)| {
11725                        char.is_ascii_alphanumeric()
11726                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11727                    })
11728            })
11729        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11730        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11731        || !claim_object
11732            .get("control_revision")
11733            .and_then(Value::as_str)
11734            .is_some_and(is_sha256)
11735        || submitted_at.is_none_or(|value| {
11736            chrono::DateTime::parse_from_rfc3339(value).is_err()
11737                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11738        })
11739        || !proposal
11740            .get("state")
11741            .and_then(Value::as_str)
11742            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11743        || proposal
11744            .get("expires_at")
11745            .and_then(Value::as_str)
11746            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11747        || proposal
11748            .get("proposer")
11749            .and_then(Value::as_object)
11750            .and_then(|value| value.get("class"))
11751            .and_then(Value::as_str)
11752            != actor_class
11753    {
11754        return Err(invalid_feed(
11755            "proposal submission claim does not bind the verified proposal",
11756        ));
11757    }
11758    let changes_b64 = proposal
11759        .get("changes_base64")
11760        .and_then(Value::as_str)
11761        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11762    let changes_bytes = STANDARD
11763        .decode(changes_b64)
11764        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11765    let changes: Value = serde_json::from_slice(&changes_bytes)
11766        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11767    if crate::linkmd_v2::canonical_bytes(&changes)
11768        .map_err(|error| invalid_feed(error.to_string()))?
11769        != changes_bytes
11770        || changes.get("v").and_then(Value::as_u64) != Some(2)
11771        || !changes.get("operations").is_some_and(Value::is_array)
11772    {
11773        return Err(invalid_feed("proposal changeset is not canonical v2"));
11774    }
11775    let blob_values = proposal
11776        .get("blobs")
11777        .and_then(Value::as_array)
11778        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11779    let mut blobs = Vec::with_capacity(blob_values.len());
11780    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11781    let mut prior_hash: Option<String> = None;
11782    for item in blob_values {
11783        let hash = item
11784            .get("sha256")
11785            .and_then(Value::as_str)
11786            .filter(|hash| is_sha256(hash))
11787            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11788        let bytes = item
11789            .get("bytes")
11790            .and_then(Value::as_u64)
11791            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11792            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11793        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11794            return Err(invalid_feed(
11795                "proposal blob declarations are not unique and sorted",
11796            ));
11797        }
11798        prior_hash = Some(hash.to_string());
11799        let endpoint = item
11800            .get("endpoint")
11801            .and_then(Value::as_str)
11802            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11803        let expected_endpoint = format!(
11804            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11805            head.brain_id
11806        );
11807        if endpoint != expected_endpoint {
11808            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11809        }
11810        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11811        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11812    }
11813    let descriptor = json!({
11814        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11815        "blobs": descriptor_blobs,
11816        "changes_base64": changes_b64,
11817        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11818        "v": 2,
11819    });
11820    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11821        .map_err(|error| invalid_feed(error.to_string()))?;
11822    if content_sha256(&descriptor_bytes) != clear_hash {
11823        return Err(invalid_feed(
11824            "proposal clear payload differs from its signed submission claim",
11825        ));
11826    }
11827    Ok(VerifiedV2Proposal {
11828        value,
11829        changes,
11830        blobs,
11831    })
11832}
11833
11834pub fn proposal_list(
11835    cfg: &HubConfig,
11836    brain: &str,
11837    state: &str,
11838    after: Option<&str>,
11839    limit: usize,
11840) -> LinkResult<Value> {
11841    require_safe_ref(brain)?;
11842    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11843        return Err(invalid_feed("proposal state is invalid"));
11844    }
11845    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11846        return Err(invalid_feed("proposal cursor is invalid"));
11847    }
11848    let head = v2_verified_head(cfg, brain)?
11849        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11850    let path = format!(
11851        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11852        head.brain_id,
11853        limit.clamp(1, 100),
11854        after.map_or_else(String::new, |value| format!("&after={value}"))
11855    );
11856    ensure_ok(
11857        request_capped(
11858            cfg,
11859            "GET",
11860            &path,
11861            None,
11862            Auth::Required,
11863            MAX_FEED_RESPONSE_BYTES,
11864        )?,
11865        "v2 proposal list",
11866    )
11867}
11868
11869pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11870    require_safe_ref(brain)?;
11871    let head = v2_verified_head(cfg, brain)?
11872        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11873    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11874}
11875
11876pub fn proposal_reject(
11877    cfg: &HubConfig,
11878    brain: &str,
11879    proposal_id: &str,
11880    mutation_id: &str,
11881    reason: &str,
11882) -> LinkResult<Value> {
11883    require_safe_ref(brain)?;
11884    require_proposal_id(proposal_id)?;
11885    let head = v2_verified_head(cfg, brain)?
11886        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11887    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11888    let body = json!({
11889        "mutation_id": mutation_id,
11890        "control_revision": head.control_revision,
11891        "reason": reason,
11892    });
11893    let path = format!(
11894        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11895        head.brain_id
11896    );
11897    ensure_ok(
11898        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11899        "v2 proposal rejection",
11900    )
11901}
11902
11903pub fn proposal_accept_exact(
11904    cfg: &HubConfig,
11905    brain: &str,
11906    proposal_id: &str,
11907    mutation_id: &str,
11908    reason: &str,
11909) -> LinkResult<Value> {
11910    require_safe_ref(brain)?;
11911    require_proposal_id(proposal_id)?;
11912    let head = v2_verified_head(cfg, brain)?
11913        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11914    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11915    let operations = proposal
11916        .changes
11917        .get("operations")
11918        .and_then(Value::as_array)
11919        .cloned()
11920        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11921    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11922        return Err(invalid_feed("proposal operation count is invalid"));
11923    }
11924    let mut downloaded = std::collections::BTreeMap::new();
11925    for (hash, bytes, endpoint) in &proposal.blobs {
11926        let body = ensure_raw_ok(
11927            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11928            "v2 proposal blob",
11929        )?;
11930        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11931            return Err(invalid_feed("proposal blob does not match its declaration"));
11932        }
11933        downloaded.insert(hash.clone(), body);
11934    }
11935    let remote = files_for_v2_view(
11936        &head,
11937        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
11938    );
11939    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
11940    let mut expected_candidate = remote.clone();
11941    let mut expected_candidate_assets = remote_assets;
11942    for operation in &operations {
11943        let op = operation
11944            .get("op")
11945            .and_then(Value::as_str)
11946            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
11947        match op {
11948            "put" | "restore" => {
11949                let path = operation
11950                    .get("path")
11951                    .and_then(Value::as_str)
11952                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
11953                crate::linkmd_v2::normalize_path(path)
11954                    .map_err(|error| invalid_feed(error.to_string()))?;
11955                let hash = operation
11956                    .get("blob")
11957                    .and_then(Value::as_str)
11958                    .filter(|hash| is_sha256(hash))
11959                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
11960                let bytes = operation
11961                    .get("bytes")
11962                    .and_then(Value::as_u64)
11963                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
11964                expected_candidate.insert(
11965                    path.to_string(),
11966                    V2BaselineFile {
11967                        sha256: hash.to_string(),
11968                        bytes,
11969                        proof: None,
11970                    },
11971                );
11972            }
11973            "delete" | "withdraw_from_hosting" => {
11974                let path = operation
11975                    .get("path")
11976                    .and_then(Value::as_str)
11977                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
11978                crate::linkmd_v2::normalize_path(path)
11979                    .map_err(|error| invalid_feed(error.to_string()))?;
11980                expected_candidate.remove(path);
11981            }
11982            "rename" => {
11983                let from = operation
11984                    .get("from")
11985                    .and_then(Value::as_str)
11986                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
11987                let to = operation
11988                    .get("to")
11989                    .and_then(Value::as_str)
11990                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
11991                crate::linkmd_v2::normalize_path(from)
11992                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
11993                    .map_err(|error| invalid_feed(error.to_string()))?;
11994                let hash = operation
11995                    .get("blob")
11996                    .and_then(Value::as_str)
11997                    .filter(|hash| is_sha256(hash))
11998                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
11999                let bytes = operation
12000                    .get("bytes")
12001                    .and_then(Value::as_u64)
12002                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12003                expected_candidate.remove(from);
12004                expected_candidate.insert(
12005                    to.to_string(),
12006                    V2BaselineFile {
12007                        sha256: hash.to_string(),
12008                        bytes,
12009                        proof: None,
12010                    },
12011                );
12012            }
12013            "asset_delete" => {
12014                let path = operation
12015                    .get("path")
12016                    .and_then(Value::as_str)
12017                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12018                expected_candidate_assets.remove(path);
12019            }
12020            "asset_withdraw" => {
12021                let path = operation
12022                    .get("path")
12023                    .and_then(Value::as_str)
12024                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12025                let asset = expected_candidate_assets
12026                    .get_mut(path)
12027                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
12028                asset.disposition = "withheld".to_string();
12029                asset.leaf_hash.clear();
12030            }
12031            "asset_put" | "asset_resume" => {
12032                let path = operation
12033                    .get("path")
12034                    .and_then(Value::as_str)
12035                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12036                let asset = operation
12037                    .get("asset")
12038                    .and_then(Value::as_object)
12039                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12040                let blob_sha256 = asset
12041                    .get("blob_sha256")
12042                    .and_then(Value::as_str)
12043                    .filter(|hash| is_sha256(hash))
12044                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12045                let bytes = asset
12046                    .get("bytes")
12047                    .and_then(Value::as_u64)
12048                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12049                let media_type = asset
12050                    .get("media_type")
12051                    .and_then(Value::as_str)
12052                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12053                let wrappers = asset
12054                    .get("wrappers")
12055                    .and_then(Value::as_array)
12056                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12057                    .iter()
12058                    .map(|wrapper| {
12059                        wrapper
12060                            .as_str()
12061                            .map(str::to_string)
12062                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12063                    })
12064                    .collect::<LinkResult<Vec<_>>>()?;
12065                let required = asset
12066                    .get("required")
12067                    .and_then(Value::as_bool)
12068                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12069                let disposition = asset
12070                    .get("disposition")
12071                    .and_then(Value::as_str)
12072                    .filter(|value| matches!(*value, "hosted" | "withheld"))
12073                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
12074                expected_candidate_assets.insert(
12075                    path.to_string(),
12076                    V2BaselineAsset {
12077                        blob_sha256: blob_sha256.to_string(),
12078                        bytes,
12079                        media_type: media_type.to_string(),
12080                        wrappers,
12081                        required,
12082                        disposition: disposition.to_string(),
12083                        leaf_hash: String::new(),
12084                    },
12085                );
12086            }
12087            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
12088        }
12089    }
12090    let base = head.pointer.as_ref().map(|pointer| {
12091        json!({
12092            "seq": pointer.seq,
12093            "commit_hash": pointer.commit_hash,
12094            "content_root": pointer.content_root,
12095            "asset_root": pointer.asset_root,
12096        })
12097    });
12098    let mut body = json!({
12099        "mutation_id": mutation_id,
12100        "base": base,
12101        "rebase": "strict",
12102        "reason": reason,
12103        "operations": operations,
12104        "blobs": downloaded
12105            .iter()
12106            .map(|(sha256, bytes)| json!({
12107                "sha256": sha256,
12108                "bytes": bytes.len(),
12109                "content_base64": STANDARD.encode(bytes),
12110            }))
12111            .collect::<Vec<_>>(),
12112        "proposal_id": proposal_id,
12113        "proposal_mode": "exact",
12114    });
12115    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
12116        total
12117            .checked_add(bytes.len())
12118            .ok_or_else(|| LinkError::PushTooLarge {
12119                detail: "proposal changed-byte total overflow".to_string(),
12120            })
12121    })?;
12122    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
12123        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12124        for operation in &operations {
12125            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
12126                return Err(invalid_feed("proposal upload operation has no kind"));
12127            };
12128            let hash = match kind {
12129                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
12130                "asset_put" | "asset_resume" => operation
12131                    .get("asset")
12132                    .and_then(|asset| asset.get("blob_sha256"))
12133                    .and_then(Value::as_str),
12134                _ => None,
12135            };
12136            let Some(hash) = hash else { continue };
12137            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
12138            if kind == "rename" {
12139                for field in ["from", "to"] {
12140                    coordinates.insert(
12141                        operation
12142                            .get(field)
12143                            .and_then(Value::as_str)
12144                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
12145                            .to_string(),
12146                    );
12147                }
12148            } else {
12149                let path = operation
12150                    .get("path")
12151                    .and_then(Value::as_str)
12152                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
12153                coordinates.insert(if kind.starts_with("asset_") {
12154                    format!("assets/{path}")
12155                } else {
12156                    path.to_string()
12157                });
12158            }
12159        }
12160        let declarations = downloaded
12161            .iter()
12162            .map(|(sha256, bytes)| {
12163                json!({
12164                    "sha256": sha256,
12165                    "bytes": bytes.len(),
12166                    "coordinates": coordinates_by_hash
12167                        .get(sha256)
12168                        .into_iter()
12169                        .flatten()
12170                        .collect::<Vec<_>>(),
12171                })
12172            })
12173            .collect::<Vec<_>>();
12174        let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
12175        for batch in batch_upload_declarations(declarations) {
12176            let reserved = reserve_upload_window(
12177                cfg,
12178                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
12179                &json!({ "blobs": batch }),
12180                "prepare proposal blob transport",
12181            )?;
12182            let reserved_items = reserved
12183                .get("uploads")
12184                .and_then(Value::as_array)
12185                .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
12186            items.extend(reserved_items.iter().cloned());
12187        }
12188        if items.len() != downloaded.len() {
12189            return Err(invalid_feed("proposal upload reservation changed the set"));
12190        }
12191        let mut references = Vec::with_capacity(items.len());
12192        for item in items {
12193            let hash = item
12194                .get("sha256")
12195                .and_then(Value::as_str)
12196                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
12197            let bytes = downloaded
12198                .get(hash)
12199                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
12200            let reservation_id = item
12201                .get("reservation_id")
12202                .and_then(Value::as_str)
12203                .filter(|id| crate::ulid::is_ulid(id))
12204                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
12205            let expected_coordinates = coordinates_by_hash
12206                .get(hash)
12207                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
12208            let returned_coordinates = item
12209                .get("coordinates")
12210                .and_then(Value::as_array)
12211                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
12212            if returned_coordinates.len() != expected_coordinates.len()
12213                || returned_coordinates
12214                    .iter()
12215                    .zip(expected_coordinates)
12216                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
12217            {
12218                return Err(invalid_feed(
12219                    "proposal upload reservation changed its coordinates",
12220                ));
12221            }
12222            match item.get("status").and_then(Value::as_str) {
12223                Some("upload") => put_presigned(
12224                    cfg,
12225                    item.get("url")
12226                        .and_then(Value::as_str)
12227                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
12228                    item.get("headers").unwrap_or(&Value::Null),
12229                    bytes,
12230                )?,
12231                Some("already_present") => {}
12232                _ => return Err(invalid_feed("proposal upload status is invalid")),
12233            }
12234            references.push(json!({
12235                "sha256": hash,
12236                "bytes": bytes.len(),
12237                "reservation_id": reservation_id,
12238            }));
12239        }
12240        body["blobs"] = Value::Array(references);
12241    }
12242    // A proposal can be queued through the staged lane, so acceptance has to be
12243    // able to restate it. Capping only this side would make a large proposal
12244    // permanently unacceptable.
12245    stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
12246    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
12247    let mut result = ensure_ok(
12248        request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
12249        "exact proposal acceptance",
12250    )?;
12251    let mut candidate_hub_signer = None;
12252    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12253        let request_id = result
12254            .get("request_id")
12255            .and_then(Value::as_str)
12256            .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12257            .to_string();
12258        let challenge = result
12259            .get("signing_challenge")
12260            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12261        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12262            cfg,
12263            &head,
12264            &expected_candidate,
12265            &expected_candidate_assets,
12266            mutation_id,
12267            &v2_signed_request_view(&body, &operations),
12268            challenge,
12269        )?;
12270        body["signing_challenge_id"] = Value::String(challenge_id);
12271        body["signature_base64url"] = Value::String(signature);
12272        candidate_hub_signer = Some(actor_signer);
12273        result = ensure_ok(
12274            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12275            "signed exact proposal acceptance",
12276        )?;
12277    }
12278    let refreshed = v2_verified_head(cfg, brain)?
12279        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12280    if candidate_hub_signer
12281        .as_ref()
12282        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12283        || refreshed
12284            .pointer
12285            .as_ref()
12286            .map(|pointer| pointer.commit_hash.as_str())
12287            != result.get("commit_hash").and_then(Value::as_str)
12288        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12289        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12290    {
12291        return Err(LinkError::RemoteAdvancedDuringSync);
12292    }
12293    accept_v2_head(cfg, &refreshed)?;
12294    Ok(result)
12295}
12296
12297// ─────────────────────────────────────────────────────────────────────────────
12298// propose — write without trust: evidence into the owner's inbox
12299// ─────────────────────────────────────────────────────────────────────────────
12300
12301/// Submit `body` to the published site `handle`, addressed to its app page
12302/// `app` (a page that declares the `write-inbox` capability). Deliberately
12303/// unauthenticated — this is the cross-party door; the submission lands as
12304/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
12305/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
12306/// receipt.
12307pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12308    require_valid_handle(handle)?;
12309    if body.len() as u64 > MAX_PROPOSE_BYTES {
12310        return Err(LinkError::ProposeTooLarge {
12311            bytes: body.len() as u64,
12312        });
12313    }
12314    let payload = json!({ "app": app, "body": body });
12315    // A ULID-shaped target is a bare brain address (link.md §7.4's
12316    // generalization): the brain inbox door, open on public brains, where a
12317    // configured credential earns a bigger actor-class budget. Anything else
12318    // is a published-site handle: that door is unauthenticated by design.
12319    let (path, auth) = if crate::ulid::is_ulid(handle) {
12320        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12321    } else {
12322        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12323    };
12324    ensure_ok(
12325        request(cfg, "POST", &path, Some(&payload), auth)?,
12326        "propose",
12327    )
12328}
12329
12330// ─────────────────────────────────────────────────────────────────────────────
12331// subscribe — follow feed-head movement
12332// ─────────────────────────────────────────────────────────────────────────────
12333
12334/// One observation of a brain's feed head.
12335#[derive(Debug, serde::Serialize)]
12336pub struct Head {
12337    /// The brain id.
12338    pub brain: String,
12339    /// The hub's durable feed cursor — advances on every accepted write.
12340    pub seq: u64,
12341    /// The hub's `updatedAt` for the brain, when present.
12342    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12343    pub updated_at: Option<String>,
12344    /// SHA-256 of the exact signed head entry.
12345    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12346    pub feed_hash: Option<String>,
12347    /// Whether the head entry's content hash, identity, and Ed25519 signature
12348    /// were verified locally. Path-scoped grants get head movement only.
12349    pub verified: bool,
12350}
12351
12352struct BoundedVecVisitor<T, const MAX: usize> {
12353    label: &'static str,
12354    marker: std::marker::PhantomData<T>,
12355}
12356
12357impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12358where
12359    T: Deserialize<'de>,
12360{
12361    type Value = Vec<T>;
12362
12363    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12364        write!(formatter, "at most {MAX} {}", self.label)
12365    }
12366
12367    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12368    where
12369        A: serde::de::SeqAccess<'de>,
12370    {
12371        if sequence.size_hint().is_some_and(|size| size > MAX) {
12372            return Err(serde::de::Error::custom(format!(
12373                "{} exceeds the {MAX}-item limit",
12374                self.label
12375            )));
12376        }
12377        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12378        while let Some(value) = sequence.next_element()? {
12379            if values.len() == MAX {
12380                return Err(serde::de::Error::custom(format!(
12381                    "{} exceeds the {MAX}-item limit",
12382                    self.label
12383                )));
12384            }
12385            values.push(value);
12386        }
12387        Ok(values)
12388    }
12389}
12390
12391fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12392    deserializer: D,
12393    label: &'static str,
12394) -> Result<Vec<T>, D::Error>
12395where
12396    D: serde::Deserializer<'de>,
12397    T: Deserialize<'de>,
12398{
12399    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12400        label,
12401        marker: std::marker::PhantomData,
12402    })
12403}
12404
12405fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12406where
12407    D: serde::Deserializer<'de>,
12408{
12409    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12410}
12411
12412fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12413where
12414    D: serde::Deserializer<'de>,
12415{
12416    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12417}
12418
12419fn deserialize_previous_identities<'de, D>(
12420    deserializer: D,
12421) -> Result<Vec<PreviousIdentity>, D::Error>
12422where
12423    D: serde::Deserializer<'de>,
12424{
12425    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12426        deserializer,
12427        "previous identities",
12428    )
12429}
12430
12431fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12432where
12433    D: serde::Deserializer<'de>,
12434{
12435    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12436        deserializer,
12437        "rotation statements",
12438    )
12439}
12440
12441fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12442where
12443    D: serde::Deserializer<'de>,
12444{
12445    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12446}
12447
12448#[derive(Debug, Clone, Deserialize, Serialize)]
12449struct FeedFile {
12450    path: String,
12451    sha256: String,
12452    bytes: u64,
12453}
12454
12455#[cfg(test)]
12456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12457enum V1DisclosureError {
12458    DuplicateFile,
12459    DuplicateRemoved,
12460    PushManifestMismatch,
12461    EditMissingChange,
12462    EditFalseFile,
12463    RemovedMismatch,
12464}
12465
12466/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
12467/// the exact previous and resulting pack manifests. `edit` accepts the
12468/// historical superset form while still requiring every actual changed path.
12469#[cfg(test)]
12470fn verify_v1_manifest_disclosure(
12471    kind: &str,
12472    previous: &[FeedFile],
12473    resulting: &[FeedFile],
12474    files: &[FeedFile],
12475    removed: &[String],
12476) -> Result<(), V1DisclosureError> {
12477    fn as_map(
12478        files: &[FeedFile],
12479    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12480        let mut result = std::collections::BTreeMap::new();
12481        for file in files {
12482            if result
12483                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12484                .is_some()
12485            {
12486                return Err(V1DisclosureError::DuplicateFile);
12487            }
12488        }
12489        Ok(result)
12490    }
12491    let previous = as_map(previous)?;
12492    let resulting = as_map(resulting)?;
12493    let disclosed = as_map(files)?;
12494    let removed_set: std::collections::BTreeSet<&str> =
12495        removed.iter().map(String::as_str).collect();
12496    if removed_set.len() != removed.len() {
12497        return Err(V1DisclosureError::DuplicateRemoved);
12498    }
12499    let expected_removed: std::collections::BTreeSet<&str> = previous
12500        .keys()
12501        .copied()
12502        .filter(|path| !resulting.contains_key(path))
12503        .collect();
12504    if removed_set != expected_removed {
12505        return Err(V1DisclosureError::RemovedMismatch);
12506    }
12507    if kind == "push" {
12508        return if disclosed == resulting {
12509            Ok(())
12510        } else {
12511            Err(V1DisclosureError::PushManifestMismatch)
12512        };
12513    }
12514    if kind != "edit" {
12515        return Err(V1DisclosureError::EditFalseFile);
12516    }
12517    if disclosed
12518        .iter()
12519        .any(|(path, value)| resulting.get(path) != Some(value))
12520    {
12521        return Err(V1DisclosureError::EditFalseFile);
12522    }
12523    for (path, value) in &resulting {
12524        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12525            return Err(V1DisclosureError::EditMissingChange);
12526        }
12527    }
12528    Ok(())
12529}
12530
12531#[derive(Debug, Clone, Deserialize, Serialize)]
12532struct FeedEntry {
12533    v: u8,
12534    seq: u64,
12535    ts: String,
12536    brain: String,
12537    public_key: String,
12538    kind: String,
12539    op: String,
12540    pack_sha256: String,
12541    #[serde(deserialize_with = "deserialize_feed_files")]
12542    files: Vec<FeedFile>,
12543    #[serde(deserialize_with = "deserialize_removed_paths")]
12544    removed: Vec<String>,
12545    prev_entry_hash: Option<String>,
12546    sig: String,
12547}
12548
12549#[derive(Serialize)]
12550struct UnsignedFeedEntry<'a> {
12551    v: u8,
12552    seq: u64,
12553    ts: &'a str,
12554    brain: &'a str,
12555    public_key: &'a str,
12556    kind: &'a str,
12557    op: &'a str,
12558    pack_sha256: &'a str,
12559    files: &'a [FeedFile],
12560    removed: &'a [String],
12561    prev_entry_hash: &'a Option<String>,
12562}
12563
12564#[derive(Debug, Clone, Deserialize, Serialize)]
12565struct FeedItem {
12566    hash: String,
12567    entry: FeedEntry,
12568}
12569
12570#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12571struct FeedIdentity {
12572    fingerprint: String,
12573    #[serde(rename = "publicKeySpki")]
12574    public_key_spki: String,
12575    /// Rotation history (link.md §9.1): identities this brain previously
12576    /// signed as. Entries verify against current OR previous — rotation
12577    /// never invalidates history.
12578    #[serde(default, deserialize_with = "deserialize_previous_identities")]
12579    previous: Vec<PreviousIdentity>,
12580    /// Exact normative rotation statements, oldest first. A list of previous
12581    /// public keys without these old-key signatures is not a trust chain.
12582    #[serde(default, deserialize_with = "deserialize_rotations")]
12583    rotations: Vec<String>,
12584}
12585
12586#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12587struct PreviousIdentity {
12588    fingerprint: String,
12589    #[serde(rename = "publicKeySpki")]
12590    public_key_spki: String,
12591}
12592
12593#[derive(Debug, Deserialize)]
12594struct FeedResponse {
12595    #[serde(rename = "headSeq")]
12596    head_seq: u64,
12597    #[serde(rename = "feedHash")]
12598    feed_hash: Option<String>,
12599    identity: Option<FeedIdentity>,
12600    #[serde(deserialize_with = "deserialize_feed_items")]
12601    entries: Vec<FeedItem>,
12602    #[serde(rename = "scopeLimited")]
12603    scope_limited: bool,
12604}
12605
12606#[derive(Debug, Deserialize, Serialize)]
12607#[serde(deny_unknown_fields)]
12608struct RotationStatement {
12609    v: u8,
12610    op: String,
12611    brain: String,
12612    public_key: String,
12613    new_brain: String,
12614    new_public_key: String,
12615    prior_head_seq: u64,
12616    prior_feed_hash: Option<String>,
12617    ts: String,
12618    sig: String,
12619}
12620
12621#[derive(Debug, Clone, Deserialize, Serialize)]
12622struct TrustState {
12623    v: u8,
12624    origin: String,
12625    /// The exact caller-visible ref whose resolution this checkpoint pins.
12626    /// Slugs/handles are mutable names at the hub; once observed, they may not
12627    /// silently resolve to a different canonical brain.
12628    #[serde(default)]
12629    requested: String,
12630    /// Canonical hub brain id returned for `requested`.
12631    brain: String,
12632    /// Federation home origin when this ref was learned from a registry.
12633    /// Once observed, the registry cannot silently relocate the same handle.
12634    #[serde(default, skip_serializing_if = "Option::is_none")]
12635    home: Option<String>,
12636    anchor: String,
12637    current: String,
12638    #[serde(rename = "headSeq")]
12639    head_seq: u64,
12640    #[serde(rename = "feedHash")]
12641    feed_hash: Option<String>,
12642    /// Exact accepted old-key-signed rotation statements, oldest first. v2
12643    /// checkpoints require this vector to be an immutable prefix of every
12644    /// subsequently served identity chain.
12645    #[serde(default)]
12646    rotations: Vec<String>,
12647    /// Hub pointer-signing identity pinned on first v2 observation. This is
12648    /// separate from the brain key, which signs commits.
12649    #[serde(default, skip_serializing_if = "Option::is_none")]
12650    hub_signer: Option<String>,
12651    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
12652    /// are recognized by their pinned hub signer during one-way migration.
12653    #[serde(default, skip_serializing_if = "Option::is_none")]
12654    protocol_profile: Option<String>,
12655}
12656
12657fn accepted_as_v2(state: &TrustState) -> bool {
12658    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12659}
12660
12661fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12662    let directory = open_trust_dir(cfg)?;
12663    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12664        return Ok(true);
12665    }
12666    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12667        return Ok(false);
12668    };
12669    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12670}
12671
12672#[derive(Debug, Clone, Deserialize, Serialize)]
12673struct AliasBinding {
12674    v: u8,
12675    origin: String,
12676    requested: String,
12677    brain: String,
12678    #[serde(default, skip_serializing_if = "Option::is_none")]
12679    home: Option<String>,
12680}
12681
12682struct VerifiedRemote {
12683    head: Head,
12684    identity: Option<FeedIdentity>,
12685    head_entry: Option<FeedItem>,
12686    /// Present when the caller requested and verified the complete chain.
12687    entries: Vec<FeedItem>,
12688    anchor: Option<String>,
12689}
12690
12691fn invalid_feed(message: impl Into<String>) -> LinkError {
12692    LinkError::InvalidFeed {
12693        message: message.into(),
12694    }
12695}
12696
12697fn is_sha256(value: &str) -> bool {
12698    value.len() == 64
12699        && value
12700            .bytes()
12701            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12702}
12703
12704fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12705    let der = URL_SAFE_NO_PAD
12706        .decode(public_key_spki)
12707        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12708    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12709        return Err(invalid_feed(
12710            "identity public key is not a valid Ed25519 SPKI",
12711        ));
12712    }
12713    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12714}
12715
12716/// Verify the hub's current identity and every old-key-signed rotation that
12717/// leads from the original TOFU anchor to it. `previous` alone is metadata,
12718/// never authority.
12719fn verify_identity_chain(
12720    identity: &FeedIdentity,
12721    pinned: Option<&TrustState>,
12722) -> LinkResult<String> {
12723    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12724        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12725    {
12726        return Err(invalid_feed(
12727            "identity rotation history exceeds the client cap",
12728        ));
12729    }
12730    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12731        return Err(invalid_feed(
12732            "current identity fingerprint does not match its public key",
12733        ));
12734    }
12735    for previous in &identity.previous {
12736        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12737            return Err(invalid_feed(
12738                "previous identity fingerprint does not match its public key",
12739            ));
12740        }
12741    }
12742    if identity.rotations.len() != identity.previous.len() {
12743        return Err(invalid_feed(
12744            "identity history is missing an old-key-signed rotation statement",
12745        ));
12746    }
12747
12748    // The HTTP identity lists prior identities newest first. Rotation
12749    // statements are chronological, so verification walks the reversed list
12750    // and ends at the current identity.
12751    let mut chain: Vec<(&str, &str)> = identity
12752        .previous
12753        .iter()
12754        .rev()
12755        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12756        .collect();
12757    chain.push((&identity.fingerprint, &identity.public_key_spki));
12758
12759    for (index, raw) in identity.rotations.iter().enumerate() {
12760        let statement: RotationStatement = serde_json::from_str(raw)
12761            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12762        let (old_fingerprint, old_spki) = chain[index];
12763        let (new_fingerprint, new_spki) = chain[index + 1];
12764        if statement.v != 1
12765            || statement.op != "rotate"
12766            || statement.brain != format!("ed25519:{old_fingerprint}")
12767            || statement.public_key != old_spki
12768            || statement.new_brain != format!("ed25519:{new_fingerprint}")
12769            || statement.new_public_key != new_spki
12770            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12771            || (statement.prior_head_seq > 0
12772                && statement
12773                    .prior_feed_hash
12774                    .as_deref()
12775                    .is_none_or(|hash| !is_sha256(hash)))
12776        {
12777            return Err(invalid_feed(
12778                "rotation statement does not connect adjacent identities",
12779            ));
12780        }
12781        let unsigned = serde_json::to_string(&UnsignedRotation {
12782            v: statement.v,
12783            op: &statement.op,
12784            brain: &statement.brain,
12785            public_key: &statement.public_key,
12786            new_brain: &statement.new_brain,
12787            new_public_key: &statement.new_public_key,
12788            prior_head_seq: statement.prior_head_seq,
12789            prior_feed_hash: statement.prior_feed_hash.as_deref(),
12790            ts: statement.ts.clone(),
12791        })
12792        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12793        let exact = format!(
12794            "{},\"sig\":\"{}\"}}",
12795            &unsigned[..unsigned.len() - 1],
12796            statement.sig
12797        );
12798        if exact != *raw {
12799            return Err(invalid_feed(
12800                "rotation statement is not in normative serialization",
12801            ));
12802        }
12803        let der = URL_SAFE_NO_PAD
12804            .decode(old_spki)
12805            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12806        let signature = URL_SAFE_NO_PAD
12807            .decode(&statement.sig)
12808            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12809        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12810            .verify(unsigned.as_bytes(), &signature)
12811            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12812        if index > 0 {
12813            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12814                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12815            if statement.prior_head_seq < prior.prior_head_seq {
12816                return Err(invalid_feed("rotation feed boundaries move backward"));
12817            }
12818        }
12819    }
12820
12821    let anchor = format!("ed25519:{}", chain[0].0);
12822    let current = format!("ed25519:{}", identity.fingerprint);
12823    if let Some(pin) = pinned {
12824        if pin.anchor != anchor {
12825            return Err(invalid_feed(
12826                "served identity chain does not descend from the pinned anchor",
12827            ));
12828        }
12829        if !chain
12830            .iter()
12831            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12832        {
12833            return Err(invalid_feed(
12834                "served identity chain forked away from the last pinned identity",
12835            ));
12836        }
12837        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12838            return Err(invalid_feed("served identity discarded its rotation chain"));
12839        }
12840        if pin.v >= 2
12841            && (identity.rotations.len() < pin.rotations.len()
12842                || identity.rotations[..pin.rotations.len()] != pin.rotations)
12843        {
12844            return Err(invalid_feed(
12845                "served identity rewrote the locally accepted rotation history",
12846            ));
12847        }
12848    }
12849    Ok(anchor)
12850}
12851
12852fn verify_rotation_feed_boundaries(
12853    identity: &FeedIdentity,
12854    pinned: Option<&TrustState>,
12855    observed: &[FeedItem],
12856    advertised_seq: u64,
12857) -> LinkResult<()> {
12858    let mut chain: Vec<String> = identity
12859        .previous
12860        .iter()
12861        .rev()
12862        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12863        .collect();
12864    chain.push(format!("ed25519:{}", identity.fingerprint));
12865    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12866
12867    for (index, raw) in identity.rotations.iter().enumerate() {
12868        let rotation: RotationStatement = serde_json::from_str(raw)
12869            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12870        if rotation.prior_head_seq > advertised_seq {
12871            return Err(invalid_feed(
12872                "rotation claims a feed boundary beyond the advertised head",
12873            ));
12874        }
12875        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12876            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12877                return Err(invalid_feed(
12878                    "newly disclosed rotation predates the local feed checkpoint",
12879                ));
12880            }
12881        }
12882        let actual = if rotation.prior_head_seq == 0 {
12883            None
12884        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12885            pinned.and_then(|pin| pin.feed_hash.as_deref())
12886        } else {
12887            observed
12888                .iter()
12889                .find(|item| item.entry.seq == rotation.prior_head_seq)
12890                .map(|item| item.hash.as_str())
12891        };
12892        if let Some(actual) = actual {
12893            if rotation.prior_feed_hash.as_deref() != Some(actual) {
12894                return Err(invalid_feed(
12895                    "rotation statement does not commit the verified feed boundary",
12896                ));
12897            }
12898        } else if rotation.prior_head_seq == 0 {
12899            // The empty-feed boundary is represented by (0, null); there is
12900            // no entry hash to find in `observed`.
12901        } else if pinned.is_some_and(|pin| {
12902            pinned_index.is_some_and(|pin_index| index >= pin_index)
12903                || rotation.prior_head_seq >= pin.head_seq
12904        }) {
12905            return Err(invalid_feed(
12906                "rotation feed boundary was not present in the verified chain",
12907            ));
12908        }
12909    }
12910    Ok(())
12911}
12912
12913/// Once a client has checkpointed identity K, identities retired before K may
12914/// verify old history but can never regain authority over a later sequence.
12915/// This is also the safe migration rule for legacy v1 checkpoints that did not
12916/// persist the exact accepted rotation statements.
12917fn reject_retired_signer_after_checkpoint(
12918    identity: &FeedIdentity,
12919    pinned: Option<&TrustState>,
12920    item: &FeedItem,
12921) -> LinkResult<()> {
12922    let Some(pin) = pinned else {
12923        return Ok(());
12924    };
12925    if item.entry.seq <= pin.head_seq {
12926        return Ok(());
12927    }
12928    let mut chain: Vec<String> = identity
12929        .previous
12930        .iter()
12931        .rev()
12932        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12933        .collect();
12934    chain.push(format!("ed25519:{}", identity.fingerprint));
12935    let pinned_index = chain
12936        .iter()
12937        .position(|key| key == &pin.current)
12938        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
12939    let signer_index = chain
12940        .iter()
12941        .position(|key| key == &item.entry.brain)
12942        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
12943    if signer_index < pinned_index {
12944        return Err(invalid_feed(
12945            "a retired identity attempted to sign after the local checkpoint",
12946        ));
12947    }
12948    Ok(())
12949}
12950
12951fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
12952    let origin = normalized_origin(&cfg.hub)?;
12953    let key = format!(
12954        "{:x}",
12955        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
12956    );
12957    Ok(format!("{key}.json"))
12958}
12959
12960fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
12961    let origin = normalized_origin(&cfg.hub)?;
12962    let key = format!(
12963        "{:x}",
12964        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
12965    );
12966    Ok(format!("alias-{key}.json"))
12967}
12968
12969#[cfg(any(unix, windows))]
12970struct TrustLock {
12971    _file: std::fs::File,
12972}
12973
12974#[cfg(unix)]
12975fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12976    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12977
12978    let lock_string = format!(".{state_name}.lock");
12979    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
12980    let fd = unsafe {
12981        libc::openat(
12982            directory.as_raw_fd(),
12983            lock_name.as_ptr(),
12984            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12985            0o600,
12986        )
12987    };
12988    if fd < 0 {
12989        return Err(std::io::Error::last_os_error().into());
12990    }
12991    let file = unsafe { std::fs::File::from_raw_fd(fd) };
12992    if !file.metadata()?.is_file() {
12993        return Err(LinkError::UnsafePath { path: lock_string });
12994    }
12995    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
12996        return Err(std::io::Error::last_os_error().into());
12997    }
12998    Ok(TrustLock { _file: file })
12999}
13000
13001#[cfg(windows)]
13002fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13003    let lock_name = format!(".{state_name}.lock");
13004    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13005    Ok(TrustLock { _file: file })
13006}
13007
13008#[cfg(any(unix, windows))]
13009fn lock_trust_many(
13010    cfg: &HubConfig,
13011    directory: &std::fs::File,
13012    refs: &[&str],
13013) -> LinkResult<Vec<TrustLock>> {
13014    let mut names = refs
13015        .iter()
13016        .map(|reference| trust_file_name(cfg, reference))
13017        .collect::<LinkResult<Vec<_>>>()?;
13018    names.sort();
13019    names.dedup();
13020    names
13021        .iter()
13022        .map(|name| lock_trust_name(directory, name))
13023        .collect()
13024}
13025
13026#[cfg(not(any(unix, windows)))]
13027fn lock_trust_many(
13028    _cfg: &HubConfig,
13029    _directory: &TrustDirectory,
13030    _refs: &[&str],
13031) -> LinkResult<Vec<()>> {
13032    Err(LinkError::UnsupportedPlatform {
13033        operation: "verified link.md state",
13034    })
13035}
13036
13037#[cfg(any(unix, windows))]
13038type TrustDirectory = std::fs::File;
13039
13040#[cfg(not(any(unix, windows)))]
13041struct TrustDirectory;
13042
13043#[cfg(unix)]
13044fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13045    use std::os::fd::AsRawFd as _;
13046
13047    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
13048    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
13049        return Err(std::io::Error::last_os_error().into());
13050    }
13051    directory.sync_all()?;
13052    Ok(directory)
13053}
13054
13055#[cfg(windows)]
13056fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13057    let marker = cfg.state_dir.join("trust").join(".directory");
13058    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
13059    Ok(crate::fsx::open_directory_nofollow(
13060        marker.parent().expect("trust marker has a parent"),
13061    )?)
13062}
13063
13064#[cfg(not(any(unix, windows)))]
13065fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13066    Err(LinkError::UnsupportedPlatform {
13067        operation: "verified link.md state",
13068    })
13069}
13070
13071#[cfg(unix)]
13072fn load_trust_in(
13073    cfg: &HubConfig,
13074    directory: &TrustDirectory,
13075    requested: &str,
13076) -> LinkResult<Option<TrustState>> {
13077    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13078
13079    let name_string = trust_file_name(cfg, requested)?;
13080    let name = c_name(name_string.as_bytes(), &name_string)?;
13081    let fd = unsafe {
13082        libc::openat(
13083            directory.as_raw_fd(),
13084            name.as_ptr(),
13085            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13086        )
13087    };
13088    if fd < 0 {
13089        let error = std::io::Error::last_os_error();
13090        if error.kind() == std::io::ErrorKind::NotFound {
13091            return Ok(None);
13092        }
13093        return Err(LinkError::UnsafePath { path: name_string });
13094    }
13095    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13096    if !file.metadata()?.is_file() {
13097        return Err(LinkError::UnsafePath { path: name_string });
13098    }
13099    let mut bytes = Vec::new();
13100    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
13101    if bytes.len() > 1024 * 1024 {
13102        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
13103    }
13104    let mut state: TrustState = serde_json::from_slice(&bytes)
13105        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13106    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13107        return Err(invalid_feed(
13108            "local identity/feed checkpoint does not match this hub and brain",
13109        ));
13110    }
13111    if state.v == 1 {
13112        // Legacy files were keyed by canonical brain id. They can migrate only
13113        // when the caller used that exact id; old slug lookups had no durable
13114        // alias binding and therefore cannot be guessed safely.
13115        if state.brain != requested {
13116            return Err(invalid_feed(
13117                "legacy checkpoint is not bound to the requested brain id",
13118            ));
13119        }
13120        state.requested = requested.to_string();
13121    } else if state.requested != requested {
13122        return Err(invalid_feed(
13123            "local identity/feed checkpoint is bound to a different requested ref",
13124        ));
13125    }
13126    Ok(Some(state))
13127}
13128
13129#[cfg(windows)]
13130fn load_trust_in(
13131    cfg: &HubConfig,
13132    directory: &TrustDirectory,
13133    requested: &str,
13134) -> LinkResult<Option<TrustState>> {
13135    let name = trust_file_name(cfg, requested)?;
13136    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13137    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
13138        Ok(bytes) => bytes,
13139        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13140        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13141    };
13142    let mut state: TrustState = serde_json::from_slice(&bytes)
13143        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13144    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13145        return Err(invalid_feed(
13146            "local identity/feed checkpoint does not match this hub and brain",
13147        ));
13148    }
13149    if state.v == 1 {
13150        if state.brain != requested {
13151            return Err(invalid_feed(
13152                "legacy checkpoint is not bound to the requested brain id",
13153            ));
13154        }
13155        state.requested = requested.to_string();
13156    } else if state.requested != requested {
13157        return Err(invalid_feed(
13158            "local identity/feed checkpoint is bound to a different requested ref",
13159        ));
13160    }
13161    Ok(Some(state))
13162}
13163
13164#[cfg(not(any(unix, windows)))]
13165fn load_trust_in(
13166    _cfg: &HubConfig,
13167    _directory: &TrustDirectory,
13168    _brain: &str,
13169) -> LinkResult<Option<TrustState>> {
13170    Err(LinkError::UnsupportedPlatform {
13171        operation: "verified link.md state",
13172    })
13173}
13174
13175#[cfg(all(test, any(unix, windows)))]
13176fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
13177    let directory = open_trust_dir(cfg)?;
13178    load_trust_in(cfg, &directory, requested)
13179}
13180
13181#[cfg(unix)]
13182fn save_trust_in(
13183    cfg: &HubConfig,
13184    directory: &TrustDirectory,
13185    state: &TrustState,
13186) -> LinkResult<()> {
13187    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13188
13189    let name_string = trust_file_name(cfg, &state.requested)?;
13190    let name = c_name(name_string.as_bytes(), &name_string)?;
13191    let mut bytes = serde_json::to_vec(state)
13192        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13193    bytes.push(b'\n');
13194
13195    let nonce = std::time::SystemTime::now()
13196        .duration_since(std::time::UNIX_EPOCH)
13197        .unwrap_or_default()
13198        .as_nanos();
13199    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13200    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13201    let fd = unsafe {
13202        libc::openat(
13203            directory.as_raw_fd(),
13204            temp.as_ptr(),
13205            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13206            0o600,
13207        )
13208    };
13209    if fd < 0 {
13210        return Err(std::io::Error::last_os_error().into());
13211    }
13212    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13213    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13214        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13215        return Err(error.into());
13216    }
13217    drop(file);
13218    if unsafe {
13219        libc::renameat(
13220            directory.as_raw_fd(),
13221            temp.as_ptr(),
13222            directory.as_raw_fd(),
13223            name.as_ptr(),
13224        )
13225    } != 0
13226    {
13227        let error = std::io::Error::last_os_error();
13228        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13229        return Err(error.into());
13230    }
13231    directory.sync_all()?;
13232    Ok(())
13233}
13234
13235#[cfg(windows)]
13236fn save_trust_in(
13237    cfg: &HubConfig,
13238    directory: &TrustDirectory,
13239    state: &TrustState,
13240) -> LinkResult<()> {
13241    let name = trust_file_name(cfg, &state.requested)?;
13242    let mut bytes = serde_json::to_vec(state)
13243        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13244    bytes.push(b'\n');
13245    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13246    Ok(())
13247}
13248
13249#[cfg(not(any(unix, windows)))]
13250fn save_trust_in(
13251    _cfg: &HubConfig,
13252    _directory: &TrustDirectory,
13253    _state: &TrustState,
13254) -> LinkResult<()> {
13255    Err(LinkError::UnsupportedPlatform {
13256        operation: "verified link.md state",
13257    })
13258}
13259
13260#[cfg(unix)]
13261fn load_alias_in(
13262    cfg: &HubConfig,
13263    directory: &TrustDirectory,
13264    requested: &str,
13265) -> LinkResult<Option<AliasBinding>> {
13266    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13267
13268    let name_string = alias_file_name(cfg, requested)?;
13269    let name = c_name(name_string.as_bytes(), &name_string)?;
13270    let fd = unsafe {
13271        libc::openat(
13272            directory.as_raw_fd(),
13273            name.as_ptr(),
13274            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13275        )
13276    };
13277    if fd < 0 {
13278        let error = std::io::Error::last_os_error();
13279        if error.kind() == std::io::ErrorKind::NotFound {
13280            return Ok(None);
13281        }
13282        return Err(LinkError::UnsafePath { path: name_string });
13283    }
13284    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13285    if !file.metadata()?.is_file() {
13286        return Err(LinkError::UnsafePath { path: name_string });
13287    }
13288    let mut bytes = Vec::new();
13289    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13290    if bytes.len() > 64 * 1024 {
13291        return Err(invalid_feed("local alias binding is oversized"));
13292    }
13293    let alias: AliasBinding = serde_json::from_slice(&bytes)
13294        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13295    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13296    {
13297        return Err(invalid_feed(
13298            "local alias binding does not match this hub and requested ref",
13299        ));
13300    }
13301    Ok(Some(alias))
13302}
13303
13304#[cfg(windows)]
13305fn load_alias_in(
13306    cfg: &HubConfig,
13307    directory: &TrustDirectory,
13308    requested: &str,
13309) -> LinkResult<Option<AliasBinding>> {
13310    let name = alias_file_name(cfg, requested)?;
13311    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13312    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13313        Ok(bytes) => bytes,
13314        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13315        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13316    };
13317    let alias: AliasBinding = serde_json::from_slice(&bytes)
13318        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13319    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13320    {
13321        return Err(invalid_feed(
13322            "local alias binding does not match this hub and requested ref",
13323        ));
13324    }
13325    Ok(Some(alias))
13326}
13327
13328#[cfg(not(any(unix, windows)))]
13329fn load_alias_in(
13330    _cfg: &HubConfig,
13331    _directory: &TrustDirectory,
13332    _requested: &str,
13333) -> LinkResult<Option<AliasBinding>> {
13334    Err(LinkError::UnsupportedPlatform {
13335        operation: "verified link.md state",
13336    })
13337}
13338
13339#[cfg(unix)]
13340fn save_alias_in(
13341    cfg: &HubConfig,
13342    directory: &TrustDirectory,
13343    alias: &AliasBinding,
13344) -> LinkResult<()> {
13345    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13346
13347    let name_string = alias_file_name(cfg, &alias.requested)?;
13348    let name = c_name(name_string.as_bytes(), &name_string)?;
13349    let mut bytes = serde_json::to_vec(alias)
13350        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13351    bytes.push(b'\n');
13352    let nonce = std::time::SystemTime::now()
13353        .duration_since(std::time::UNIX_EPOCH)
13354        .unwrap_or_default()
13355        .as_nanos();
13356    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13357    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13358    let fd = unsafe {
13359        libc::openat(
13360            directory.as_raw_fd(),
13361            temp.as_ptr(),
13362            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13363            0o600,
13364        )
13365    };
13366    if fd < 0 {
13367        return Err(std::io::Error::last_os_error().into());
13368    }
13369    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13370    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13371        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13372        return Err(error.into());
13373    }
13374    drop(file);
13375    if unsafe {
13376        libc::renameat(
13377            directory.as_raw_fd(),
13378            temp.as_ptr(),
13379            directory.as_raw_fd(),
13380            name.as_ptr(),
13381        )
13382    } != 0
13383    {
13384        let error = std::io::Error::last_os_error();
13385        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13386        return Err(error.into());
13387    }
13388    directory.sync_all()?;
13389    Ok(())
13390}
13391
13392#[cfg(windows)]
13393fn save_alias_in(
13394    cfg: &HubConfig,
13395    directory: &TrustDirectory,
13396    alias: &AliasBinding,
13397) -> LinkResult<()> {
13398    let name = alias_file_name(cfg, &alias.requested)?;
13399    let mut bytes = serde_json::to_vec(alias)
13400        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13401    bytes.push(b'\n');
13402    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13403    Ok(())
13404}
13405
13406#[cfg(not(any(unix, windows)))]
13407fn save_alias_in(
13408    _cfg: &HubConfig,
13409    _directory: &TrustDirectory,
13410    _alias: &AliasBinding,
13411) -> LinkResult<()> {
13412    Err(LinkError::UnsupportedPlatform {
13413        operation: "verified link.md state",
13414    })
13415}
13416
13417/// Load the canonical checkpoint shared by every spelling of one brain and
13418/// the separate alias binding. This also performs the one-way migration from
13419/// pre-v3 checkpoints that stored a full trust state under the alias itself.
13420/// Callers hold both alias and canonical locks before entering.
13421fn load_canonical_pin(
13422    cfg: &HubConfig,
13423    directory: &TrustDirectory,
13424    requested: &str,
13425    resolved_brain: &str,
13426) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13427    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13428    if requested == resolved_brain {
13429        return Ok((canonical, None));
13430    }
13431
13432    let mut alias = load_alias_in(cfg, directory, requested)?;
13433    if let Some(binding) = &alias {
13434        if binding.brain != resolved_brain {
13435            return Err(LinkError::AliasRebindRequired {
13436                alias: requested.to_string(),
13437                from: binding.brain.clone(),
13438                to: resolved_brain.to_string(),
13439            });
13440        }
13441        return Ok((canonical, alias));
13442    }
13443
13444    // A v2 build stored the complete checkpoint under the requested slug.
13445    // Promote it to the canonical ULID key before creating the lightweight
13446    // alias binding. Never silently merge two independently advanced states.
13447    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13448        if legacy.brain != resolved_brain {
13449            return Err(invalid_feed(
13450                "legacy alias checkpoint names a different canonical brain",
13451            ));
13452        }
13453        if let Some(existing) = &canonical {
13454            if existing.brain != legacy.brain
13455                || existing.anchor != legacy.anchor
13456                || existing.current != legacy.current
13457                || existing.head_seq != legacy.head_seq
13458                || existing.feed_hash != legacy.feed_hash
13459                || existing.rotations != legacy.rotations
13460            {
13461                return Err(invalid_feed(
13462                    "legacy alias checkpoint conflicts with the canonical checkpoint",
13463                ));
13464            }
13465        } else {
13466            let mut promoted = legacy.clone();
13467            promoted.requested = resolved_brain.to_string();
13468            promoted.home = None;
13469            save_trust_in(cfg, directory, &promoted)?;
13470            canonical = Some(promoted);
13471        }
13472        alias = Some(AliasBinding {
13473            v: 1,
13474            origin: normalized_origin(&cfg.hub)?,
13475            requested: requested.to_string(),
13476            brain: resolved_brain.to_string(),
13477            home: legacy.home,
13478        });
13479        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13480    }
13481    Ok((canonical, alias))
13482}
13483
13484/// Explicitly replace one mutable alias binding after the operator reviews the
13485/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
13486/// or rewritten, so old history remains pinned and the new brain establishes
13487/// its own independently verified trust chain.
13488pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13489    require_hardened_filesystem("verified alias rebind")?;
13490    require_safe_ref(alias)?;
13491    require_safe_ref(from)?;
13492    require_safe_ref(to)?;
13493    if crate::ulid::is_ulid(alias)
13494        || !crate::ulid::is_ulid(from)
13495        || !crate::ulid::is_ulid(to)
13496        || from == to
13497    {
13498        return Err(LinkError::InvalidPack {
13499            message:
13500                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13501                    .to_string(),
13502        });
13503    }
13504
13505    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13506        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13507    })?;
13508    accept_v2_head(cfg, &verified)?;
13509
13510    let alias_response = ensure_ok(
13511        request(
13512            cfg,
13513            "GET",
13514            &format!("/api/hub/brains/{alias}/v2/head"),
13515            None,
13516            Auth::Required,
13517        )?,
13518        "resolve alias for explicit rebind",
13519    )?;
13520    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13521        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13522    if resolved.v != 2 || resolved.brain_id != to {
13523        return Err(LinkError::RemoteAdvancedDuringSync);
13524    }
13525
13526    let directory = open_trust_dir(cfg)?;
13527    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13528    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13529        message: "the requested alias has no existing local binding to replace".to_string(),
13530    })?;
13531    if binding.brain != from {
13532        return Err(LinkError::AliasRebindRequired {
13533            alias: alias.to_string(),
13534            from: binding.brain,
13535            to: to.to_string(),
13536        });
13537    }
13538    save_alias_in(
13539        cfg,
13540        &directory,
13541        &AliasBinding {
13542            v: 1,
13543            origin: normalized_origin(&cfg.hub)?,
13544            requested: alias.to_string(),
13545            brain: to.to_string(),
13546            home: binding.home,
13547        },
13548    )?;
13549    Ok(json!({
13550        "v": 2,
13551        "alias": alias,
13552        "from": from,
13553        "to": to,
13554        "outcome": "alias_rebound",
13555    }))
13556}
13557
13558fn save_canonical_pin_and_alias(
13559    cfg: &HubConfig,
13560    directory: &TrustDirectory,
13561    requested: &str,
13562    resolved_brain: &str,
13563    mut state: TrustState,
13564    existing_alias: Option<&AliasBinding>,
13565) -> LinkResult<()> {
13566    state.requested = resolved_brain.to_string();
13567    state.brain = resolved_brain.to_string();
13568    state.home = None;
13569    save_trust_in(cfg, directory, &state)?;
13570    if requested != resolved_brain {
13571        save_alias_in(
13572            cfg,
13573            directory,
13574            &AliasBinding {
13575                v: 1,
13576                origin: normalized_origin(&cfg.hub)?,
13577                requested: requested.to_string(),
13578                brain: resolved_brain.to_string(),
13579                home: existing_alias.and_then(|alias| alias.home.clone()),
13580            },
13581        )?;
13582    }
13583    Ok(())
13584}
13585
13586fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13587    const ED25519_SPKI_PREFIX: &[u8] = &[
13588        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13589    ];
13590    let entry = &item.entry;
13591    let public_der = URL_SAFE_NO_PAD
13592        .decode(&entry.public_key)
13593        .map_err(|_| invalid_feed("public key is not base64url"))?;
13594    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13595        || !public_der.starts_with(ED25519_SPKI_PREFIX)
13596    {
13597        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13598    }
13599    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13600    if entry.brain != format!("ed25519:{fingerprint}") {
13601        return Err(invalid_feed(
13602            "brain fingerprint does not match its public key",
13603        ));
13604    }
13605    // Verify the complete rotation authority before using any previous key.
13606    let _ = verify_identity_chain(identity, None)?;
13607    let mut chain: Vec<(&str, &str)> = identity
13608        .previous
13609        .iter()
13610        .rev()
13611        .map(|previous| {
13612            (
13613                previous.fingerprint.as_str(),
13614                previous.public_key_spki.as_str(),
13615            )
13616        })
13617        .collect();
13618    chain.push((&identity.fingerprint, &identity.public_key_spki));
13619    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13620        *known_fingerprint == fingerprint && *spki == entry.public_key
13621    });
13622    let Some(signer_index) = signer_index else {
13623        return Err(invalid_feed(
13624            "entry signer is not this brain's identity (current or rotated-from)",
13625        ));
13626    };
13627    let lower_boundary = if signer_index == 0 {
13628        None
13629    } else {
13630        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13631            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13632        Some(prior.prior_head_seq)
13633    };
13634    let upper_boundary = if signer_index == identity.rotations.len() {
13635        None
13636    } else {
13637        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13638            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13639        Some(next.prior_head_seq)
13640    };
13641    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13642        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13643    {
13644        return Err(invalid_feed(
13645            "entry signer is outside its authenticated rotation epoch",
13646        ));
13647    }
13648    let unsigned = UnsignedFeedEntry {
13649        v: entry.v,
13650        seq: entry.seq,
13651        ts: &entry.ts,
13652        brain: &entry.brain,
13653        public_key: &entry.public_key,
13654        kind: &entry.kind,
13655        op: &entry.op,
13656        pack_sha256: &entry.pack_sha256,
13657        files: &entry.files,
13658        removed: &entry.removed,
13659        prev_entry_hash: &entry.prev_entry_hash,
13660    };
13661    let message =
13662        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13663    let signature = URL_SAFE_NO_PAD
13664        .decode(&entry.sig)
13665        .map_err(|_| invalid_feed("signature is not base64url"))?;
13666    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13667        .verify(&message, &signature)
13668        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13669
13670    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13671    exact.push(b'\n');
13672    let actual_hash = format!("{:x}", Sha256::digest(&exact));
13673    if actual_hash != item.hash {
13674        return Err(invalid_feed("entry SHA-256 does not match"));
13675    }
13676    Ok(())
13677}
13678
13679// ─────────────────────────────────────────────────────────────────────────────
13680// key rotation — link.md §9.1: the new key, signed by the old one
13681// ─────────────────────────────────────────────────────────────────────────────
13682
13683/// The unsigned rotation statement in its normative field order.
13684#[derive(Serialize)]
13685struct UnsignedRotation<'a> {
13686    v: u8,
13687    op: &'a str,
13688    brain: &'a str,
13689    public_key: &'a str,
13690    new_brain: &'a str,
13691    new_public_key: &'a str,
13692    prior_head_seq: u64,
13693    prior_feed_hash: Option<&'a str>,
13694    ts: String,
13695}
13696
13697/// Durable intent for an in-flight key rotation. The hub's recovery contract
13698/// identifies an ambiguous retry by the exact statement bytes, so the
13699/// statement cannot be reconstructed from the key and feed boundary later:
13700/// its timestamp and signature would differ.
13701#[derive(Debug, Deserialize, Serialize)]
13702#[serde(deny_unknown_fields)]
13703struct RotationJournal {
13704    v: u8,
13705    origin: String,
13706    brain: String,
13707    old_brain: String,
13708    new_brain: String,
13709    prior_head_seq: u64,
13710    prior_feed_hash: Option<String>,
13711    statement: String,
13712}
13713
13714fn rotation_journal_path(key_path: &Path) -> PathBuf {
13715    let mut path = key_path.as_os_str().to_os_string();
13716    path.push(".rotation.json");
13717    PathBuf::from(path)
13718}
13719
13720fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13721    #[cfg(unix)]
13722    let file = {
13723        use std::os::fd::{AsRawFd as _, FromRawFd as _};
13724        use std::os::unix::ffi::OsStrExt as _;
13725        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13726            .map_err(|error| {
13727                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13728            })?;
13729        let leaf_name = path
13730            .file_name()
13731            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13732        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13733        let fd = unsafe {
13734            libc::openat(
13735                parent.as_raw_fd(),
13736                leaf.as_ptr(),
13737                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13738            )
13739        };
13740        if fd < 0 {
13741            return Err(bad_agent_key(
13742                "the rotation journal must be an existing regular file without symlink ancestors",
13743            ));
13744        }
13745        unsafe { std::fs::File::from_raw_fd(fd) }
13746    };
13747    #[cfg(not(unix))]
13748    let file = std::fs::File::open(path)
13749        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13750    let metadata = file
13751        .metadata()
13752        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13753    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13754        return Err(bad_agent_key(
13755            "the rotation journal must be a bounded regular file",
13756        ));
13757    }
13758    #[cfg(unix)]
13759    {
13760        use std::os::unix::fs::PermissionsExt as _;
13761        if metadata.permissions().mode() & 0o077 != 0 {
13762            return Err(bad_agent_key(
13763                "the rotation journal is accessible to group/other; set mode 0600",
13764            ));
13765        }
13766    }
13767    serde_json::from_reader(file)
13768        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13769}
13770
13771fn remove_rotation_journal(path: &Path) {
13772    #[cfg(unix)]
13773    {
13774        use std::os::fd::AsRawFd as _;
13775        use std::os::unix::ffi::OsStrExt as _;
13776        let Ok(parent) =
13777            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13778        else {
13779            return;
13780        };
13781        let Some(leaf_name) = path.file_name() else {
13782            return;
13783        };
13784        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13785            return;
13786        };
13787        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13788            let _ = parent.sync_all();
13789        }
13790    }
13791    #[cfg(not(unix))]
13792    {
13793        let _ = std::fs::remove_file(path);
13794    }
13795}
13796
13797fn validate_rotation_journal(
13798    journal: &RotationJournal,
13799    cfg: &HubConfig,
13800    canonical_brain: &str,
13801    old_key: &AgentSigningKey,
13802    new_key: &AgentSigningKey,
13803    head: &Head,
13804) -> LinkResult<()> {
13805    if journal.v != 1
13806        || journal.origin != normalized_origin(&cfg.hub)?
13807        || journal.brain != canonical_brain
13808        || journal.old_brain != old_key.multikey
13809        || journal.new_brain != new_key.multikey
13810        || journal.prior_head_seq != head.seq
13811        || journal.prior_feed_hash != head.feed_hash
13812    {
13813        return Err(invalid_feed(
13814            "rotation journal does not match the verified key and feed boundary",
13815        ));
13816    }
13817    let statement: RotationStatement = serde_json::from_str(&journal.statement)
13818        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13819    if statement.prior_head_seq != journal.prior_head_seq
13820        || statement.prior_feed_hash != journal.prior_feed_hash
13821        || statement.brain != old_key.multikey
13822        || statement.public_key != old_key.public_key_spki
13823        || statement.new_brain != new_key.multikey
13824        || statement.new_public_key != new_key.public_key_spki
13825    {
13826        return Err(invalid_feed(
13827            "rotation journal statement does not match its durable intent",
13828        ));
13829    }
13830    let identity = FeedIdentity {
13831        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13832        public_key_spki: new_key.public_key_spki.clone(),
13833        previous: vec![PreviousIdentity {
13834            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13835            public_key_spki: old_key.public_key_spki.clone(),
13836        }],
13837        rotations: vec![journal.statement.clone()],
13838    };
13839    verify_identity_chain(&identity, None)?;
13840    Ok(())
13841}
13842
13843/// What `dbmd key rotate` returns.
13844#[derive(Debug, Serialize)]
13845pub struct RotationReport {
13846    /// The brain id rotated.
13847    pub brain: String,
13848    /// The NEW identity the hub now serves.
13849    pub multikey: String,
13850    /// Where the new PKCS#8 secret landed (0600).
13851    #[serde(rename = "keyFile")]
13852    pub key_file: String,
13853    /// Prior identities (newest first) the feed still verifies against.
13854    pub previous: Vec<String>,
13855}
13856
13857/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
13858/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
13859/// OLD key in normative serialization — and send it to the hub. The new secret
13860/// is durably created at 0600 before the POST; an existing output is reused for
13861/// idempotent retry/reconciliation. The old key is left untouched.
13862pub fn rotate_brain_key(
13863    cfg: &HubConfig,
13864    brain: &str,
13865    old_key: &AgentSigningKey,
13866    out: &Path,
13867) -> LinkResult<RotationReport> {
13868    require_hardened_filesystem("key rotation")?;
13869    require_safe_ref(brain)?;
13870    // The new private key must be durable *before* the hub can accept its
13871    // public half. An existing file is the retry/reconciliation path after an
13872    // ambiguous network failure: reuse it, never generate another identity.
13873    let new_key = if out.exists() {
13874        load_signing_key(out)?
13875    } else {
13876        let rng = ring::rand::SystemRandom::new();
13877        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13878            .map_err(|_| bad_agent_key("key generation failed"))?;
13879        let pair = agent_keypair(pkcs8.as_ref())?;
13880        let (public_key_spki, multikey) = public_identity_for(&pair);
13881        write_secret_new(
13882            out,
13883            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13884        )?;
13885        AgentSigningKey {
13886            pkcs8: pkcs8.as_ref().to_vec(),
13887            multikey,
13888            public_key_spki,
13889        }
13890    };
13891    let new_spki = new_key.public_key_spki.clone();
13892    let new_multikey = new_key.multikey.clone();
13893    let journal_path = rotation_journal_path(out);
13894    let before_v2 = v2_verified_head(cfg, brain)?;
13895    let (canonical_brain, served_identity, observed_head, v2_profile) =
13896        if let Some(head) = before_v2 {
13897            let observed = Head {
13898                brain: head.brain_id.clone(),
13899                seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13900                updated_at: head
13901                    .pointer
13902                    .as_ref()
13903                    .map(|pointer| pointer.signed_at.clone()),
13904                feed_hash: head
13905                    .pointer
13906                    .as_ref()
13907                    .map(|pointer| pointer.feed_hash.clone()),
13908                verified: true,
13909            };
13910            let identity = v2_identity(&head.identity);
13911            let canonical = head.brain_id.clone();
13912            accept_v2_head(cfg, &head)?;
13913            (canonical, identity, observed, true)
13914        } else {
13915            let remote = verified_remote_head(cfg, brain, false)?;
13916            let identity = remote
13917                .identity
13918                .clone()
13919                .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13920            (remote.head.brain.clone(), identity, remote.head, false)
13921        };
13922    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13923    let already_rotated = served_multikey == new_multikey;
13924    // A completed invocation removes the journal only after the server has
13925    // acknowledged its recovery boundary. If the verified identity advanced
13926    // but the journal remains, the prior response was ambiguous: replay the
13927    // exact signed statement so a v2 hub can repair/confirm its checkpoint.
13928    if already_rotated && !journal_path.exists() {
13929        remove_rotation_journal(&journal_path);
13930        return Ok(RotationReport {
13931            brain: brain.to_string(),
13932            multikey: new_multikey,
13933            key_file: out.display().to_string(),
13934            previous: served_identity
13935                .previous
13936                .iter()
13937                .map(|identity| format!("ed25519:{}", identity.fingerprint))
13938                .collect(),
13939        });
13940    }
13941    if !already_rotated && served_multikey != old_key.multikey {
13942        return Err(invalid_feed(
13943            "the supplied old key is not the brain's verified current identity",
13944        ));
13945    }
13946
13947    let journal = if journal_path.exists() {
13948        read_rotation_journal(&journal_path)?
13949    } else {
13950        let ts = crate::now()
13951            .with_timezone(&chrono::Utc)
13952            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
13953            .to_string();
13954        let unsigned = serde_json::to_string(&UnsignedRotation {
13955            v: 1,
13956            op: "rotate",
13957            brain: &old_key.multikey,
13958            public_key: &old_key.public_key_spki,
13959            new_brain: &new_multikey,
13960            new_public_key: &new_spki,
13961            prior_head_seq: observed_head.seq,
13962            prior_feed_hash: observed_head.feed_hash.as_deref(),
13963            ts,
13964        })
13965        .expect("serialize rotation");
13966        let old_pair = agent_keypair(&old_key.pkcs8)?;
13967        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13968        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
13969        let journal = RotationJournal {
13970            v: 1,
13971            origin: normalized_origin(&cfg.hub)?,
13972            brain: canonical_brain.clone(),
13973            old_brain: old_key.multikey.clone(),
13974            new_brain: new_multikey.clone(),
13975            prior_head_seq: observed_head.seq,
13976            prior_feed_hash: observed_head.feed_hash.clone(),
13977            statement,
13978        };
13979        let mut exact = serde_json::to_vec(&journal)
13980            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
13981        exact.push(b'\n');
13982        if write_secret_new(&journal_path, &exact).is_err() {
13983            // A concurrent retry may have won the O_EXCL race. Only an exact,
13984            // fully validated journal is allowed to recover that race.
13985            read_rotation_journal(&journal_path)?
13986        } else {
13987            journal
13988        }
13989    };
13990    validate_rotation_journal(
13991        &journal,
13992        cfg,
13993        &canonical_brain,
13994        old_key,
13995        &new_key,
13996        &observed_head,
13997    )?;
13998
13999    let body = json!({ "statement": journal.statement });
14000    let path = format!("/api/hub/brains/{brain}/rotate");
14001    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14002    let attempted_failure = match attempted {
14003        Ok(response) if (200..300).contains(&response.status) => None,
14004        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14005        Err(error) => Some(error),
14006    };
14007
14008    // A 2xx body is not authority, and a failed response may have followed a
14009    // committed mutation. In both cases success means the normal verifier sees
14010    // an append-only rotation chain ending at the durable new key.
14011    let identity = if v2_profile {
14012        match v2_verified_head(cfg, brain) {
14013            Ok(Some(after)) => {
14014                let identity = v2_identity(&after.identity);
14015                accept_v2_head(cfg, &after)?;
14016                identity
14017            }
14018            Ok(None) => {
14019                return Err(attempted_failure.unwrap_or_else(|| {
14020                    invalid_feed("rotated v2 brain no longer serves a v2 head")
14021                }));
14022            }
14023            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14024        }
14025    } else {
14026        match verified_remote_head(cfg, brain, false) {
14027            Ok(after) => after
14028                .identity
14029                .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14030            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14031        }
14032    };
14033    if format!("ed25519:{}", identity.fingerprint) != new_multikey
14034        || identity.public_key_spki != new_spki
14035    {
14036        return Err(attempted_failure.unwrap_or_else(|| {
14037            invalid_feed("hub acknowledged rotation without committing the verified new identity")
14038        }));
14039    }
14040    if v2_profile {
14041        if let Some(error) = attempted_failure {
14042            // Seeing the new public identity is insufficient: v2 rotation is
14043            // successful only after the endpoint has forced and acknowledged
14044            // the recovery checkpoint that contains it. Keep the journal for
14045            // an exact retry after any non-2xx or transport ambiguity.
14046            return Err(error);
14047        }
14048    }
14049    let previous = identity
14050        .previous
14051        .iter()
14052        .map(|prior| format!("ed25519:{}", prior.fingerprint))
14053        .collect();
14054    remove_rotation_journal(&journal_path);
14055
14056    Ok(RotationReport {
14057        brain: brain.to_string(),
14058        multikey: new_multikey,
14059        key_file: out.display().to_string(),
14060        previous,
14061    })
14062}
14063
14064// ─────────────────────────────────────────────────────────────────────────────
14065// mirror — verified replication: the whole feed + files, re-servable
14066// ─────────────────────────────────────────────────────────────────────────────
14067
14068/// What `dbmd mirror` materialized.
14069#[derive(Debug, Serialize)]
14070pub struct MirrorReport {
14071    /// The brain id.
14072    pub brain: String,
14073    /// The mirrored feed head.
14074    #[serde(rename = "headSeq")]
14075    pub head_seq: u64,
14076    /// The head entry hash (the feed's advertised converged state).
14077    #[serde(rename = "feedHash")]
14078    pub feed_hash: Option<String>,
14079    /// Signed feed entries verified and stored.
14080    pub entries: u64,
14081    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
14082    pub pinned: String,
14083    /// Store files materialized by the pull.
14084    pub files: usize,
14085}
14086
14087/// The mirror state directory, relative to the mirror root.
14088pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
14089
14090/// Fully re-verified mirror material suitable for a read-only re-server.
14091#[derive(Debug)]
14092pub struct VerifiedMirrorMaterial {
14093    pub brain: String,
14094    pub head_seq: u64,
14095    pub feed_hash: Option<String>,
14096    pub identity: serde_json::Value,
14097    /// Sequence, exact normative entry JSON without newline, and entry hash.
14098    pub entries: Vec<(u64, String, String)>,
14099    pub pack_sha256: Option<String>,
14100}
14101
14102#[derive(Deserialize)]
14103#[serde(deny_unknown_fields)]
14104struct StoredMirrorHead {
14105    brain: String,
14106    #[serde(rename = "headSeq")]
14107    head_seq: u64,
14108    #[serde(rename = "feedHash")]
14109    feed_hash: Option<String>,
14110}
14111
14112/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
14113/// exact snapshot pack before `dbmd serve` exposes any bytes.
14114pub fn verify_mirror_material(
14115    head_bytes: &[u8],
14116    identity_bytes: &[u8],
14117    feed_bytes: &[Vec<u8>],
14118    snapshot_pack: Option<&[u8]>,
14119    expected_anchor: &str,
14120) -> LinkResult<VerifiedMirrorMaterial> {
14121    let snapshot_hash = snapshot_pack
14122        .filter(|pack| !pack.is_empty())
14123        .map(content_sha256);
14124    verify_mirror_material_with_pack_hash(
14125        head_bytes,
14126        identity_bytes,
14127        feed_bytes,
14128        snapshot_hash.as_deref(),
14129        expected_anchor,
14130    )
14131}
14132
14133/// Re-verify mirror metadata against a snapshot digest computed from a held
14134/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
14135/// large pack without ever buffering the pack in process memory.
14136pub fn verify_mirror_material_with_pack_hash(
14137    head_bytes: &[u8],
14138    identity_bytes: &[u8],
14139    feed_bytes: &[Vec<u8>],
14140    snapshot_pack_sha256: Option<&str>,
14141    expected_anchor: &str,
14142) -> LinkResult<VerifiedMirrorMaterial> {
14143    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
14144        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
14145    require_safe_ref(&head.brain)?;
14146    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
14147        return Err(invalid_feed(
14148            "stored mirror feed count does not match its bounded head sequence",
14149        ));
14150    }
14151    let aggregate = feed_bytes
14152        .iter()
14153        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
14154        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
14155    if aggregate > MAX_FEED_REPLAY_BYTES {
14156        return Err(invalid_feed(
14157            "stored mirror feed metadata exceeds the aggregate limit",
14158        ));
14159    }
14160    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
14161        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
14162    let anchor = verify_identity_chain(&identity, None)?;
14163    if anchor != expected_anchor {
14164        return Err(invalid_feed(
14165            "stored mirror identity does not descend from the explicitly trusted anchor",
14166        ));
14167    }
14168
14169    let mut entries = Vec::with_capacity(feed_bytes.len());
14170    let mut items = Vec::with_capacity(feed_bytes.len());
14171    let mut previous_hash = None;
14172    let mut pack_sha256 = None;
14173    for (index, bytes) in feed_bytes.iter().enumerate() {
14174        let exact = bytes
14175            .strip_suffix(b"\n")
14176            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
14177        if exact.ends_with(b"\n") {
14178            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
14179        }
14180        let entry: FeedEntry = serde_json::from_slice(exact)
14181            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
14182        let expected_seq = index as u64 + 1;
14183        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
14184            return Err(invalid_feed(
14185                "stored mirror feed is not contiguous and hash-chained",
14186            ));
14187        }
14188        let canonical = serde_json::to_vec(&entry)
14189            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
14190        if canonical != exact {
14191            return Err(invalid_feed(
14192                "stored feed entry is not in normative serialization",
14193            ));
14194        }
14195        let hash = content_sha256(bytes);
14196        let item = FeedItem {
14197            hash: hash.clone(),
14198            entry,
14199        };
14200        verify_feed_item(&item, &identity)?;
14201        previous_hash = Some(hash.clone());
14202        if expected_seq == head.head_seq {
14203            pack_sha256 = Some(item.entry.pack_sha256.clone());
14204        }
14205        entries.push((
14206            expected_seq,
14207            std::str::from_utf8(exact)
14208                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
14209                .to_string(),
14210            hash,
14211        ));
14212        items.push(item);
14213    }
14214    if previous_hash != head.feed_hash {
14215        return Err(invalid_feed(
14216            "stored mirror feed does not converge on its advertised head",
14217        ));
14218    }
14219    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
14220    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
14221        (0, None, None) => {}
14222        (_, Some(actual), Some(expected)) if actual == expected => {}
14223        _ => {
14224            return Err(LinkError::InvalidPack {
14225                message: "stored snapshot pack does not match the signed head digest".to_string(),
14226            });
14227        }
14228    }
14229    let identity_value = serde_json::to_value(&identity)
14230        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
14231    Ok(VerifiedMirrorMaterial {
14232        brain: head.brain,
14233        head_seq: head.head_seq,
14234        feed_hash: head.feed_hash,
14235        identity: identity_value,
14236        entries,
14237        pack_sha256,
14238    })
14239}
14240
14241/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
14242/// entry hash every consumer recomputes (SPEC §5.3).
14243pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
14244    format!(
14245        "{:x}",
14246        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
14247    )
14248}
14249
14250/// SHA-256 hex for content a signed manifest names. Exposed for the thin
14251/// `dbmd serve` adapter; cryptographic verification remains centralized here.
14252pub fn content_sha256(bytes: &[u8]) -> String {
14253    format!("{:x}", Sha256::digest(bytes))
14254}
14255
14256/// SHA-256 a stream with a fixed-size working buffer.
14257pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14258    let mut digest = Sha256::new();
14259    let mut buffer = [0u8; 64 * 1024];
14260    loop {
14261        let read = reader.read(&mut buffer)?;
14262        if read == 0 {
14263            break;
14264        }
14265        digest.update(&buffer[..read]);
14266    }
14267    Ok(format!("{:x}", digest.finalize()))
14268}
14269
14270/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
14271/// chain, not just the head): every entry's signature, hash, sequence
14272/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
14273/// checked in a sibling staging directory. Only then is the old mirror swapped
14274/// out through an atomic directory exchange. Every stage, install, and cleanup
14275/// operation is relative to one held no-follow parent-directory capability, so
14276/// renaming an ancestor cannot redirect any write or deletion.
14277#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14278pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14279    require_hardened_filesystem("mirror")?;
14280    require_safe_ref(brain)?;
14281    #[cfg(windows)]
14282    {
14283        let _ = (cfg, dest);
14284        return Err(LinkError::UnsupportedPlatform {
14285            operation: "atomic whole-mirror replacement on Windows",
14286        });
14287    }
14288    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14289    let name = dest
14290        .file_name()
14291        .and_then(|name| name.to_str())
14292        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14293        .ok_or_else(|| LinkError::UnsafePath {
14294            path: dest.display().to_string(),
14295        })?;
14296    #[cfg(unix)]
14297    let parent_dir = open_or_create_dir_nofollow(parent)?;
14298    #[cfg(unix)]
14299    use std::os::fd::AsRawFd as _;
14300    #[cfg(unix)]
14301    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14302    #[cfg(unix)]
14303    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14304        None => false,
14305        Some(true) => true,
14306        Some(false) => {
14307            return Err(LinkError::UnsafePath {
14308                path: dest.display().to_string(),
14309            });
14310        }
14311    };
14312
14313    // A fixed backup was used by pre-hardening builds. Never interpret or
14314    // delete an attacker-planted entry at that name; require manual recovery.
14315    #[cfg(unix)]
14316    let legacy_backup_name = c_name(
14317        format!(".{name}.dbmd-backup").as_bytes(),
14318        &dest.display().to_string(),
14319    )?;
14320    #[cfg(unix)]
14321    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14322        return Err(LinkError::UnsafePath {
14323            path: parent
14324                .join(format!(".{name}.dbmd-backup"))
14325                .display()
14326                .to_string(),
14327        });
14328    }
14329
14330    let nonce = std::time::SystemTime::now()
14331        .duration_since(std::time::UNIX_EPOCH)
14332        .unwrap_or_default()
14333        .as_nanos();
14334    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14335    #[cfg(unix)]
14336    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14337    #[cfg(unix)]
14338    let stage_dir = create_dir_exclusive_at(
14339        parent_dir.as_raw_fd(),
14340        &stage_name,
14341        &dest.display().to_string(),
14342    )?;
14343
14344    let assembled = (|| -> LinkResult<MirrorReport> {
14345        let remote = verified_remote_head(cfg, brain, true)?;
14346        let brain_id = remote.head.brain.clone();
14347        let identity = remote
14348            .identity
14349            .as_ref()
14350            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14351        let anchor = remote
14352            .anchor
14353            .clone()
14354            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14355        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14356        let snapshot_entries = parse_store_pack(pack.clone())?;
14357        let snapshot_count = snapshot_entries.len();
14358        let mut staged_entries = snapshot_entries;
14359        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14360        for item in &remote.entries {
14361            let mut exact = serde_json::to_vec(&item.entry)
14362                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14363            exact.push(b'\n');
14364            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14365                return Err(invalid_feed(
14366                    "serialized mirror entry differs from its verified hash",
14367                ));
14368            }
14369            staged_entries.push((
14370                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14371                exact,
14372            ));
14373        }
14374        let mut identity_bytes = serde_json::to_vec(identity)
14375            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14376        identity_bytes.push(b'\n');
14377        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14378        let mut head_bytes = serde_json::to_vec(&json!({
14379            "brain": brain_id,
14380            "headSeq": remote.head.seq,
14381            "feedHash": remote.head.feed_hash,
14382        }))
14383        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14384        head_bytes.push(b'\n');
14385        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14386        staged_entries.push((
14387            CONFIG_REL_PATH.to_string(),
14388            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14389        ));
14390        #[cfg(unix)]
14391        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14392
14393        Ok(MirrorReport {
14394            brain: brain_id,
14395            head_seq: remote.head.seq,
14396            feed_hash: remote.head.feed_hash,
14397            entries: remote.entries.len() as u64,
14398            pinned: anchor,
14399            files: snapshot_count,
14400        })
14401    })();
14402
14403    let report = match assembled {
14404        Ok(report) => report,
14405        Err(error) => {
14406            #[cfg(unix)]
14407            let _ = remove_tree_at(
14408                parent_dir.as_raw_fd(),
14409                &stage_name,
14410                &dest.display().to_string(),
14411            );
14412            return Err(error);
14413        }
14414    };
14415
14416    #[cfg(unix)]
14417    if let Err(error) =
14418        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14419    {
14420        let _ = remove_tree_at(
14421            parent_dir.as_raw_fd(),
14422            &stage_name,
14423            &dest.display().to_string(),
14424        );
14425        return Err(error);
14426    }
14427    // An exchange leaves the old mirror at the unique stage name. Cleanup is
14428    // capability-relative and never follows symlinks in hostile old content.
14429    #[cfg(unix)]
14430    if dest_exists {
14431        remove_tree_at(
14432            parent_dir.as_raw_fd(),
14433            &stage_name,
14434            &dest.display().to_string(),
14435        )?;
14436    }
14437    #[cfg(unix)]
14438    parent_dir.sync_all()?;
14439    Ok(report)
14440}
14441
14442fn verified_remote_head(
14443    cfg: &HubConfig,
14444    brain: &str,
14445    require_full_chain: bool,
14446) -> LinkResult<VerifiedRemote> {
14447    require_hardened_filesystem("verified link.md state")?;
14448    require_safe_ref(brain)?;
14449    // Refuse an unsafe state root before sending credentials or consulting an
14450    // untrusted card, and retain this exact directory inode for the complete
14451    // checkpoint transaction.
14452    let trust_directory = open_trust_dir(cfg)?;
14453    let path = format!("/api/hub/brains/{brain}");
14454    let body = ensure_ok(
14455        request(cfg, "GET", &path, None, Auth::Required)?,
14456        "subscribe",
14457    )?;
14458    let resolved_brain = body
14459        .get("id")
14460        .and_then(Value::as_str)
14461        .filter(|id| crate::ulid::is_ulid(id))
14462        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14463        .to_string();
14464    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14465        return Err(invalid_feed(
14466            "brain card id differs from the explicitly requested brain id",
14467        ));
14468    }
14469    // The card supplies the canonical ULID. Lock alias + canonical keys in
14470    // deterministic filename order, then hold both through verify + save.
14471    // Concurrent aliases for the same brain therefore converge on one
14472    // checkpoint instead of establishing independent TOFU universes.
14473    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14474    let (pinned, alias_binding) =
14475        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14476    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14477    let advertised_hash = body
14478        .get("feedHash")
14479        .and_then(Value::as_str)
14480        .map(str::to_string);
14481    let updated_at = body
14482        .get("updatedAt")
14483        .and_then(Value::as_str)
14484        .map(str::to_string);
14485    if let Some(pin) = &pinned {
14486        if seq < pin.head_seq {
14487            return Err(invalid_feed(format!(
14488                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14489                pin.head_seq
14490            )));
14491        }
14492        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14493            return Err(invalid_feed(
14494                "feed equivocation: the checkpoint sequence now has a different hash",
14495            ));
14496        }
14497    }
14498    if seq == 0 {
14499        if advertised_hash.is_some() {
14500            return Err(invalid_feed("an empty feed advertised a head hash"));
14501        }
14502        let identity: FeedIdentity = serde_json::from_value(
14503            body.get("identity")
14504                .cloned()
14505                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14506        )
14507        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14508        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14509        // A valid old-key-signed rotation is still inconsistent if it commits
14510        // to history that the same card now claims never existed. Run the
14511        // identical feed-boundary proof used by non-empty heads before this
14512        // identity can become a durable TOFU checkpoint.
14513        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14514        save_canonical_pin_and_alias(
14515            cfg,
14516            &trust_directory,
14517            brain,
14518            &resolved_brain,
14519            TrustState {
14520                v: 2,
14521                origin: normalized_origin(&cfg.hub)?,
14522                requested: resolved_brain.clone(),
14523                brain: resolved_brain.clone(),
14524                home: None,
14525                anchor: anchor.clone(),
14526                current: format!("ed25519:{}", identity.fingerprint),
14527                head_seq: 0,
14528                feed_hash: None,
14529                rotations: identity.rotations.clone(),
14530                hub_signer: None,
14531                protocol_profile: None,
14532            },
14533            alias_binding.as_ref(),
14534        )?;
14535        return Ok(VerifiedRemote {
14536            head: Head {
14537                brain: resolved_brain,
14538                seq,
14539                updated_at,
14540                feed_hash: None,
14541                verified: true,
14542            },
14543            identity: Some(identity),
14544            head_entry: None,
14545            entries: Vec::new(),
14546            anchor: Some(anchor),
14547        });
14548    }
14549    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14550        return Err(invalid_feed(
14551            "non-empty feed did not advertise a valid SHA-256 head",
14552        ));
14553    }
14554
14555    // On first contact the signed head itself is the TOFU checkpoint. A full
14556    // history replay cannot add authority before an anchor exists; mirrors
14557    // still request the complete chain because they promise an archival copy.
14558    let replay_head_only = !require_full_chain
14559        && pinned
14560            .as_ref()
14561            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14562    let mut after = if replay_head_only {
14563        seq - 1
14564    } else if require_full_chain || pinned.is_none() {
14565        0
14566    } else {
14567        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14568    };
14569    let mut expected_seq = after + 1;
14570    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14571        None
14572    } else {
14573        pinned
14574            .as_ref()
14575            .and_then(|checkpoint| checkpoint.feed_hash.clone())
14576    };
14577    let mut identity: Option<FeedIdentity> = None;
14578    let mut anchor: Option<String> = None;
14579    let mut head_entry: Option<FeedItem> = None;
14580    let mut all_entries = Vec::new();
14581    let mut observed_entries = Vec::new();
14582    let replay_count = seq
14583        .checked_sub(after)
14584        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14585    if replay_count > MAX_FEED_REPLAY_ENTRIES {
14586        return Err(invalid_feed(format!(
14587            "feed replay requires {replay_count} entries, over the client cap"
14588        )));
14589    }
14590    let mut replay_bytes = 0u64;
14591
14592    loop {
14593        let feed_bytes = ensure_raw_ok(
14594            request_raw(
14595                cfg,
14596                "GET",
14597                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14598                None,
14599                Auth::Required,
14600                MAX_FEED_RESPONSE_BYTES,
14601            )?,
14602            "subscribe feed",
14603        )?;
14604        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14605            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14606        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14607            return Err(invalid_feed("brain card and feed head disagree"));
14608        }
14609        if feed.entries.len() > FEED_PAGE_LIMIT {
14610            return Err(invalid_feed("feed page exceeds the requested entry limit"));
14611        }
14612        if feed.scope_limited {
14613            if require_full_chain {
14614                return Err(invalid_feed(
14615                    "path-scoped grants cannot verify a full snapshot chain",
14616                ));
14617            }
14618            return Ok(VerifiedRemote {
14619                head: Head {
14620                    brain: resolved_brain,
14621                    seq,
14622                    updated_at,
14623                    feed_hash: advertised_hash,
14624                    verified: false,
14625                },
14626                identity: None,
14627                head_entry: None,
14628                entries: Vec::new(),
14629                anchor: None,
14630            });
14631        }
14632        let page_identity = feed
14633            .identity
14634            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14635        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14636        if identity
14637            .as_ref()
14638            .is_some_and(|existing| existing != &page_identity)
14639        {
14640            return Err(invalid_feed("identity changed while reading the feed"));
14641        }
14642        if anchor
14643            .as_ref()
14644            .is_some_and(|existing| existing != &page_anchor)
14645        {
14646            return Err(invalid_feed(
14647                "identity anchor changed while reading the feed",
14648            ));
14649        }
14650        identity = Some(page_identity.clone());
14651        if anchor.is_none() {
14652            anchor = Some(page_anchor);
14653        }
14654        if feed.entries.is_empty() {
14655            return Err(invalid_feed("feed page was empty before the signed head"));
14656        }
14657
14658        for item in feed.entries {
14659            if item.entry.seq != expected_seq {
14660                return Err(invalid_feed(format!(
14661                    "expected entry {expected_seq}, feed served {}",
14662                    item.entry.seq
14663                )));
14664            }
14665            if item.entry.seq > seq {
14666                return Err(invalid_feed("feed advanced past the card snapshot"));
14667            }
14668            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14669                return Err(invalid_feed(format!(
14670                    "entry {} does not chain to the local checkpoint",
14671                    item.entry.seq
14672                )));
14673            }
14674            verify_feed_item(&item, &page_identity)?;
14675            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14676            replay_bytes = replay_bytes.saturating_add(
14677                serde_json::to_vec(&item)
14678                    .map_err(|_| invalid_feed("could not size feed entry"))?
14679                    .len() as u64,
14680            );
14681            if replay_bytes > MAX_FEED_REPLAY_BYTES {
14682                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14683            }
14684            previous_hash = Some(item.hash.clone());
14685            after = item.entry.seq;
14686            expected_seq = expected_seq
14687                .checked_add(1)
14688                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14689            if require_full_chain {
14690                all_entries.push(item.clone());
14691            }
14692            observed_entries.push(item.clone());
14693            head_entry = Some(item);
14694        }
14695        if after == seq {
14696            break;
14697        }
14698    }
14699
14700    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14701        return Err(invalid_feed(
14702            "verified chain does not converge on the advertised head",
14703        ));
14704    }
14705    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14706    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14707    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14708    save_canonical_pin_and_alias(
14709        cfg,
14710        &trust_directory,
14711        brain,
14712        &resolved_brain,
14713        TrustState {
14714            v: 2,
14715            origin: normalized_origin(&cfg.hub)?,
14716            requested: resolved_brain.clone(),
14717            brain: resolved_brain.clone(),
14718            home: None,
14719            anchor: anchor.clone(),
14720            current: format!("ed25519:{}", identity.fingerprint),
14721            head_seq: seq,
14722            feed_hash: advertised_hash.clone(),
14723            rotations: identity.rotations.clone(),
14724            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14725            protocol_profile: pinned
14726                .as_ref()
14727                .and_then(|state| state.protocol_profile.clone()),
14728        },
14729        alias_binding.as_ref(),
14730    )?;
14731    Ok(VerifiedRemote {
14732        head: Head {
14733            brain: resolved_brain,
14734            seq,
14735            updated_at,
14736            feed_hash: advertised_hash,
14737            verified: true,
14738        },
14739        identity: Some(identity),
14740        head_entry,
14741        entries: all_entries,
14742        anchor: Some(anchor),
14743    })
14744}
14745
14746/// Read and locally verify the brain's current signed feed head. Identity
14747/// rotation is accepted only through an old-key-signed chain rooted at the
14748/// local TOFU anchor; sequence and hash checkpoints reject rollback and
14749/// equivocation across invocations.
14750pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14751    if let Some(verified) = v2_verified_head(cfg, brain)? {
14752        let observation = Head {
14753            brain: verified.brain_id.clone(),
14754            seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14755            updated_at: verified
14756                .pointer
14757                .as_ref()
14758                .map(|pointer| pointer.signed_at.clone()),
14759            feed_hash: verified
14760                .pointer
14761                .as_ref()
14762                .map(|pointer| pointer.feed_hash.clone()),
14763            verified: true,
14764        };
14765        accept_v2_head(cfg, &verified)?;
14766        return Ok(observation);
14767    }
14768    Ok(verified_remote_head(cfg, brain, false)?.head)
14769}
14770
14771#[cfg(test)]
14772mod tests {
14773    use super::*;
14774
14775    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14776
14777    fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14778        json!({
14779            "sha256": "a".repeat(64),
14780            "bytes": 10,
14781            "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14782        })
14783    }
14784
14785    #[test]
14786    fn upload_reservations_batch_by_count_and_by_size() {
14787        // A real store's first push declares one blob per changed file, which
14788        // does not fit in one request. Every batch has to stay inside both
14789        // bounds, and concatenating them has to reproduce the original order.
14790        let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14791        let batches = batch_upload_declarations(declarations.clone());
14792
14793        assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14794        for batch in &batches {
14795            assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14796            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14797                .expect("batch serializes")
14798                .len();
14799            assert!(
14800                bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14801                "batch body {bytes} exceeds the reservation budget"
14802            );
14803        }
14804        let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14805        assert_eq!(
14806            flattened, declarations,
14807            "batching must preserve the set and order"
14808        );
14809    }
14810
14811    #[test]
14812    fn only_load_shaped_hub_answers_are_worth_asking_again() {
14813        // A migration opens dozens of staging windows, so a busy hub's 429 or
14814        // 5xx must not end the push — while a refusal that states something
14815        // about the request itself has to surface immediately rather than
14816        // being repeated six times behind a minute of waiting.
14817        for status in [408, 429, 500, 502, 503, 504] {
14818            assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14819        }
14820        for status in [400, 401, 403, 404, 409, 413, 422] {
14821            assert!(
14822                !is_retryable_hub_status(status),
14823                "{status} states something about the request"
14824            );
14825        }
14826        // The waits must reach past a whole limiter window to be worth taking.
14827        let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14828        assert!(total >= 60_000, "backoff totals only {total}ms");
14829    }
14830
14831    #[test]
14832    fn a_batch_shares_a_connection_only_within_one_authority() {
14833        // Sharing a pooled agent is what makes a migration stage in minutes,
14834        // but it must never widen where bytes can go: a batch that spans two
14835        // authorities, or names an unsafe URL, falls back to per-object agents
14836        // rather than reusing a connection pinned to somewhere else.
14837        let cfg = HubConfig {
14838            hub: "https://www.sevrahq.com".to_string(),
14839            key: Some("k".to_string()),
14840            agent_key: None,
14841            brain_key: None,
14842            state_dir: PathBuf::from("."),
14843            store_selected: false,
14844        };
14845        assert!(shared_staging_agent(&cfg, &[]).is_none());
14846        assert!(
14847            shared_staging_agent(
14848                &cfg,
14849                &[
14850                    "https://one.example.com/a?sig=1",
14851                    "https://two.example.com/b?sig=2",
14852                ]
14853            )
14854            .is_none(),
14855            "two authorities must not share a pinned pool"
14856        );
14857        assert!(
14858            shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14859            "an unsafe object-store URL must not produce an agent"
14860        );
14861        assert!(
14862            shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14863            "credentials in the URL must not produce an agent"
14864        );
14865    }
14866
14867    #[test]
14868    fn a_staged_change_states_only_operations_and_blobs() {
14869        // The hub refuses a manifest with any other key, so the two sides have
14870        // to agree exactly. A drifting extra field would fail a whole migration
14871        // at the commit, after every byte had already been staged.
14872        let operations = vec![json!({
14873            "op": "put",
14874            "path": "records/a.md",
14875            "blob": "a".repeat(64),
14876            "bytes": 3,
14877        })];
14878        let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14879        let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14880        let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14881        let keys: Vec<&str> = parsed
14882            .as_object()
14883            .expect("manifest is an object")
14884            .keys()
14885            .map(String::as_str)
14886            .collect();
14887        assert_eq!(keys, ["blobs", "operations"]);
14888        assert_eq!(parsed["operations"], Value::Array(operations));
14889        assert_eq!(parsed["blobs"], blobs);
14890    }
14891
14892    #[test]
14893    fn a_staged_push_signs_the_change_not_the_transport() {
14894        // Self-custody signs a request hash the hub recomputes from the parsed
14895        // change. A staged body no longer states its operations, so the signed
14896        // view has to restore them or every large self-custodied push would
14897        // sign a different request than the one the hub validated.
14898        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14899        let staged = json!({
14900            "mutation_id": "dbmd-1",
14901            "rebase": "strict",
14902            "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14903        });
14904        let view = v2_signed_request_view(&staged, &operations);
14905        assert_eq!(view["operations"], Value::Array(operations.clone()));
14906        assert!(view.get("staged_change").is_none());
14907        assert_eq!(view["mutation_id"], staged["mutation_id"]);
14908
14909        let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14910        assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14911    }
14912
14913    #[test]
14914    fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14915        let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
14916        let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
14917            .expect_err("an oversized change must not be staged");
14918        assert!(
14919            matches!(error, LinkError::PushTooLarge { .. }),
14920            "expected a size refusal, got {error:?}"
14921        );
14922    }
14923
14924    #[test]
14925    fn a_push_that_fits_the_request_is_left_inline() {
14926        // The staged lane costs a reservation and a round trip, so it must
14927        // engage only when the body genuinely does not fit. The hub here is a
14928        // closed port: reaching for it at all would fail this test.
14929        let cfg = HubConfig {
14930            hub: "http://127.0.0.1:9".to_string(),
14931            key: Some("k".to_string()),
14932            agent_key: None,
14933            brain_key: None,
14934            state_dir: PathBuf::from("."),
14935            store_selected: false,
14936        };
14937        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14938        let mut body = json!({
14939            "mutation_id": "dbmd-1",
14940            "operations": operations,
14941            "blobs": [],
14942        });
14943        stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
14944        assert!(body.get("staged_change").is_none());
14945        assert_eq!(body["operations"], Value::Array(operations));
14946    }
14947
14948    #[test]
14949    fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
14950        // Byte budget, not just count. One hash can carry many coordinates
14951        // (the same bytes reachable at several paths), so a batch sized only by
14952        // count would blow the body budget on a deduplicated store.
14953        let declarations: Vec<Value> = (0..2_000)
14954            .map(|index| {
14955                json!({
14956                    "sha256": "a".repeat(64),
14957                    "bytes": 10,
14958                    "coordinates": (0..24)
14959                        .map(|slot| format!(
14960                            "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
14961                        ))
14962                        .collect::<Vec<_>>(),
14963                })
14964            })
14965            .collect();
14966        let batches = batch_upload_declarations(declarations);
14967        assert!(
14968            batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
14969            "wide coordinate sets must bound the batch by size"
14970        );
14971        for batch in &batches {
14972            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14973                .expect("batch serializes")
14974                .len();
14975            assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
14976        }
14977    }
14978
14979    #[test]
14980    fn a_small_push_still_rides_exactly_one_request() {
14981        let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
14982        assert_eq!(batch_upload_declarations(declarations).len(), 1);
14983        assert!(batch_upload_declarations(Vec::new()).is_empty());
14984    }
14985
14986    #[test]
14987    fn exact_source_move_becomes_one_provenance_preserving_rename() {
14988        let hash = "a".repeat(64);
14989        let operations = vec![
14990            json!({
14991                "op": "put",
14992                "path": "sources/curated/item.md",
14993                "expected": { "kind": "absent" },
14994                "blob": hash,
14995                "bytes": 19,
14996            }),
14997            json!({
14998                "op": "delete",
14999                "path": "sources/inbox/item.md",
15000                "expected": { "kind": "blob", "hash": hash },
15001            }),
15002        ];
15003
15004        assert_eq!(
15005            infer_exact_source_promotions(operations),
15006            vec![json!({
15007                "op": "rename",
15008                "from": "sources/inbox/item.md",
15009                "to": "sources/curated/item.md",
15010                "expected_from": { "kind": "blob", "hash": hash },
15011                "expected_to": { "kind": "absent" },
15012                "blob": hash,
15013                "bytes": 19,
15014            })]
15015        );
15016    }
15017
15018    #[test]
15019    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
15020        let hash = "b".repeat(64);
15021        let operations = vec![
15022            json!({
15023                "op": "delete",
15024                "path": "sources/inbox/a.md",
15025                "expected": { "kind": "blob", "hash": hash },
15026            }),
15027            json!({
15028                "op": "delete",
15029                "path": "sources/inbox/b.md",
15030                "expected": { "kind": "blob", "hash": hash },
15031            }),
15032            json!({
15033                "op": "put",
15034                "path": "sources/curated/item.md",
15035                "expected": { "kind": "absent" },
15036                "blob": hash,
15037                "bytes": 19,
15038            }),
15039        ];
15040
15041        assert_eq!(
15042            infer_exact_source_promotions(operations.clone()),
15043            operations,
15044            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
15045        );
15046    }
15047
15048    #[test]
15049    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
15050        let hash = "c".repeat(64);
15051        let mut candidate = std::collections::BTreeMap::from([(
15052            "sources/inbox/item.md".to_string(),
15053            V2BaselineFile {
15054                sha256: hash.clone(),
15055                bytes: 19,
15056                proof: None,
15057            },
15058        )]);
15059        let mut candidate_assets = std::collections::BTreeMap::new();
15060        let operations = vec![
15061            json!({
15062                "op": "rename",
15063                "from": "sources/inbox/item.md",
15064                "to": "sources/curated/item.md",
15065                "expected_from": { "kind": "blob", "hash": hash },
15066                "expected_to": { "kind": "absent" },
15067                "blob": hash,
15068                "bytes": 19,
15069            }),
15070            json!({
15071                "op": "put",
15072                "path": "records/rsvps/item.md",
15073                "expected": { "kind": "absent" },
15074                "blob": "d".repeat(64),
15075                "bytes": 23,
15076            }),
15077        ];
15078
15079        assert!(!apply_generated_v2_operations(
15080            &operations,
15081            &std::collections::BTreeMap::new(),
15082            &mut candidate,
15083            &mut candidate_assets,
15084        )
15085        .unwrap());
15086        assert!(!candidate.contains_key("sources/inbox/item.md"));
15087        assert_eq!(
15088            candidate
15089                .get("sources/curated/item.md")
15090                .map(|file| (&file.sha256, file.bytes)),
15091            Some((&hash, 19))
15092        );
15093        assert_eq!(
15094            candidate
15095                .get("records/rsvps/item.md")
15096                .map(|file| (file.sha256.as_str(), file.bytes)),
15097            Some((
15098                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
15099                23
15100            ))
15101        );
15102    }
15103
15104    fn merge_fixture(
15105        base: Option<&str>,
15106        remote: Option<&str>,
15107        local: Option<&str>,
15108        keep_local: bool,
15109    ) -> V2PulledMerge<String> {
15110        let map = |value: Option<&str>| {
15111            value
15112                .map(|value| [("records/a.md".to_string(), value.to_string())])
15113                .into_iter()
15114                .flatten()
15115                .collect::<std::collections::BTreeMap<_, _>>()
15116        };
15117        merge_v2_pulled_records(
15118            &map(base),
15119            &map(remote),
15120            &map(local),
15121            |value, _| value.clone(),
15122            |value, _| value.clone(),
15123            |_| keep_local,
15124        )
15125    }
15126
15127    #[test]
15128    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
15129        let path = "records/a.md".to_string();
15130
15131        let local_add = merge_fixture(None, None, Some("local"), false);
15132        assert_eq!(
15133            local_add.records.get(&path).map(String::as_str),
15134            Some("local")
15135        );
15136        assert!(local_add.accept_remote.is_empty());
15137        assert!(local_add.conflicts.is_empty());
15138
15139        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
15140        assert_eq!(
15141            local_edit.records.get(&path).map(String::as_str),
15142            Some("local")
15143        );
15144        assert!(local_edit.accept_remote.is_empty());
15145        assert!(local_edit.conflicts.is_empty());
15146
15147        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
15148        assert!(!local_delete.records.contains_key(&path));
15149        assert!(local_delete.accept_remote.is_empty());
15150        assert!(local_delete.conflicts.is_empty());
15151
15152        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
15153        assert_eq!(
15154            remote_edit.records.get(&path).map(String::as_str),
15155            Some("remote")
15156        );
15157        assert!(remote_edit.accept_remote.contains(&path));
15158        assert!(remote_edit.conflicts.is_empty());
15159
15160        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
15161        assert!(!remote_delete.records.contains_key(&path));
15162        assert!(remote_delete.accept_remote.contains(&path));
15163        assert!(remote_delete.conflicts.is_empty());
15164
15165        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
15166        assert_eq!(
15167            same_edit.records.get(&path).map(String::as_str),
15168            Some("same")
15169        );
15170        assert!(same_edit.accept_remote.contains(&path));
15171        assert!(same_edit.conflicts.is_empty());
15172
15173        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
15174        assert_eq!(conflict.conflicts, vec![path.clone()]);
15175        assert_eq!(
15176            conflict.records.get(&path).map(String::as_str),
15177            Some("local")
15178        );
15179        assert!(conflict.accept_remote.is_empty());
15180
15181        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
15182        assert_eq!(
15183            kept_home.records.get(&path).map(String::as_str),
15184            Some("local")
15185        );
15186        assert!(kept_home.accept_remote.is_empty());
15187        assert!(kept_home.conflicts.is_empty());
15188    }
15189
15190    #[test]
15191    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
15192        let path = "sources/report.pdf";
15193        let record = crate::AssetRecord {
15194            path: path.to_string(),
15195            sha256: "a".repeat(64),
15196            bytes: 42,
15197            media_type: "application/pdf".to_string(),
15198            wrappers: vec!["gzip".to_string()],
15199            required: true,
15200        };
15201        let mut remote = V2BaselineAsset {
15202            blob_sha256: record.sha256.clone(),
15203            bytes: record.bytes,
15204            media_type: record.media_type.clone(),
15205            wrappers: record.wrappers.clone(),
15206            required: record.required,
15207            disposition: "withheld".to_string(),
15208            leaf_hash: "b".repeat(64),
15209        };
15210
15211        assert!(v2_asset_resumes_hosting(
15212            Some(&remote),
15213            path,
15214            &record,
15215            "hosted"
15216        ));
15217        assert!(!v2_asset_resumes_hosting(
15218            Some(&remote),
15219            path,
15220            &record,
15221            "withheld"
15222        ));
15223
15224        remote.disposition = "hosted".to_string();
15225        assert!(!v2_asset_resumes_hosting(
15226            Some(&remote),
15227            path,
15228            &record,
15229            "hosted"
15230        ));
15231
15232        remote.disposition = "withheld".to_string();
15233        remote.blob_sha256 = "c".repeat(64);
15234        assert!(!v2_asset_resumes_hosting(
15235            Some(&remote),
15236            path,
15237            &record,
15238            "hosted"
15239        ));
15240        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
15241    }
15242
15243    #[test]
15244    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
15245        let path = "records/team/alpha.md".to_string();
15246        let deleted_path = "records/team/deleted.md".to_string();
15247        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
15248            sha256,
15249            bytes,
15250            file: None,
15251        };
15252        let files = vec![
15253            V2ConflictFile {
15254                path: path.clone(),
15255                base: coordinate(None, None),
15256                local: coordinate(Some("b".repeat(64)), Some(7)),
15257                remote: coordinate(Some("a".repeat(64)), Some(5)),
15258            },
15259            V2ConflictFile {
15260                path: deleted_path.clone(),
15261                base: coordinate(Some("c".repeat(64)), Some(9)),
15262                local: coordinate(Some("d".repeat(64)), Some(11)),
15263                remote: coordinate(None, None),
15264            },
15265        ];
15266        let proven = V2BaselineFile {
15267            sha256: "a".repeat(64),
15268            bytes: 5,
15269            proof: None,
15270        };
15271        let current = [(path.clone(), proven.clone())]
15272            .into_iter()
15273            .collect::<std::collections::BTreeMap<_, _>>();
15274
15275        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
15276        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15277        assert_eq!(deleted, vec![deleted_path.clone()]);
15278
15279        let changed = [(
15280            path.clone(),
15281            V2BaselineFile {
15282                sha256: "e".repeat(64),
15283                bytes: 5,
15284                proof: None,
15285            },
15286        )]
15287        .into_iter()
15288        .collect::<std::collections::BTreeMap<_, _>>();
15289        assert!(v2_take_remote_selection(&files, &changed).is_err());
15290
15291        let resurrected = [
15292            (path, proven),
15293            (
15294                deleted_path,
15295                V2BaselineFile {
15296                    sha256: "f".repeat(64),
15297                    bytes: 13,
15298                    proof: None,
15299                },
15300            ),
15301        ]
15302        .into_iter()
15303        .collect::<std::collections::BTreeMap<_, _>>();
15304        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15305    }
15306
15307    #[cfg(target_os = "linux")]
15308    #[test]
15309    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15310        use std::os::fd::AsRawFd as _;
15311
15312        let sandbox = tempfile::TempDir::new().unwrap();
15313        let parent = std::fs::File::open(sandbox.path()).unwrap();
15314        let stage = std::ffi::CString::new("stage").unwrap();
15315        let destination = std::ffi::CString::new("brain").unwrap();
15316
15317        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15318        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15319        install_stage_at(
15320            parent.as_raw_fd(),
15321            stage.as_c_str(),
15322            destination.as_c_str(),
15323            false,
15324        )
15325        .unwrap();
15326        assert!(!sandbox.path().join("stage").exists());
15327        assert_eq!(
15328            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15329            b"created"
15330        );
15331
15332        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15333        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15334        install_stage_at(
15335            parent.as_raw_fd(),
15336            stage.as_c_str(),
15337            destination.as_c_str(),
15338            true,
15339        )
15340        .unwrap();
15341        assert_eq!(
15342            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15343            b"replacement"
15344        );
15345        assert_eq!(
15346            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15347            b"created",
15348            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15349        );
15350    }
15351
15352    struct SignedRemoteFixture {
15353        card: String,
15354        feed: String,
15355        key: AgentSigningKey,
15356        identity: FeedIdentity,
15357    }
15358
15359    fn signed_remote_fixture() -> SignedRemoteFixture {
15360        let rng = ring::rand::SystemRandom::new();
15361        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15362        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15363        let (public_key, multikey) = public_identity_for(&pair);
15364        let identity = FeedIdentity {
15365            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15366            public_key_spki: public_key.clone(),
15367            previous: Vec::new(),
15368            rotations: Vec::new(),
15369        };
15370        let mut entry = FeedEntry {
15371            v: 1,
15372            seq: 1,
15373            ts: "2026-07-30T12:00:00.000Z".to_string(),
15374            brain: multikey.clone(),
15375            public_key: public_key.clone(),
15376            kind: "push".to_string(),
15377            op: "snapshot".to_string(),
15378            pack_sha256: "a".repeat(64),
15379            files: Vec::new(),
15380            removed: Vec::new(),
15381            prev_entry_hash: None,
15382            sig: String::new(),
15383        };
15384        let unsigned = UnsignedFeedEntry {
15385            v: entry.v,
15386            seq: entry.seq,
15387            ts: &entry.ts,
15388            brain: &entry.brain,
15389            public_key: &entry.public_key,
15390            kind: &entry.kind,
15391            op: &entry.op,
15392            pack_sha256: &entry.pack_sha256,
15393            files: &entry.files,
15394            removed: &entry.removed,
15395            prev_entry_hash: &entry.prev_entry_hash,
15396        };
15397        entry.sig =
15398            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15399        let mut exact = serde_json::to_vec(&entry).unwrap();
15400        exact.push(b'\n');
15401        let hash = content_sha256(&exact);
15402        let card = json!({
15403            "id": TEST_BRAIN_ID,
15404            "headSeq": 1,
15405            "feedHash": hash,
15406            "identity": identity.clone(),
15407        })
15408        .to_string();
15409        let feed = json!({
15410            "headSeq": 1,
15411            "feedHash": hash,
15412            "identity": identity.clone(),
15413            "entries": [{"hash": hash, "entry": entry}],
15414            "scopeLimited": false,
15415        })
15416        .to_string();
15417        SignedRemoteFixture {
15418            card,
15419            feed,
15420            key: AgentSigningKey {
15421                pkcs8: pkcs8.as_ref().to_vec(),
15422                multikey,
15423                public_key_spki: public_key,
15424            },
15425            identity,
15426        }
15427    }
15428
15429    #[test]
15430    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15431        let file = |path: &str, byte: char| FeedFile {
15432            path: path.to_string(),
15433            sha256: byte.to_string().repeat(64),
15434            bytes: 1,
15435        };
15436        let a0 = file("records/a.md", 'a');
15437        let a1 = file("records/a.md", 'b');
15438        let stable = file("records/stable.md", 'c');
15439        let added = file("records/added.md", 'd');
15440        let removed_file = file("records/removed.md", 'e');
15441        let previous = vec![a0, stable.clone(), removed_file.clone()];
15442        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15443        let removed = vec![removed_file.path.clone()];
15444
15445        assert_eq!(
15446            verify_v1_manifest_disclosure(
15447                "edit",
15448                &previous,
15449                &resulting,
15450                &[a1.clone(), added.clone()],
15451                &removed,
15452            ),
15453            Ok(())
15454        );
15455        assert_eq!(
15456            verify_v1_manifest_disclosure(
15457                "edit",
15458                &previous,
15459                &resulting,
15460                &[stable.clone(), added.clone(), a1.clone()],
15461                &removed,
15462            ),
15463            Ok(())
15464        );
15465        assert_eq!(
15466            verify_v1_manifest_disclosure(
15467                "edit",
15468                &previous,
15469                &resulting,
15470                std::slice::from_ref(&added),
15471                &removed,
15472            ),
15473            Err(V1DisclosureError::EditMissingChange)
15474        );
15475        assert_eq!(
15476            verify_v1_manifest_disclosure(
15477                "edit",
15478                &previous,
15479                &resulting,
15480                &[file("records/a.md", 'f'), added.clone()],
15481                &removed,
15482            ),
15483            Err(V1DisclosureError::EditFalseFile)
15484        );
15485        assert_eq!(
15486            verify_v1_manifest_disclosure(
15487                "edit",
15488                &previous,
15489                &resulting,
15490                &[a1.clone(), added.clone()],
15491                &[],
15492            ),
15493            Err(V1DisclosureError::RemovedMismatch)
15494        );
15495        assert_eq!(
15496            verify_v1_manifest_disclosure(
15497                "push",
15498                &previous,
15499                &resulting,
15500                &[added.clone(), stable, a1],
15501                &removed,
15502            ),
15503            Ok(())
15504        );
15505        assert_eq!(
15506            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15507            Err(V1DisclosureError::PushManifestMismatch)
15508        );
15509    }
15510
15511    #[test]
15512    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15513        let fixture = signed_remote_fixture();
15514        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15515        let item = feed["entries"][0].to_string();
15516        let oversized_page = format!(
15517            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15518            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15519                .collect::<Vec<_>>()
15520                .join(",")
15521        );
15522        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15523
15524        let oversized_identity = format!(
15525            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15526            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15527                .collect::<Vec<_>>()
15528                .join(",")
15529        );
15530        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15531
15532        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15533        let oversized_entry = format!(
15534            "{{\"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\"}}",
15535            "a".repeat(64),
15536            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15537                .collect::<Vec<_>>()
15538                .join(",")
15539        );
15540        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15541    }
15542
15543    #[test]
15544    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15545        let id = "01arz3ndektsv4rrffq69g5fav";
15546        let digest = "a".repeat(64);
15547        assert_eq!(
15548            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15549            V2BulkConfirmation {
15550                id: id.to_string(),
15551                digest,
15552            }
15553        );
15554        for invalid in [
15555            "",
15556            "01arz3ndektsv4rrffq69g5fav",
15557            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15558            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15559            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15560        ] {
15561            assert!(matches!(
15562                V2BulkConfirmation::parse(invalid),
15563                Err(LinkError::InvalidPack { .. })
15564            ));
15565        }
15566    }
15567
15568    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15569        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15570        use std::net::TcpListener;
15571
15572        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15573        let url = format!("http://{}", listener.local_addr().unwrap());
15574        let handle = std::thread::spawn(move || {
15575            for (status, body) in responses {
15576                let (stream, _) = listener.accept().unwrap();
15577                let mut reader = BufReader::new(stream);
15578                let mut line = String::new();
15579                reader.read_line(&mut line).unwrap();
15580                let mut content_length = 0usize;
15581                loop {
15582                    line.clear();
15583                    reader.read_line(&mut line).unwrap();
15584                    if line == "\r\n" || line == "\n" || line.is_empty() {
15585                        break;
15586                    }
15587                    if let Some((name, value)) = line.split_once(':') {
15588                        if name.eq_ignore_ascii_case("content-length") {
15589                            content_length = value.trim().parse().unwrap();
15590                        }
15591                    }
15592                }
15593                let mut request_body = vec![0_u8; content_length];
15594                reader.read_exact(&mut request_body).unwrap();
15595                let response = format!(
15596                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15597                    body.len()
15598                );
15599                reader.get_mut().write_all(response.as_bytes()).unwrap();
15600            }
15601        });
15602        (url, handle)
15603    }
15604
15605    fn routed_json_hub(
15606        requests: usize,
15607        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15608    ) -> (String, std::thread::JoinHandle<()>) {
15609        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15610        use std::net::TcpListener;
15611
15612        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15613        let url = format!("http://{}", listener.local_addr().unwrap());
15614        let handle = std::thread::spawn(move || {
15615            for _ in 0..requests {
15616                let (stream, _) = listener.accept().unwrap();
15617                let mut reader = BufReader::new(stream);
15618                let mut line = String::new();
15619                reader.read_line(&mut line).unwrap();
15620                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
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 (status, body) = respond(&path);
15637                let response = format!(
15638                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15639                    body.len()
15640                );
15641                reader.get_mut().write_all(response.as_bytes()).unwrap();
15642            }
15643        });
15644        (url, handle)
15645    }
15646
15647    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15648        HubConfig {
15649            hub,
15650            key: Some("test-key".to_string()),
15651            agent_key: None,
15652            brain_key: None,
15653            state_dir,
15654            store_selected: false,
15655        }
15656    }
15657
15658    #[cfg(any(unix, windows))]
15659    #[test]
15660    fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
15661        use std::sync::{Arc, Mutex};
15662
15663        let bytes = b"immutable asset bytes".to_vec();
15664        let sha256 = content_sha256(&bytes);
15665        let commit_hash = "c".repeat(64);
15666        let base_url = Arc::new(Mutex::new(String::new()));
15667        let server_base = Arc::clone(&base_url);
15668        let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15669        let server_attempt = Arc::clone(&object_attempt);
15670        let response_bytes = bytes.clone();
15671        let response_sha = sha256.clone();
15672        let response_commit = commit_hash.clone();
15673        let (hub, server) = routed_json_hub(4, move |path| {
15674            if path.contains("/v2/assets/downloads") {
15675                let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
15676                let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
15677                return (
15678                    200,
15679                    json!({
15680                        "v": 2,
15681                        "commit": response_commit,
15682                        "downloads": [{
15683                            "path": "assets/proof.bin",
15684                            "sha256": response_sha,
15685                            "bytes": response_bytes.len(),
15686                            "url": url,
15687                            "method": "GET"
15688                        }]
15689                    })
15690                    .to_string(),
15691                );
15692            }
15693            let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15694            if attempt == 0 {
15695                (403, "{}".to_string())
15696            } else {
15697                (200, String::from_utf8(response_bytes.clone()).unwrap())
15698            }
15699        });
15700        *base_url.lock().unwrap() = hub.clone();
15701
15702        let temp = tempfile::tempdir().unwrap();
15703        let cache = temp.path().join("cache");
15704        std::fs::create_dir(&cache).unwrap();
15705        let cfg = test_hub_config(hub, temp.path().to_path_buf());
15706        let pointer = V2PointerBody {
15707            v: 2,
15708            brain: TEST_BRAIN_ID.to_string(),
15709            seq: 1,
15710            commit_hash,
15711            feed_hash: "f".repeat(64),
15712            content_root: Some("a".repeat(64)),
15713            asset_root: Some("b".repeat(64)),
15714            materializer: "m".repeat(64),
15715            signer_epoch: 1,
15716            control_revision: "d".repeat(64),
15717            backup_preparation: "ready".to_string(),
15718            prior_pointer_hash: None,
15719            signed_at: "2026-08-23T00:00:00Z".to_string(),
15720        };
15721        let path = "assets/proof.bin".to_string();
15722        let asset = V2BaselineAsset {
15723            blob_sha256: sha256.clone(),
15724            bytes: bytes.len() as u64,
15725            media_type: "application/octet-stream".to_string(),
15726            wrappers: Vec::new(),
15727            required: true,
15728            disposition: "hosted".to_string(),
15729            leaf_hash: "e".repeat(64),
15730        };
15731
15732        let staged = stage_v2_asset_download_window(
15733            &cfg,
15734            TEST_BRAIN_ID,
15735            &pointer,
15736            &cache,
15737            &[(&path, &asset)],
15738        )
15739        .expect("a fresh authority-checked capability recovers an expired one");
15740        assert_eq!(staged.len(), 1);
15741        assert_eq!(staged[0].path, path);
15742        assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
15743        assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
15744        server.join().unwrap();
15745    }
15746
15747    #[cfg(any(unix, windows))]
15748    #[test]
15749    fn asset_capability_windows_cannot_exceed_the_worker_bound() {
15750        let temp = tempfile::tempdir().unwrap();
15751        let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
15752        let pointer = V2PointerBody {
15753            v: 2,
15754            brain: TEST_BRAIN_ID.to_string(),
15755            seq: 1,
15756            commit_hash: "c".repeat(64),
15757            feed_hash: "f".repeat(64),
15758            content_root: Some("a".repeat(64)),
15759            asset_root: Some("b".repeat(64)),
15760            materializer: "m".repeat(64),
15761            signer_epoch: 1,
15762            control_revision: "d".repeat(64),
15763            backup_preparation: "ready".to_string(),
15764            prior_pointer_hash: None,
15765            signed_at: "2026-08-23T00:00:00Z".to_string(),
15766        };
15767        let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
15768            .map(|index| format!("assets/{index}.bin"))
15769            .collect::<Vec<_>>();
15770        let assets = paths
15771            .iter()
15772            .map(|_| V2BaselineAsset {
15773                blob_sha256: "a".repeat(64),
15774                bytes: 1,
15775                media_type: "application/octet-stream".to_string(),
15776                wrappers: Vec::new(),
15777                required: true,
15778                disposition: "hosted".to_string(),
15779                leaf_hash: "b".repeat(64),
15780            })
15781            .collect::<Vec<_>>();
15782        let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
15783
15784        let error =
15785            stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
15786                .expect_err("an oversized window must fail before any network request");
15787        assert!(matches!(error, LinkError::InvalidFeed { .. }));
15788    }
15789
15790    #[test]
15791    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15792        use ring::signature::KeyPair as _;
15793
15794        let rng = ring::rand::SystemRandom::new();
15795        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15796        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15797        let (spki, multikey) = public_identity_for(&pair);
15798        let key = AgentSigningKey {
15799            pkcs8: pkcs8.as_ref().to_vec(),
15800            multikey,
15801            public_key_spki: spki,
15802        };
15803        let header = linkmd_sig_header(
15804            &key,
15805            "https://hub-a.example",
15806            "post",
15807            "/api/hub/brains/brain/push?mode=exact",
15808            Some("{\"ok\":true}"),
15809        )
15810        .unwrap();
15811        assert!(header.starts_with("LinkMD-Sig v2,"));
15812        let ts = header
15813            .split(",ts=")
15814            .nth(1)
15815            .unwrap()
15816            .split(',')
15817            .next()
15818            .unwrap();
15819        let signature = URL_SAFE_NO_PAD
15820            .decode(header.rsplit(",sig=").next().unwrap())
15821            .unwrap();
15822        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15823        let accepted = format!(
15824            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15825        );
15826        let replayed = format!(
15827            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15828        );
15829        let public = pair.public_key().as_ref();
15830        assert!(UnparsedPublicKey::new(&ED25519, public)
15831            .verify(accepted.as_bytes(), &signature)
15832            .is_ok());
15833        assert!(
15834            UnparsedPublicKey::new(&ED25519, public)
15835                .verify(replayed.as_bytes(), &signature)
15836                .is_err(),
15837            "a proof captured at hub A must not authenticate at hub B"
15838        );
15839    }
15840
15841    #[test]
15842    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15843        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15844        let card = json!({
15845            "id": other,
15846            "headSeq": 0,
15847            "identity": signed_remote_fixture().identity,
15848        })
15849        .to_string();
15850        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15851        let state = tempfile::tempdir().unwrap();
15852        let cfg = test_hub_config(hub, state.path().to_path_buf());
15853        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15854        assert!(
15855            error.contains("differs from the explicitly requested"),
15856            "{error}"
15857        );
15858        server.join().unwrap();
15859    }
15860
15861    #[test]
15862    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
15863        let first = signed_remote_fixture().identity;
15864        let second = signed_remote_fixture().identity;
15865        let card = |identity: FeedIdentity| {
15866            json!({
15867                "id": TEST_BRAIN_ID,
15868                "headSeq": 0,
15869                "identity": identity,
15870            })
15871            .to_string()
15872        };
15873        let (hub, server) = scripted_json_hub(vec![
15874            (404, "{}".to_string()),
15875            (200, card(first)),
15876            (404, "{}".to_string()),
15877            (200, card(second)),
15878        ]);
15879        let state = tempfile::tempdir().unwrap();
15880        let cfg = test_hub_config(hub, state.path().to_path_buf());
15881        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15882        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15883        assert!(
15884            error.contains("pinned anchor") || error.contains("forked away"),
15885            "{error}"
15886        );
15887        server.join().unwrap();
15888    }
15889
15890    #[test]
15891    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
15892        let old = signed_remote_fixture();
15893        let new = signed_remote_fixture();
15894        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
15895        let unsigned = serde_json::to_string(&UnsignedRotation {
15896            v: 1,
15897            op: "rotate",
15898            brain: &old.key.multikey,
15899            public_key: &old.key.public_key_spki,
15900            new_brain: &new.key.multikey,
15901            new_public_key: &new.key.public_key_spki,
15902            prior_head_seq: 1,
15903            prior_feed_hash: Some(&"a".repeat(64)),
15904            ts: "2026-07-30T12:00:00.000Z".to_string(),
15905        })
15906        .unwrap();
15907        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
15908        let rotation = format!(
15909            "{},\"sig\":\"{}\"}}",
15910            &unsigned[..unsigned.len() - 1],
15911            signature
15912        );
15913        let identity = FeedIdentity {
15914            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
15915            public_key_spki: new.key.public_key_spki,
15916            previous: vec![PreviousIdentity {
15917                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
15918                public_key_spki: old.key.public_key_spki,
15919            }],
15920            rotations: vec![rotation],
15921        };
15922        let card = json!({
15923            "id": TEST_BRAIN_ID,
15924            "headSeq": 0,
15925            "feedHash": null,
15926            "identity": identity,
15927        })
15928        .to_string();
15929        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15930        let state = tempfile::tempdir().unwrap();
15931        let cfg = test_hub_config(hub, state.path().to_path_buf());
15932        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15933        assert!(
15934            error.contains("rotation claims a feed boundary beyond the advertised head"),
15935            "{error}"
15936        );
15937        assert!(
15938            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
15939            "an inconsistent empty-head identity must not become the TOFU checkpoint"
15940        );
15941        server.join().unwrap();
15942    }
15943
15944    #[test]
15945    fn trust_checkpoint_rejects_a_later_fork() {
15946        let fixture = signed_remote_fixture();
15947        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
15948        fork["feedHash"] = Value::String("b".repeat(64));
15949        let (hub, server) = scripted_json_hub(vec![
15950            (404, "{}".to_string()),
15951            (200, fixture.card),
15952            (200, fixture.feed),
15953            (404, "{}".to_string()),
15954            (200, fork.to_string()),
15955        ]);
15956        let state = tempfile::tempdir().unwrap();
15957        let cfg = test_hub_config(hub, state.path().to_path_buf());
15958        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15959        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
15960        server.join().unwrap();
15961    }
15962
15963    #[test]
15964    fn alias_and_canonical_id_share_one_identity_checkpoint() {
15965        let trusted = signed_remote_fixture();
15966        let attacker = signed_remote_fixture();
15967        let (hub, server) = scripted_json_hub(vec![
15968            (404, "{}".to_string()),
15969            (200, trusted.card),
15970            (200, trusted.feed),
15971            (404, "{}".to_string()),
15972            (200, attacker.card),
15973        ]);
15974        let state = tempfile::tempdir().unwrap();
15975        let cfg = test_hub_config(hub, state.path().to_path_buf());
15976        assert!(head(&cfg, "trusted-slug").unwrap().verified);
15977        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15978        assert!(
15979            error.contains("equivocation")
15980                || error.contains("pinned")
15981                || error.contains("identity"),
15982            "{error}"
15983        );
15984        server.join().unwrap();
15985    }
15986
15987    #[test]
15988    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
15989        let state = tempfile::tempdir().unwrap();
15990        let cfg = test_hub_config(
15991            "https://hub.example".to_string(),
15992            state.path().to_path_buf(),
15993        );
15994        let directory = open_trust_dir(&cfg).unwrap();
15995        let old = TEST_BRAIN_ID;
15996        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15997        save_alias_in(
15998            &cfg,
15999            &directory,
16000            &AliasBinding {
16001                v: 1,
16002                origin: normalized_origin(&cfg.hub).unwrap(),
16003                requested: "company-brain".to_string(),
16004                brain: old.to_string(),
16005                home: Some("company-brain".to_string()),
16006            },
16007        )
16008        .unwrap();
16009
16010        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
16011        assert!(matches!(
16012            error,
16013            LinkError::AliasRebindRequired {
16014                alias,
16015                from,
16016                to
16017            } if alias == "company-brain" && from == old && to == new
16018        ));
16019        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
16020            .unwrap()
16021            .unwrap();
16022        assert_eq!(unchanged.brain, old);
16023        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
16024    }
16025
16026    #[test]
16027    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
16028        let alpha = signed_remote_fixture();
16029        let beta = signed_remote_fixture();
16030        let alpha_card = alpha.card.clone();
16031        let alpha_feed = alpha.feed.clone();
16032        let beta_card = beta.card.clone();
16033        let beta_feed = beta.feed.clone();
16034        let (hub, server) = routed_json_hub(5, move |path| {
16035            if path.ends_with("/v2/head") {
16036                (404, "{}".to_string())
16037            } else if path.contains("/alpha/feed?") {
16038                (200, alpha_feed.clone())
16039            } else if path.contains("/beta/feed?") {
16040                (200, beta_feed.clone())
16041            } else if path.ends_with("/alpha") {
16042                (200, alpha_card.clone())
16043            } else if path.ends_with("/beta") {
16044                (200, beta_card.clone())
16045            } else {
16046                (500, r#"{"error":"unexpected path"}"#.to_string())
16047            }
16048        });
16049        let state = tempfile::tempdir().unwrap();
16050        let cfg = test_hub_config(hub, state.path().to_path_buf());
16051        let alpha_cfg = cfg.clone();
16052        let beta_cfg = cfg;
16053        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
16054        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
16055        let results = [first.join().unwrap(), second.join().unwrap()];
16056        assert_eq!(
16057            results.iter().filter(|result| result.is_ok()).count(),
16058            1,
16059            "only one alias identity may establish canonical TOFU: {results:?}"
16060        );
16061        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
16062        server.join().unwrap();
16063    }
16064
16065    #[cfg(unix)]
16066    #[test]
16067    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
16068        use std::os::unix::fs::symlink;
16069
16070        let fixture = signed_remote_fixture();
16071        let card = json!({
16072            "id": TEST_BRAIN_ID,
16073            "headSeq": 0,
16074            "feedHash": Value::Null,
16075            "identity": fixture.identity,
16076        })
16077        .to_string();
16078        let work = tempfile::tempdir().unwrap();
16079        let outside = tempfile::tempdir().unwrap();
16080        let state = work.path().join("state");
16081        let moved = work.path().join("state-held");
16082        let swap_state = state.clone();
16083        let swap_moved = moved.clone();
16084        let outside_path = outside.path().to_path_buf();
16085        let (hub, server) = routed_json_hub(1, move |_| {
16086            // The client has already opened state/trust before this response.
16087            std::fs::rename(&swap_state, &swap_moved).unwrap();
16088            symlink(&outside_path, &swap_state).unwrap();
16089            (200, card.clone())
16090        });
16091        let cfg = test_hub_config(hub, state);
16092
16093        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
16094        assert_eq!(verified.head.seq, 0);
16095        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
16096        assert!(std::fs::read_dir(moved.join("trust"))
16097            .unwrap()
16098            .flatten()
16099            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
16100        server.join().unwrap();
16101    }
16102
16103    #[test]
16104    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
16105        let remote = signed_remote_fixture();
16106        let unrelated = signed_remote_fixture().key;
16107        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
16108        let state = tempfile::tempdir().unwrap();
16109        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
16110        cfg.brain_key = Some(unrelated);
16111        let error = sync_push(
16112            &cfg,
16113            TEST_BRAIN_ID,
16114            &[("DB.md".to_string(), "signed local content".to_string())],
16115        )
16116        .unwrap_err()
16117        .to_string();
16118        assert!(
16119            error.contains("not the verified current brain identity"),
16120            "{error}"
16121        );
16122        server.join().unwrap();
16123    }
16124
16125    #[test]
16126    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
16127        let remote = signed_remote_fixture();
16128        let new = signed_remote_fixture().key;
16129        let state = tempfile::tempdir().unwrap();
16130        let new_file = state.path().join("new.key");
16131        std::fs::write(
16132            &new_file,
16133            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
16134        )
16135        .unwrap();
16136        #[cfg(unix)]
16137        {
16138            use std::os::unix::fs::PermissionsExt as _;
16139            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
16140        }
16141        let forged = json!({
16142            "brain": TEST_BRAIN_ID,
16143            "identity": {
16144                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
16145                "publicKeySpki": new.public_key_spki,
16146            }
16147        })
16148        .to_string();
16149        let (hub, server) = scripted_json_hub(vec![
16150            (404, "{}".to_string()),
16151            (200, remote.card.clone()),
16152            (200, remote.feed.clone()),
16153            (200, forged),
16154            (200, remote.card),
16155            (200, remote.feed),
16156        ]);
16157        let cfg = test_hub_config(hub, state.path().to_path_buf());
16158        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
16159            .unwrap_err()
16160            .to_string();
16161        assert!(
16162            error.contains("without committing the verified new identity"),
16163            "{error}"
16164        );
16165        server.join().unwrap();
16166    }
16167
16168    #[test]
16169    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
16170        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16171        let raw = format!(
16172            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16173        );
16174        let pack = build_store_pack(&[
16175            (
16176                "DB.md".to_string(),
16177                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
16178            ),
16179            ("records/clients/truth.md".to_string(), raw.clone()),
16180        ])
16181        .unwrap();
16182        let by_id = resolve_from_verified_pack(
16183            "01j5qc3v9k4ym8rwbn2tqe6f7d",
16184            &AddressTarget::Id(record_id.to_string()),
16185            pack.clone(),
16186        )
16187        .unwrap();
16188        assert_eq!(by_id["document"]["summary"], "Signed truth");
16189        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
16190        assert_eq!(
16191            by_id["document"]["contentSha"],
16192            content_sha256(raw.as_bytes())
16193        );
16194
16195        let by_path = resolve_from_verified_pack(
16196            "01j5qc3v9k4ym8rwbn2tqe6f7d",
16197            &AddressTarget::Path("records/clients/truth.md".to_string()),
16198            pack,
16199        )
16200        .unwrap();
16201        assert_eq!(by_path["document"]["id"], record_id);
16202        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
16203
16204        let wrong_id = resolve_from_verified_record_bytes(
16205            TEST_BRAIN_ID,
16206            &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
16207            "records/clients/truth.md".to_string(),
16208            raw.as_bytes().to_vec(),
16209        )
16210        .unwrap_err()
16211        .to_string();
16212        assert!(wrong_id.contains("id differs"), "{wrong_id}");
16213
16214        let wrong_path = resolve_from_verified_record_bytes(
16215            TEST_BRAIN_ID,
16216            &AddressTarget::Path("records/clients/other.md".to_string()),
16217            "records/clients/truth.md".to_string(),
16218            raw.into_bytes(),
16219        )
16220        .unwrap_err()
16221        .to_string();
16222        assert!(wrong_path.contains("path differs"), "{wrong_path}");
16223    }
16224
16225    #[test]
16226    fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
16227        let path = "records/clients/truth.md";
16228        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16229        let raw = format!(
16230            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16231        );
16232        let sha256 = content_sha256(raw.as_bytes());
16233        let mut nonce = 0_u128;
16234        let tree = crate::linkmd_v2::build_content_tree(
16235            &[crate::linkmd_v2::ContentFile {
16236                path: path.to_string(),
16237                blob_hash: sha256.clone(),
16238                bytes: raw.len() as u64,
16239            }],
16240            None,
16241            &mut || {
16242                nonce += 1;
16243                format!("{nonce:032x}")
16244            },
16245        )
16246        .unwrap();
16247        let root = tree.root.clone().unwrap();
16248        let mut directory_root = root.clone();
16249        let mut proof = Vec::new();
16250        for component in path.split('/') {
16251            let inclusion =
16252                crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
16253            let child = match &inclusion {
16254                crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
16255                crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
16256                    panic!("fixture path must have an inclusion proof")
16257                }
16258            };
16259            proof.push(json!({
16260                "directory_root": directory_root,
16261                "component": component,
16262                "proof": inclusion,
16263            }));
16264            directory_root = child;
16265        }
16266        let commit_hash = "c".repeat(64);
16267        let pointer = V2PointerBody {
16268            v: 2,
16269            brain: TEST_BRAIN_ID.to_string(),
16270            seq: 1,
16271            commit_hash: commit_hash.clone(),
16272            feed_hash: "f".repeat(64),
16273            content_root: Some(root.clone()),
16274            asset_root: None,
16275            materializer: "dbmd-projection-v1".to_string(),
16276            signer_epoch: 1,
16277            control_revision: "d".repeat(64),
16278            backup_preparation: "e".repeat(64),
16279            prior_pointer_hash: None,
16280            signed_at: "2026-08-21T12:00:00.000Z".to_string(),
16281        };
16282        let manifest = json!({
16283            "v": 2,
16284            "commit": commit_hash,
16285            "content_root": root,
16286            "files": [{
16287                "path": path,
16288                "sha256": sha256,
16289                "bytes": raw.len(),
16290                "proof": proof,
16291            }],
16292            "next_cursor": Value::Null,
16293        })
16294        .to_string();
16295
16296        let path_manifest = manifest.clone();
16297        let (hub, server) = routed_json_hub(1, move |request| {
16298            assert_eq!(
16299                request,
16300                format!(
16301                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
16302                    "c".repeat(64)
16303                )
16304            );
16305            (200, path_manifest.clone())
16306        });
16307        let state = tempfile::tempdir().unwrap();
16308        let cfg = test_hub_config(hub, state.path().to_path_buf());
16309        let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
16310            .unwrap()
16311            .unwrap();
16312        assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
16313        assert!(by_path.proof.is_some());
16314        server.join().unwrap();
16315
16316        let (hub, server) = routed_json_hub(1, move |request| {
16317            assert_eq!(
16318                request,
16319                format!(
16320                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
16321                    "c".repeat(64)
16322                )
16323            );
16324            (404, r#"{"error":"File not found"}"#.to_string())
16325        });
16326        let state = tempfile::tempdir().unwrap();
16327        let cfg = test_hub_config(hub, state.path().to_path_buf());
16328        assert!(
16329            v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
16330                .unwrap()
16331                .is_none()
16332        );
16333        server.join().unwrap();
16334
16335        let id_manifest = manifest;
16336        let (hub, server) = routed_json_hub(1, move |request| {
16337            assert_eq!(
16338                request,
16339                format!(
16340                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
16341                    "c".repeat(64)
16342                )
16343            );
16344            (200, id_manifest.clone())
16345        });
16346        let state = tempfile::tempdir().unwrap();
16347        let cfg = test_hub_config(hub, state.path().to_path_buf());
16348        let (located_path, by_id) =
16349            v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
16350        assert_eq!(located_path, path);
16351        assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
16352        server.join().unwrap();
16353    }
16354
16355    #[test]
16356    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
16357        let unsorted = vec![
16358            ("records/a.md".to_string(), "alpha\n".to_string()),
16359            ("DB.md".to_string(), "# db\n".to_string()),
16360        ];
16361        let sorted = vec![
16362            ("DB.md".to_string(), "# db\n".to_string()),
16363            ("records/a.md".to_string(), "alpha\n".to_string()),
16364        ];
16365        let pack = build_store_pack(&unsorted).unwrap();
16366
16367        // This digest is shared with the hub implementation. It locks every
16368        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
16369        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
16370        // absence of descriptors/extras/comments/ZIP64.
16371        assert_eq!(pack.len(), 219);
16372        assert_eq!(
16373            content_sha256(&pack),
16374            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
16375        );
16376        assert_eq!(pack, build_store_pack(&sorted).unwrap());
16377        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
16378        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
16379        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
16380
16381        assert_eq!(
16382            parse_store_pack(pack).unwrap(),
16383            vec![
16384                ("DB.md".to_string(), b"# db\n".to_vec()),
16385                ("records/a.md".to_string(), b"alpha\n".to_vec()),
16386            ]
16387        );
16388    }
16389
16390    #[test]
16391    fn canonical_store_pack_validates_every_path_before_writing() {
16392        let duplicate = vec![
16393            ("DB.md".to_string(), "first".to_string()),
16394            ("DB.md".to_string(), "second".to_string()),
16395        ];
16396        assert!(build_store_pack(&duplicate)
16397            .unwrap_err()
16398            .to_string()
16399            .contains("duplicate path"));
16400        assert!(matches!(
16401            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16402            Err(LinkError::UnsafePath { .. })
16403        ));
16404    }
16405
16406    #[test]
16407    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16408        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16409        // One byte stands in for the central directory. The trailer itself is
16410        // structurally valid; the count is the sole reason for refusal.
16411        let mut bytes = vec![0_u8];
16412        let zip64_offset = bytes.len() as u64;
16413        bytes.extend_from_slice(b"PK\x06\x06");
16414        bytes.extend_from_slice(&44_u64.to_le_bytes());
16415        bytes.extend_from_slice(&[0_u8; 12]);
16416        bytes.extend_from_slice(&COUNT.to_le_bytes());
16417        bytes.extend_from_slice(&COUNT.to_le_bytes());
16418        bytes.extend_from_slice(&1_u64.to_le_bytes());
16419        bytes.extend_from_slice(&0_u64.to_le_bytes());
16420        bytes.extend_from_slice(b"PK\x06\x07");
16421        bytes.extend_from_slice(&0_u32.to_le_bytes());
16422        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16423        bytes.extend_from_slice(&1_u32.to_le_bytes());
16424        bytes.extend_from_slice(b"PK\x05\x06");
16425        bytes.extend_from_slice(&0_u16.to_le_bytes());
16426        bytes.extend_from_slice(&0_u16.to_le_bytes());
16427        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16428        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16429        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16430        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16431        bytes.extend_from_slice(&0_u16.to_le_bytes());
16432
16433        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16434            .unwrap_err()
16435            .to_string();
16436        assert!(error.contains("invalid file count"), "{error}");
16437    }
16438
16439    #[test]
16440    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16441        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16442        let mut bytes = vec![0_u8];
16443        let zip64_offset = bytes.len() as u64;
16444        bytes.extend_from_slice(b"PK\x06\x06");
16445        bytes.extend_from_slice(&44_u64.to_le_bytes());
16446        bytes.extend_from_slice(&[0_u8; 12]);
16447        bytes.extend_from_slice(&COUNT.to_le_bytes());
16448        bytes.extend_from_slice(&COUNT.to_le_bytes());
16449        bytes.extend_from_slice(&1_u64.to_le_bytes());
16450        bytes.extend_from_slice(&0_u64.to_le_bytes());
16451        bytes.extend_from_slice(b"PK\x06\x07");
16452        bytes.extend_from_slice(&0_u32.to_le_bytes());
16453        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16454        bytes.extend_from_slice(&1_u32.to_le_bytes());
16455        bytes.extend_from_slice(b"PK\x05\x06");
16456        bytes.extend_from_slice(&0_u16.to_le_bytes());
16457        bytes.extend_from_slice(&0_u16.to_le_bytes());
16458        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16459        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16460        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16461        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16462        bytes.extend_from_slice(&0_u16.to_le_bytes());
16463        // The old parser trusted this last low-count signature, while
16464        // ZipArchive fell back to the real Zip64 directory and allocated for
16465        // COUNT entries. Its central-directory offsets are deliberately fake.
16466        let fake_eocd = bytes.len() as u32;
16467        bytes.extend_from_slice(b"PK\x05\x06");
16468        bytes.extend_from_slice(&0_u16.to_le_bytes());
16469        bytes.extend_from_slice(&0_u16.to_le_bytes());
16470        bytes.extend_from_slice(&1_u16.to_le_bytes());
16471        bytes.extend_from_slice(&1_u16.to_le_bytes());
16472        bytes.extend_from_slice(&0_u32.to_le_bytes());
16473        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16474        bytes.extend_from_slice(&0_u16.to_le_bytes());
16475
16476        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16477            .unwrap_err()
16478            .to_string();
16479        assert!(error.contains("central directory"), "{error}");
16480    }
16481
16482    #[test]
16483    fn strict_http_status_handling_rejects_redirects_without_panicking() {
16484        let error = ensure_ok(
16485            HubResponse {
16486                status: 302,
16487                body: Some(json!({"redirect": "/elsewhere"})),
16488            },
16489            "mutation",
16490        )
16491        .unwrap_err();
16492        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16493
16494        let error = ensure_raw_ok(
16495            RawHubResponse {
16496                status: 302,
16497                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16498            },
16499            "feed",
16500        )
16501        .unwrap_err();
16502        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16503    }
16504
16505    #[cfg(unix)]
16506    #[test]
16507    fn collect_push_files_refuses_external_symlink_and_nested_store() {
16508        use std::os::unix::fs::symlink;
16509
16510        let root = tempfile::tempdir().unwrap();
16511        std::fs::write(
16512            root.path().join("DB.md"),
16513            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16514        )
16515        .unwrap();
16516        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16517
16518        let external = tempfile::tempdir().unwrap();
16519        let secret = external.path().join("secret.md");
16520        std::fs::write(&secret, "TOP SECRET").unwrap();
16521        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16522
16523        let store = Store::open_strict(root.path()).unwrap();
16524        let err = collect_push_files(&store).unwrap_err().to_string();
16525        assert!(err.contains("cannot push"), "{err}");
16526        assert!(
16527            !err.contains("TOP SECRET"),
16528            "external bytes must never leak"
16529        );
16530
16531        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16532        let nested = root.path().join("records/nested");
16533        std::fs::create_dir_all(&nested).unwrap();
16534        std::fs::write(
16535            nested.join("DB.md"),
16536            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16537        )
16538        .unwrap();
16539        let err = collect_push_files(&store).unwrap_err().to_string();
16540        assert!(err.contains("nested db.md store"), "{err}");
16541    }
16542
16543    #[cfg(unix)]
16544    #[test]
16545    fn remote_push_uses_opened_root_after_path_replacement() {
16546        use std::os::unix::fs::symlink;
16547
16548        let sandbox = tempfile::tempdir().unwrap();
16549        let root = sandbox.path().join("store");
16550        std::fs::create_dir_all(root.join("records/notes")).unwrap();
16551        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16552        std::fs::write(
16553            root.join("records/notes/owned.md"),
16554            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16555        )
16556        .unwrap();
16557        let store = Store::open_strict(&root).unwrap();
16558        let detached = sandbox.path().join("detached");
16559        std::fs::rename(&root, &detached).unwrap();
16560
16561        let replacement = sandbox.path().join("replacement");
16562        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16563        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16564        std::fs::write(
16565            replacement.join("records/notes/secret.md"),
16566            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16567        )
16568        .unwrap();
16569        symlink(&replacement, &root).unwrap();
16570
16571        let files = collect_push_files(&store).unwrap();
16572        let wire_text = files
16573            .iter()
16574            .map(|(path, content)| format!("{path}\n{content}"))
16575            .collect::<Vec<_>>()
16576            .join("\n");
16577        assert!(wire_text.contains("owned upload"));
16578        assert!(!wire_text.contains("replacement sentinel"));
16579        assert!(!wire_text.contains("records/notes/secret.md"));
16580
16581        let remote = signed_remote_fixture();
16582        let (hub, server) = scripted_json_hub(vec![
16583            (200, remote.card),
16584            (200, remote.feed),
16585            (200, json!({"ok": true}).to_string()),
16586        ]);
16587        let state = tempfile::tempdir().unwrap();
16588        let cfg = test_hub_config(hub, state.path().to_path_buf());
16589        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16590        assert_eq!(pushed, json!({"ok": true}));
16591        server.join().unwrap();
16592    }
16593
16594    #[test]
16595    fn signed_feed_item_verifies_identity_hash_and_signature() {
16596        use ring::rand::SystemRandom;
16597        use ring::signature::{Ed25519KeyPair, KeyPair};
16598
16599        const PREFIX: &[u8] = &[
16600            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16601        ];
16602        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16603        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16604        let mut spki = PREFIX.to_vec();
16605        spki.extend_from_slice(pair.public_key().as_ref());
16606        let public_key = URL_SAFE_NO_PAD.encode(&spki);
16607        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16608        let mut entry = FeedEntry {
16609            v: 1,
16610            seq: 1,
16611            ts: "2026-07-14T00:00:00.000Z".to_string(),
16612            brain: format!("ed25519:{fingerprint}"),
16613            public_key: public_key.clone(),
16614            kind: "push".to_string(),
16615            op: "snapshot".to_string(),
16616            pack_sha256: "a".repeat(64),
16617            files: vec![FeedFile {
16618                path: "DB.md".to_string(),
16619                sha256: "b".repeat(64),
16620                bytes: 3,
16621            }],
16622            removed: vec![],
16623            prev_entry_hash: None,
16624            sig: String::new(),
16625        };
16626        let unsigned = UnsignedFeedEntry {
16627            v: entry.v,
16628            seq: entry.seq,
16629            ts: &entry.ts,
16630            brain: &entry.brain,
16631            public_key: &entry.public_key,
16632            kind: &entry.kind,
16633            op: &entry.op,
16634            pack_sha256: &entry.pack_sha256,
16635            files: &entry.files,
16636            removed: &entry.removed,
16637            prev_entry_hash: &entry.prev_entry_hash,
16638        };
16639        entry.sig =
16640            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16641        let mut exact = serde_json::to_vec(&entry).unwrap();
16642        exact.push(b'\n');
16643        let item = FeedItem {
16644            hash: format!("{:x}", Sha256::digest(&exact)),
16645            entry,
16646        };
16647        let identity = FeedIdentity {
16648            fingerprint,
16649            public_key_spki: public_key,
16650            previous: Vec::new(),
16651            rotations: Vec::new(),
16652        };
16653        assert!(verify_feed_item(&item, &identity).is_ok());
16654        let mut tampered = item;
16655        tampered.entry.pack_sha256 = "c".repeat(64);
16656        assert!(verify_feed_item(&tampered, &identity).is_err());
16657    }
16658
16659    #[test]
16660    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16661        let rng = ring::rand::SystemRandom::new();
16662        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16663        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16664        let (spki, multikey) = public_identity_for(&pair);
16665        let identity = V2HeadIdentity {
16666            custody: "self".to_string(),
16667            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16668            public_key_spki: spki.clone(),
16669            previous: Vec::new(),
16670            rotations: Vec::new(),
16671        };
16672        let unsigned = json!({
16673            "actor_ref": "a".repeat(64),
16674            "asset_root": Value::Null,
16675            "brain": multikey,
16676            "changes_sha256": "b".repeat(64),
16677            "control_revision": "c".repeat(64),
16678            "materializer": "dbmd-projection-v1",
16679            "op": "changeset",
16680            "parent_asset_root": Value::Null,
16681            "parent_commit": Value::Null,
16682            "parent_root": Value::Null,
16683            "prev_entry_hash": Value::Null,
16684            "public_key": spki,
16685            "seq": 1,
16686            "signer_epoch": 1,
16687            "state_root": "d".repeat(64),
16688            "ts": "2026-08-19T12:00:00.000Z",
16689            "v": 2,
16690            "v1_bridge": {
16691                "feed_hash": "e".repeat(64),
16692                "head_seq": 7,
16693                "pack_sha256": "f".repeat(64),
16694            },
16695        });
16696        let sign_value = |value: Value| {
16697            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16698            let mut object = value.as_object().unwrap().clone();
16699            object.insert(
16700                "sig".to_string(),
16701                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16702            );
16703            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16704        };
16705        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16706
16707        let mut extra = unsigned.clone();
16708        extra
16709            .as_object_mut()
16710            .unwrap()
16711            .insert("future".to_string(), Value::Bool(true));
16712        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16713
16714        let mut missing = unsigned.clone();
16715        missing.as_object_mut().unwrap().remove("v1_bridge");
16716        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16717
16718        let mut invalid_bridge = unsigned;
16719        invalid_bridge.as_object_mut().unwrap().insert(
16720            "v1_bridge".to_string(),
16721            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16722        );
16723        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16724    }
16725
16726    #[test]
16727    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16728        let vector: Value = serde_json::from_str(include_str!(
16729            "../tests/vectors/linkmd-v2-commit-bridge.json"
16730        ))
16731        .unwrap();
16732        let identity_value = vector.get("identity").unwrap();
16733        let identity = V2HeadIdentity {
16734            custody: "self".to_string(),
16735            fingerprint: identity_value
16736                .get("fingerprint")
16737                .and_then(Value::as_str)
16738                .unwrap()
16739                .to_string(),
16740            public_key_spki: identity_value
16741                .get("public_key_spki")
16742                .and_then(Value::as_str)
16743                .unwrap()
16744                .to_string(),
16745            previous: Vec::new(),
16746            rotations: Vec::new(),
16747        };
16748        let private = URL_SAFE_NO_PAD
16749            .decode(
16750                identity_value
16751                    .get("private_key_pkcs8")
16752                    .and_then(Value::as_str)
16753                    .unwrap(),
16754            )
16755            .unwrap();
16756        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16757            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16758            .unwrap();
16759        let base = vector.get("body").unwrap().as_object().unwrap();
16760
16761        for item in vector.get("valid").unwrap().as_array().unwrap() {
16762            let mut body = base.clone();
16763            body.insert(
16764                "v1_bridge".to_string(),
16765                item.get("v1_bridge").unwrap().clone(),
16766            );
16767            body.insert(
16768                "sig".to_string(),
16769                item.get("signature_base64url").unwrap().clone(),
16770            );
16771            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16772            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16773            assert_eq!(
16774                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16775                item.get("commit_hash").and_then(Value::as_str).unwrap()
16776            );
16777            assert_eq!(
16778                format!("{:x}", Sha256::digest(&signed)),
16779                item.get("feed_hash").and_then(Value::as_str).unwrap()
16780            );
16781        }
16782
16783        for item in vector.get("invalid").unwrap().as_array().unwrap() {
16784            let mut body = base.clone();
16785            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16786                for field in remove {
16787                    body.remove(field.as_str().unwrap());
16788                }
16789            }
16790            if let Some(set) = item.get("set").and_then(Value::as_object) {
16791                for (field, value) in set {
16792                    body.insert(field.clone(), value.clone());
16793                }
16794            }
16795            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16796            body.insert(
16797                "sig".to_string(),
16798                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16799            );
16800            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16801            assert!(
16802                verified_v2_commit_object(&signed, &identity).is_err(),
16803                "accepted invalid shared vector {}",
16804                item.get("reason").and_then(Value::as_str).unwrap()
16805            );
16806        }
16807    }
16808
16809    #[test]
16810    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16811        let vector: Value = serde_json::from_str(include_str!(
16812            "../tests/vectors/linkmd-v2-changeset-withheld.json"
16813        ))
16814        .unwrap();
16815        assert_eq!(
16816            vector.get("profile").and_then(Value::as_str),
16817            Some("link.md-v2-changeset-withheld")
16818        );
16819        let canonical =
16820            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16821        let expected = STANDARD
16822            .decode(
16823                vector
16824                    .get("canonical_base64")
16825                    .and_then(Value::as_str)
16826                    .unwrap(),
16827            )
16828            .unwrap();
16829        assert_eq!(canonical, expected);
16830        assert_eq!(
16831            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16832            vector.get("domain_hash").and_then(Value::as_str).unwrap()
16833        );
16834    }
16835
16836    #[test]
16837    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16838        let remote = signed_remote_fixture();
16839        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16840        let legacy_item = legacy.entries.first().unwrap();
16841        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16842        let body = json!({
16843            "actor_ref": "a".repeat(64),
16844            "asset_root": Value::Null,
16845            "brain": remote.key.multikey,
16846            "changes_sha256": "b".repeat(64),
16847            "control_revision": "c".repeat(64),
16848            "materializer": "dbmd-projection-v1",
16849            "op": "changeset",
16850            "parent_asset_root": Value::Null,
16851            "parent_commit": Value::Null,
16852            "parent_root": Value::Null,
16853            "prev_entry_hash": Value::Null,
16854            "public_key": remote.key.public_key_spki,
16855            "seq": 1,
16856            "signer_epoch": 1,
16857            "state_root": "d".repeat(64),
16858            "ts": "2026-08-19T12:00:00.000Z",
16859            "v": 2,
16860            "v1_bridge": {
16861                "feed_hash": legacy_item.hash,
16862                "head_seq": legacy_item.entry.seq,
16863                "pack_sha256": legacy_item.entry.pack_sha256,
16864            },
16865        });
16866        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
16867        let mut signed = body.as_object().unwrap().clone();
16868        signed.insert(
16869            "sig".to_string(),
16870            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16871        );
16872        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
16873        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
16874        let feed_hash = content_sha256(&raw);
16875        let pointer = V2PointerBody {
16876            v: 2,
16877            brain: TEST_BRAIN_ID.to_string(),
16878            seq: 1,
16879            commit_hash: commit_hash.clone(),
16880            feed_hash: feed_hash.clone(),
16881            content_root: Some("d".repeat(64)),
16882            asset_root: None,
16883            materializer: "dbmd-projection-v1".to_string(),
16884            signer_epoch: 1,
16885            control_revision: "c".repeat(64),
16886            backup_preparation: "e".repeat(64),
16887            prior_pointer_hash: None,
16888            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
16889        };
16890        let v2_page = json!({
16891            "v": 2,
16892            "head_seq": 1,
16893            "head_commit_hash": commit_hash,
16894            "head_feed_hash": feed_hash,
16895            "entries": [{
16896                "seq": 1,
16897                "commit_hash": pointer.commit_hash,
16898                "feed_hash": pointer.feed_hash,
16899                "bytes_base64": STANDARD.encode(&raw),
16900            }],
16901            "next_after": 1,
16902            "complete": true,
16903        })
16904        .to_string();
16905        let identity = V2HeadIdentity {
16906            custody: "self".to_string(),
16907            fingerprint: remote.identity.fingerprint.clone(),
16908            public_key_spki: remote.identity.public_key_spki.clone(),
16909            previous: Vec::new(),
16910            rotations: Vec::new(),
16911        };
16912        let checkpoint = TrustState {
16913            v: 2,
16914            origin: "unused".to_string(),
16915            requested: TEST_BRAIN_ID.to_string(),
16916            brain: TEST_BRAIN_ID.to_string(),
16917            home: None,
16918            anchor: remote.key.multikey.clone(),
16919            current: remote.key.multikey,
16920            head_seq: legacy_item.entry.seq,
16921            feed_hash: Some(legacy_item.hash.clone()),
16922            rotations: Vec::new(),
16923            hub_signer: None,
16924            protocol_profile: None,
16925        };
16926        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
16927        let state = tempfile::tempdir().unwrap();
16928        let cfg = test_hub_config(hub, state.path().to_path_buf());
16929        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
16930        server.join().unwrap();
16931
16932        let mut wrong = checkpoint;
16933        wrong.feed_hash = Some("0".repeat(64));
16934        let (hub, server) = scripted_json_hub(vec![(
16935            200,
16936            json!({
16937                "v": 2,
16938                "head_seq": 1,
16939                "head_commit_hash": pointer.commit_hash,
16940                "head_feed_hash": pointer.feed_hash,
16941                "entries": [{
16942                    "seq": 1,
16943                    "commit_hash": pointer.commit_hash,
16944                    "feed_hash": pointer.feed_hash,
16945                    "bytes_base64": STANDARD.encode(&raw),
16946                }],
16947                "next_after": 1,
16948                "complete": true,
16949            })
16950            .to_string(),
16951        )]);
16952        let state = tempfile::tempdir().unwrap();
16953        let cfg = test_hub_config(hub, state.path().to_path_buf());
16954        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
16955        server.join().unwrap();
16956    }
16957
16958    #[test]
16959    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
16960        let rng = ring::rand::SystemRandom::new();
16961        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16962        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16963        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16964        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16965        let (old_spki, old_multikey) = public_identity_for(&old);
16966        let (new_spki, new_multikey) = public_identity_for(&new);
16967        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
16968            v: 1,
16969            op: "rotate",
16970            brain: &old_multikey,
16971            public_key: &old_spki,
16972            new_brain: &new_multikey,
16973            new_public_key: &new_spki,
16974            prior_head_seq: 1,
16975            prior_feed_hash: Some(&"9".repeat(64)),
16976            ts: "2026-08-19T12:01:00.000Z".to_string(),
16977        })
16978        .unwrap();
16979        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
16980        let rotation = format!(
16981            "{},\"sig\":\"{}\"}}",
16982            &rotation_unsigned[..rotation_unsigned.len() - 1],
16983            rotation_sig
16984        );
16985        let identity = V2HeadIdentity {
16986            custody: "self".to_string(),
16987            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16988            public_key_spki: new_spki.clone(),
16989            previous: vec![V2PreviousIdentity {
16990                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16991                public_key_spki: old_spki.clone(),
16992            }],
16993            rotations: vec![rotation],
16994        };
16995        let commit = |seq: u64,
16996                      epoch: u64,
16997                      multikey: &str,
16998                      spki: &str,
16999                      pair: &ring::signature::Ed25519KeyPair| {
17000            let value = json!({
17001                "actor_ref": "a".repeat(64),
17002                "asset_root": Value::Null,
17003                "brain": multikey,
17004                "changes_sha256": "b".repeat(64),
17005                "control_revision": "c".repeat(64),
17006                "materializer": "dbmd-projection-v1",
17007                "op": "changeset",
17008                "parent_asset_root": Value::Null,
17009                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
17010                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
17011                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
17012                "public_key": spki,
17013                "seq": seq,
17014                "signer_epoch": epoch,
17015                "state_root": "1".repeat(64),
17016                "ts": "2026-08-19T12:00:00.000Z",
17017                "v": 2,
17018                "v1_bridge": Value::Null,
17019            });
17020            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17021            let mut object = value.as_object().unwrap().clone();
17022            object.insert(
17023                "sig".to_string(),
17024                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17025            );
17026            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17027        };
17028
17029        assert!(verified_v2_commit_object(
17030            &commit(1, 1, &old_multikey, &old_spki, &old),
17031            &identity,
17032        )
17033        .is_ok());
17034        assert!(verified_v2_commit_object(
17035            &commit(2, 2, &new_multikey, &new_spki, &new),
17036            &identity,
17037        )
17038        .is_ok());
17039        assert!(verified_v2_commit_object(
17040            &commit(2, 1, &old_multikey, &old_spki, &old),
17041            &identity,
17042        )
17043        .is_err());
17044        assert!(verified_v2_commit_object(
17045            &commit(1, 2, &new_multikey, &new_spki, &new),
17046            &identity,
17047        )
17048        .is_err());
17049    }
17050
17051    #[test]
17052    fn a_self_custody_entry_verifies_like_any_hub_entry() {
17053        let rng = ring::rand::SystemRandom::new();
17054        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17055        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17056        let (spki, multikey) = public_identity_for(&pair);
17057        let key = AgentSigningKey {
17058            pkcs8: pkcs8.as_ref().to_vec(),
17059            multikey: multikey.clone(),
17060            public_key_spki: spki.clone(),
17061        };
17062        let files = vec![WireFeedFile {
17063            path: "DB.md".to_string(),
17064            sha256: "a".repeat(64),
17065            bytes: 3,
17066        }];
17067        let raw = self_custody_entry(
17068            &key,
17069            1,
17070            "2026-07-23T12:00:00.000Z".to_string(),
17071            &"c".repeat(64),
17072            &files,
17073            None,
17074        )
17075        .unwrap();
17076        // The exact client serialization parses as a feed entry and passes the
17077        // SAME verifier every subscribe read runs — the self-custody path
17078        // produces first-class wire-profile-v1 entries.
17079        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
17080        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
17081        let item = FeedItem { hash, entry };
17082        let identity = FeedIdentity {
17083            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17084            public_key_spki: spki,
17085            previous: Vec::new(),
17086            rotations: Vec::new(),
17087        };
17088        assert!(verify_feed_item(&item, &identity).is_ok());
17089    }
17090
17091    #[test]
17092    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
17093        let rng = ring::rand::SystemRandom::new();
17094        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17095        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17096        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17097        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17098        let (old_spki, old_multikey) = public_identity_for(&old);
17099        let (new_spki, new_multikey) = public_identity_for(&new);
17100        let unsigned = serde_json::to_string(&UnsignedRotation {
17101            v: 1,
17102            op: "rotate",
17103            brain: &old_multikey,
17104            public_key: &old_spki,
17105            new_brain: &new_multikey,
17106            new_public_key: &new_spki,
17107            prior_head_seq: 1,
17108            prior_feed_hash: Some(&"a".repeat(64)),
17109            ts: "2026-07-30T12:00:00.000Z".to_string(),
17110        })
17111        .unwrap();
17112        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
17113        let rotation = format!(
17114            "{},\"sig\":\"{}\"}}",
17115            &unsigned[..unsigned.len() - 1],
17116            signature
17117        );
17118        let identity = FeedIdentity {
17119            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17120            public_key_spki: new_spki,
17121            previous: vec![PreviousIdentity {
17122                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17123                public_key_spki: old_spki,
17124            }],
17125            rotations: vec![rotation],
17126        };
17127        let pin = TrustState {
17128            v: 2,
17129            origin: "https://hub.example".to_string(),
17130            requested: "brain".to_string(),
17131            brain: "brain".to_string(),
17132            home: None,
17133            anchor: old_multikey.clone(),
17134            current: old_multikey.clone(),
17135            head_seq: 1,
17136            feed_hash: Some("a".repeat(64)),
17137            rotations: Vec::new(),
17138            hub_signer: None,
17139            protocol_profile: None,
17140        };
17141        assert_eq!(
17142            verify_identity_chain(&identity, Some(&pin)).unwrap(),
17143            old_multikey
17144        );
17145        let mut accepted = pin.clone();
17146        accepted.current = new_multikey.clone();
17147        accepted.rotations = identity.rotations.clone();
17148        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
17149            v: 1,
17150            op: "rotate",
17151            brain: &old_multikey,
17152            public_key: &identity.previous[0].public_key_spki,
17153            new_brain: &new_multikey,
17154            new_public_key: &identity.public_key_spki,
17155            prior_head_seq: 1,
17156            prior_feed_hash: Some(&"a".repeat(64)),
17157            ts: "2026-07-30T12:00:01.000Z".to_string(),
17158        })
17159        .unwrap();
17160        let alternate_signature =
17161            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
17162        let mut rewritten = identity.clone();
17163        rewritten.rotations[0] = format!(
17164            "{},\"sig\":\"{}\"}}",
17165            &alternate_unsigned[..alternate_unsigned.len() - 1],
17166            alternate_signature
17167        );
17168        assert!(
17169            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
17170            "an alternate valid statement must not rewrite accepted history"
17171        );
17172
17173        let mut stale_entry = FeedEntry {
17174            v: 1,
17175            seq: 2,
17176            ts: "2026-07-30T12:01:00.000Z".to_string(),
17177            brain: pin.current.clone(),
17178            public_key: identity.previous[0].public_key_spki.clone(),
17179            kind: "push".to_string(),
17180            op: "snapshot".to_string(),
17181            pack_sha256: "b".repeat(64),
17182            files: Vec::new(),
17183            removed: Vec::new(),
17184            prev_entry_hash: pin.feed_hash.clone(),
17185            sig: String::new(),
17186        };
17187        let stale_unsigned = UnsignedFeedEntry {
17188            v: stale_entry.v,
17189            seq: stale_entry.seq,
17190            ts: &stale_entry.ts,
17191            brain: &stale_entry.brain,
17192            public_key: &stale_entry.public_key,
17193            kind: &stale_entry.kind,
17194            op: &stale_entry.op,
17195            pack_sha256: &stale_entry.pack_sha256,
17196            files: &stale_entry.files,
17197            removed: &stale_entry.removed,
17198            prev_entry_hash: &stale_entry.prev_entry_hash,
17199        };
17200        stale_entry.sig = URL_SAFE_NO_PAD.encode(
17201            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
17202                .as_ref(),
17203        );
17204        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
17205        stale_exact.push(b'\n');
17206        let stale_item = FeedItem {
17207            hash: content_sha256(&stale_exact),
17208            entry: stale_entry,
17209        };
17210        assert!(
17211            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
17212                .is_err(),
17213            "a key retired before the checkpoint must never append after it"
17214        );
17215        assert!(
17216            verify_feed_item(&stale_item, &identity).is_err(),
17217            "an old key must never append after its signed rotation boundary"
17218        );
17219
17220        let mut missing = identity.clone();
17221        missing.rotations.clear();
17222        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
17223
17224        let mut tampered = identity;
17225        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
17226        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
17227    }
17228
17229    #[cfg(unix)]
17230    #[test]
17231    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
17232        use std::os::unix::fs::symlink;
17233
17234        let dir = tempfile::tempdir().unwrap();
17235        let target = dir.path().join("valuable.txt");
17236        let planted = dir.path().join("agent.key");
17237        std::fs::write(&target, "do not overwrite").unwrap();
17238        symlink(&target, &planted).unwrap();
17239
17240        assert!(matches!(
17241            generate_agent_key(&planted),
17242            Err(LinkError::BadAgentKey { .. })
17243        ));
17244        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
17245    }
17246
17247    #[cfg(unix)]
17248    #[test]
17249    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
17250        use std::os::unix::fs::symlink;
17251
17252        let root = tempfile::tempdir().unwrap();
17253        let outside = tempfile::tempdir().unwrap();
17254        symlink(outside.path(), root.path().join("redirect")).unwrap();
17255
17256        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
17257        assert!(!outside.path().join("agent.key").exists());
17258    }
17259
17260    // ── Address parsing ─────────────────────────────────────────────────────
17261
17262    #[test]
17263    fn address_bare_brain_with_and_without_sigil() {
17264        for raw in ["@acme-ops", "acme-ops"] {
17265            let a = Address::parse(raw).expect(raw);
17266            assert_eq!(a.brain, "acme-ops");
17267            assert_eq!(a.target, None);
17268        }
17269    }
17270
17271    #[test]
17272    fn address_ulid_target_parses_as_id() {
17273        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
17274        assert_eq!(a.brain, "acme");
17275        assert_eq!(
17276            a.target,
17277            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
17278        );
17279    }
17280
17281    #[test]
17282    fn address_md_path_target_parses_as_path() {
17283        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
17284        assert_eq!(
17285            a.target,
17286            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
17287        );
17288    }
17289
17290    #[test]
17291    fn address_rejects_malformed_forms() {
17292        for raw in [
17293            "",
17294            "@",
17295            "@/x",
17296            "@acme/",
17297            "@acme/../etc/passwd",
17298            "@acme/records/.hidden.md",
17299            "@ACME",             // uppercase is not a hub ref shape
17300            "@acme/notes/x.txt", // target is neither ULID nor .md path
17301            "@a b",              // whitespace
17302        ] {
17303            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
17304        }
17305    }
17306
17307    // ── Path safety ─────────────────────────────────────────────────────────
17308
17309    #[test]
17310    fn safe_paths_accept_store_shapes_and_reject_escapes() {
17311        for ok in [
17312            "DB.md",
17313            "assets.jsonl",
17314            "records/clients/lumio.md",
17315            "sources/emails/2026/07/x.md",
17316        ] {
17317            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
17318        }
17319        for bad in [
17320            "",
17321            "/etc/passwd",
17322            "../up.md",
17323            "records/../../up.md",
17324            "records//x.md",
17325            ".dbmd/config",
17326            "records/.hidden/x.md",
17327            "records/a b.md",
17328            "records\\win.md",
17329        ] {
17330            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
17331        }
17332    }
17333
17334    #[cfg(unix)]
17335    #[test]
17336    fn opened_destination_capability_survives_an_ancestor_path_swap() {
17337        use std::os::unix::fs::symlink;
17338
17339        let work = tempfile::tempdir().unwrap();
17340        let outside = tempfile::tempdir().unwrap();
17341        let original = work.path().join("destination");
17342        let moved = work.path().join("destination-moved");
17343        let directory = open_or_create_dir_nofollow(&original).unwrap();
17344
17345        std::fs::rename(&original, &moved).unwrap();
17346        symlink(outside.path(), &original).unwrap();
17347        write_pull_entries_beneath_dir(
17348            &directory,
17349            &[("records/note.md".to_string(), b"held inode".to_vec())],
17350        )
17351        .unwrap();
17352
17353        assert_eq!(
17354            std::fs::read(moved.join("records/note.md")).unwrap(),
17355            b"held inode"
17356        );
17357        assert!(!outside.path().join("records/note.md").exists());
17358    }
17359
17360    // ── Config resolution (flag + file precedence; env is covered by the CLI
17361    //    integration tests, where a child process isolates it) ───────────────
17362
17363    #[test]
17364    fn hub_config_flag_beats_file_and_requires_some_source() {
17365        let dir = tempfile::tempdir().unwrap();
17366        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
17367        std::fs::write(
17368            dir.path().join(CONFIG_REL_PATH),
17369            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
17370        )
17371        .unwrap();
17372
17373        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
17374        assert_eq!(from_flag.hub, "https://flag.example.com");
17375
17376        let from_file = hub_config(None, dir.path()).unwrap();
17377        assert_eq!(from_file.hub, "https://file.example.com");
17378
17379        let none = hub_config(None, tempfile::tempdir().unwrap().path());
17380        assert!(matches!(none, Err(LinkError::NoHub)));
17381    }
17382
17383    #[test]
17384    fn https_guard_allows_loopback_only_for_plain_http() {
17385        assert!(assert_safe_hub("https://hub.example.com").is_ok());
17386        assert!(assert_safe_hub("http://localhost:3000").is_ok());
17387        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
17388        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
17389        assert!(matches!(
17390            assert_safe_hub("http://hub.example.com"),
17391            Err(LinkError::UnsafeHub { .. })
17392        ));
17393        assert!(matches!(
17394            assert_safe_hub("hub.example.com"),
17395            Err(LinkError::UnsafeHub { .. })
17396        ));
17397        assert!(matches!(
17398            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
17399            Err(LinkError::UnsafeHub { .. })
17400        ));
17401        assert!(matches!(
17402            assert_safe_hub("https://hub.example.com@attacker.example"),
17403            Err(LinkError::UnsafeHub { .. })
17404        ));
17405        assert!(matches!(
17406            assert_safe_hub("https://hub.example.com/base"),
17407            Err(LinkError::UnsafeHub { .. })
17408        ));
17409    }
17410
17411    #[test]
17412    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17413        for blocked in [
17414            "127.0.0.1",
17415            "10.0.0.1",
17416            "100.64.0.1",
17417            "169.254.169.254",
17418            "172.16.0.1",
17419            "192.168.0.1",
17420            "192.88.99.1",
17421            "198.18.0.1",
17422            "203.0.113.1",
17423            "::1",
17424            "fe80::1",
17425            "fd00::1",
17426            "2001:db8::1",
17427            "2001:1::1",
17428            "2002:7f00:1::",
17429            "3fff::1",
17430        ] {
17431            assert!(
17432                !is_public_registry_ip(blocked.parse().unwrap()),
17433                "must block {blocked}"
17434            );
17435        }
17436        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17437        assert!(is_public_registry_ip(
17438            "2606:4700:4700::1111".parse().unwrap()
17439        ));
17440        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17441    }
17442
17443    #[test]
17444    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17445        use ureq::Resolver as _;
17446
17447        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17448        let resolver = PinnedRegistryResolver {
17449            netloc: "home.example:443".to_string(),
17450            addresses: vec![pinned],
17451        };
17452        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17453        assert!(resolver.resolve("127.0.0.1:443").is_err());
17454        assert_eq!(
17455            resolver.resolve("home.example:443").unwrap(),
17456            vec![pinned],
17457            "subsequent connects reuse the validated answer instead of DNS"
17458        );
17459    }
17460
17461    #[test]
17462    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17463        let cfg = HubConfig {
17464            hub: "https://hub.example".to_string(),
17465            key: None,
17466            agent_key: None,
17467            brain_key: None,
17468            state_dir: tempfile::tempdir().unwrap().keep(),
17469            store_selected: false,
17470        };
17471        assert!(
17472            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17473            "a production hub must not turn its presigned URL into an SSRF primitive"
17474        );
17475
17476        let store_selected = HubConfig {
17477            hub: "https://127.0.0.1".to_string(),
17478            store_selected: true,
17479            ..cfg
17480        };
17481        assert!(
17482            hub_agent(&store_selected).is_err(),
17483            "bytes in a cloned store must not select a private-network hub"
17484        );
17485    }
17486
17487    #[test]
17488    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17489        assert_eq!(
17490            one_past_bounded_limit(MAX_PACK_BYTES),
17491            Some(MAX_PACK_BYTES + 1),
17492            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17493        );
17494        assert_eq!(
17495            presigned_download_read_limit(),
17496            MAX_PACK_BYTES + 1,
17497            "the presigned reader is capped by the client constant, not a hub response"
17498        );
17499        assert_eq!(
17500            one_past_bounded_limit(u64::MAX),
17501            None,
17502            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17503        );
17504    }
17505
17506    #[test]
17507    fn https_guard_matches_the_scheme_case_insensitively() {
17508        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
17509        // hub is still HTTPS, never a misleading non-HTTPS refusal.
17510        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17511        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17512        // And an uppercase plain-HTTP hub is still refused outside loopback.
17513        assert!(matches!(
17514            assert_safe_hub("HTTP://hub.example.com"),
17515            Err(LinkError::UnsafeHub { .. })
17516        ));
17517    }
17518
17519    #[test]
17520    fn clean_key_refuses_paste_artifacts_without_echoing() {
17521        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
17522        for bad in ["vc account", "vc\naccount", "ключ", ""] {
17523            let err = clean_key(bad).unwrap_err();
17524            assert!(matches!(err, LinkError::BadKey));
17525            assert!(
17526                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17527                "error must not echo the key"
17528            );
17529        }
17530    }
17531
17532    // ── Verb entry gates: refs must never reshape the request path ──────────
17533
17534    /// A config whose hub passes the loopback guard but is never listened on:
17535    /// every refusal below must come from the entry gate BEFORE a request
17536    /// exists — a dial on this dead port would surface `Transport` instead.
17537    fn dead_hub() -> HubConfig {
17538        HubConfig {
17539            hub: "http://127.0.0.1:9".to_string(),
17540            key: Some("k".to_string()),
17541            agent_key: None,
17542            brain_key: None,
17543            state_dir: PathBuf::from("."),
17544            store_selected: false,
17545        }
17546    }
17547
17548    #[test]
17549    fn request_retries_a_connection_failure_before_sending() {
17550        use std::io::{Read as _, Write as _};
17551        use std::net::TcpListener;
17552        use std::thread;
17553        use std::time::Duration;
17554
17555        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17556        let address = probe.local_addr().unwrap();
17557        drop(probe);
17558        let server = thread::spawn(move || {
17559            thread::sleep(Duration::from_millis(40));
17560            let listener = TcpListener::bind(address).unwrap();
17561            let (mut stream, _) = listener.accept().unwrap();
17562            let mut request_bytes = [0_u8; 1024];
17563            let _ = stream.read(&mut request_bytes).unwrap();
17564            stream
17565                .write_all(
17566                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17567                )
17568                .unwrap();
17569        });
17570        let cfg = HubConfig {
17571            hub: format!("http://{address}"),
17572            key: None,
17573            agent_key: None,
17574            brain_key: None,
17575            state_dir: tempfile::tempdir().unwrap().keep(),
17576            store_selected: false,
17577        };
17578
17579        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17580        assert_eq!(response.status, 200);
17581        assert_eq!(response.body, Some(json!({ "ok": true })));
17582        server.join().unwrap();
17583    }
17584
17585    #[test]
17586    fn a_commit_goes_back_for_a_receipt_it_lost() {
17587        use std::io::{Read as _, Write as _};
17588        use std::net::TcpListener;
17589        use std::thread;
17590
17591        // The mutation coordinate is derived from the observed base and the
17592        // exact operation set so a repeat is the SAME effect. A commit that
17593        // lands durably and then loses its connection must therefore be asked
17594        // after, not reported as a failure — which is what happened to a
17595        // 32,338-file store that had already committed.
17596        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17597        let address = listener.local_addr().unwrap();
17598        let server = thread::spawn(move || {
17599            // First attempt: promise a body, hang up mid-way.
17600            let (mut first, _) = listener.accept().unwrap();
17601            let mut bytes = [0_u8; 4096];
17602            let _ = first.read(&mut bytes).unwrap();
17603            first
17604                .write_all(
17605                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17606                )
17607                .unwrap();
17608            drop(first);
17609            // Second attempt: the receipt for the commit that already landed.
17610            let (mut second, _) = listener.accept().unwrap();
17611            let _ = second.read(&mut bytes).unwrap();
17612            second
17613                .write_all(
17614                    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\"}",
17615                )
17616                .unwrap();
17617        });
17618        let cfg = HubConfig {
17619            hub: format!("http://{address}"),
17620            key: Some("k".to_string()),
17621            agent_key: None,
17622            brain_key: None,
17623            state_dir: tempfile::tempdir().unwrap().keep(),
17624            store_selected: false,
17625        };
17626
17627        let response = request_patient(
17628            &cfg,
17629            "POST",
17630            "/api/hub/brains/b/v2/commits",
17631            Some(&json!({ "mutation_id": "dbmd-1" })),
17632            Auth::Required,
17633        )
17634        .expect("the receipt is collected on the second ask");
17635        assert_eq!(response.status, 200);
17636        assert_eq!(
17637            response
17638                .body
17639                .as_ref()
17640                .and_then(|value| value.get("outcome"))
17641                .and_then(Value::as_str),
17642            Some("converged"),
17643            "an already-applied mutation answers with its receipt"
17644        );
17645        server.join().unwrap();
17646    }
17647
17648    #[test]
17649    fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
17650        use std::io::{Read as _, Write as _};
17651        use std::net::TcpListener;
17652        use std::thread;
17653
17654        // A connection that fails while the body is arriving is the same class
17655        // of problem as one that fails before it arrives at all. Typed as a
17656        // bare io error it matched no retry path and named no peer, and one
17657        // stalled body ended a whole migration reporting only "timed out
17658        // reading response".
17659        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17660        let address = listener.local_addr().unwrap();
17661        let server = thread::spawn(move || {
17662            let (mut stream, _) = listener.accept().unwrap();
17663            let mut request_bytes = [0_u8; 1024];
17664            let _ = stream.read(&mut request_bytes).unwrap();
17665            // Promise a hundred bytes, send ten, hang up.
17666            stream
17667                .write_all(
17668                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17669                )
17670                .unwrap();
17671        });
17672        let cfg = HubConfig {
17673            hub: format!("http://{address}"),
17674            key: None,
17675            agent_key: None,
17676            brain_key: None,
17677            state_dir: tempfile::tempdir().unwrap().keep(),
17678            store_selected: false,
17679        };
17680
17681        let error = request(&cfg, "POST", "/truncated", None, Auth::None)
17682            .expect_err("a truncated body must not read as success");
17683        match error {
17684            LinkError::Transport { hub, .. } => {
17685                assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17686            }
17687            other => panic!("expected a transport failure, got {other:?}"),
17688        }
17689        server.join().unwrap();
17690    }
17691
17692    #[test]
17693    fn a_safe_get_retries_when_its_body_dies_mid_stream() {
17694        use std::io::{Read as _, Write as _};
17695        use std::net::{TcpListener, TcpStream};
17696        use std::thread;
17697
17698        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17699        let address = listener.local_addr().unwrap();
17700        let server = thread::spawn(move || {
17701            let read_request = |stream: &mut TcpStream| {
17702                let mut request = Vec::new();
17703                let mut bytes = [0_u8; 1024];
17704                loop {
17705                    let read = stream.read(&mut bytes).unwrap();
17706                    if read == 0 {
17707                        break;
17708                    }
17709                    request.extend_from_slice(&bytes[..read]);
17710                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17711                    else {
17712                        continue;
17713                    };
17714                    let headers = String::from_utf8_lossy(&request[..header_end]);
17715                    let content_length = headers
17716                        .lines()
17717                        .find_map(|line| {
17718                            let (name, value) = line.split_once(':')?;
17719                            name.eq_ignore_ascii_case("content-length")
17720                                .then(|| value.trim().parse::<usize>().ok())
17721                                .flatten()
17722                        })
17723                        .unwrap_or(0);
17724                    if request.len() >= header_end + 4 + content_length {
17725                        break;
17726                    }
17727                }
17728            };
17729            let (mut first, _) = listener.accept().unwrap();
17730            read_request(&mut first);
17731            first
17732                .write_all(
17733                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17734                )
17735                .unwrap();
17736            drop(first);
17737
17738            let (mut second, _) = listener.accept().unwrap();
17739            read_request(&mut second);
17740            second
17741                .write_all(
17742                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17743                )
17744                .unwrap();
17745        });
17746        let cfg = HubConfig {
17747            hub: format!("http://{address}"),
17748            key: None,
17749            agent_key: None,
17750            brain_key: None,
17751            state_dir: tempfile::tempdir().unwrap().keep(),
17752            store_selected: false,
17753        };
17754
17755        let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
17756            .expect("a safe read retries the interrupted body");
17757        assert_eq!(response.status, 200);
17758        assert_eq!(response.body, Some(json!({ "ok": true })));
17759        server.join().unwrap();
17760    }
17761
17762    #[test]
17763    fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
17764        use std::io::{Read as _, Write as _};
17765        use std::net::{TcpListener, TcpStream};
17766        use std::thread;
17767
17768        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17769        let address = listener.local_addr().unwrap();
17770        let server = thread::spawn(move || {
17771            let read_request = |stream: &mut TcpStream| {
17772                let mut request = Vec::new();
17773                let mut bytes = [0_u8; 1024];
17774                loop {
17775                    let read = stream.read(&mut bytes).unwrap();
17776                    if read == 0 {
17777                        break;
17778                    }
17779                    request.extend_from_slice(&bytes[..read]);
17780                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17781                    else {
17782                        continue;
17783                    };
17784                    let headers = String::from_utf8_lossy(&request[..header_end]);
17785                    let content_length = headers
17786                        .lines()
17787                        .find_map(|line| {
17788                            let (name, value) = line.split_once(':')?;
17789                            name.eq_ignore_ascii_case("content-length")
17790                                .then(|| value.trim().parse::<usize>().ok())
17791                                .flatten()
17792                        })
17793                        .unwrap_or(0);
17794                    if request.len() >= header_end + 4 + content_length {
17795                        break;
17796                    }
17797                }
17798            };
17799            let (mut first, _) = listener.accept().unwrap();
17800            read_request(&mut first);
17801            first
17802                .write_all(
17803                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17804                )
17805                .unwrap();
17806            drop(first);
17807
17808            let (mut second, _) = listener.accept().unwrap();
17809            read_request(&mut second);
17810            second
17811                .write_all(
17812                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17813                )
17814                .unwrap();
17815        });
17816        let cfg = HubConfig {
17817            hub: format!("http://{address}"),
17818            key: None,
17819            agent_key: None,
17820            brain_key: None,
17821            state_dir: tempfile::tempdir().unwrap().keep(),
17822            store_selected: false,
17823        };
17824
17825        let response = request_raw_retryable_read(
17826            &cfg,
17827            "POST",
17828            "/v2/stream",
17829            Some(&json!({ "files": ["proof"] })),
17830            Auth::None,
17831            1_024,
17832        )
17833        .expect("an explicitly safe POST retries the interrupted body");
17834        assert_eq!(response.status, 200);
17835        assert_eq!(
17836            serde_json::from_slice::<Value>(&response.body).unwrap(),
17837            json!({ "ok": true })
17838        );
17839        server.join().unwrap();
17840    }
17841
17842    #[test]
17843    fn object_store_transport_errors_never_render_presigned_urls() {
17844        use std::net::TcpListener;
17845
17846        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17847        let address = listener.local_addr().unwrap();
17848        drop(listener);
17849        let signature = "do-not-render-this-presigned-signature";
17850        let raw =
17851            format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
17852        let error = ureq::get(&raw)
17853            .timeout(std::time::Duration::from_millis(250))
17854            .call()
17855            .expect_err("the closed local port must fail");
17856        let ureq::Error::Transport(transport) = error else {
17857            panic!("expected a transport failure");
17858        };
17859
17860        let rendered = object_store_transport_error(transport).to_string();
17861        assert!(rendered.contains("the object store"));
17862        assert!(rendered.contains("network error"));
17863        assert!(!rendered.contains(&raw));
17864        assert!(!rendered.contains(signature));
17865        assert!(!rendered.contains("X-Amz-"));
17866    }
17867
17868    #[test]
17869    fn endpoint_cap_refuses_a_body_before_json_parsing() {
17870        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
17871        let cfg = HubConfig {
17872            hub,
17873            key: None,
17874            agent_key: None,
17875            brain_key: None,
17876            state_dir: tempfile::tempdir().unwrap().keep(),
17877            store_selected: false,
17878        };
17879
17880        assert!(matches!(
17881            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
17882            Err(LinkError::ResponseTooLarge { .. })
17883        ));
17884        server.join().unwrap();
17885    }
17886
17887    #[test]
17888    fn overall_deadline_stops_a_dribbled_response_body() {
17889        use std::io::{Read as _, Write as _};
17890        use std::net::TcpListener;
17891        use std::time::{Duration, Instant};
17892
17893        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17894        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
17895        let server = std::thread::spawn(move || {
17896            let (mut stream, _) = listener.accept().unwrap();
17897            let mut request = [0_u8; 1024];
17898            let _ = stream.read(&mut request);
17899            stream
17900                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
17901                .unwrap();
17902            for byte in [b'x'; 32] {
17903                if stream.write_all(&[byte]).is_err() {
17904                    break;
17905                }
17906                std::thread::sleep(Duration::from_millis(40));
17907            }
17908        });
17909        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17910        let started = Instant::now();
17911        let response = http.get(&url).call().unwrap();
17912        let mut body = Vec::new();
17913        let error = response
17914            .into_reader()
17915            .read_to_end(&mut body)
17916            .expect_err("per-read progress must not reset the overall deadline");
17917        assert!(
17918            started.elapsed() < Duration::from_millis(700),
17919            "dribbled body exceeded the wall-clock budget: {error}"
17920        );
17921        server.join().unwrap();
17922    }
17923
17924    #[test]
17925    fn overall_deadline_stops_a_stalled_upload() {
17926        use std::net::TcpListener;
17927        use std::time::{Duration, Instant};
17928
17929        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17930        let url = format!("http://{}/upload", listener.local_addr().unwrap());
17931        let server = std::thread::spawn(move || {
17932            let (_stream, _) = listener.accept().unwrap();
17933            // Never consume the request body. Once the kernel send buffer fills,
17934            // the client must leave on its absolute write deadline.
17935            std::thread::sleep(Duration::from_millis(600));
17936        });
17937        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17938        let body = vec![0x5a; 32 * 1024 * 1024];
17939        let started = Instant::now();
17940        let error = http
17941            .put(&url)
17942            .send_bytes(&body)
17943            .expect_err("stalled request-body writes must time out");
17944        assert!(
17945            started.elapsed() < Duration::from_millis(700),
17946            "stalled upload exceeded the wall-clock budget: {error}"
17947        );
17948        server.join().unwrap();
17949    }
17950
17951    #[test]
17952    fn presigned_source_retries_share_one_upload_deadline() {
17953        use std::net::TcpListener;
17954        use std::time::{Duration, Instant};
17955
17956        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17957        let address = listener.local_addr().unwrap();
17958        let signature = "do-not-render-this-stalled-upload-signature";
17959        let url = format!(
17960            "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
17961        );
17962        let server = std::thread::spawn(move || {
17963            let (_stream, _) = listener.accept().unwrap();
17964            // Keep the socket open without consuming the request. The first
17965            // attempt must spend the complete shared budget; retries may not
17966            // each restart that budget.
17967            std::thread::sleep(Duration::from_millis(600));
17968        });
17969
17970        let directory = tempfile::tempdir().unwrap();
17971        std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17972        std::fs::create_dir(directory.path().join("records")).unwrap();
17973        let relative = "records/stalled.bin";
17974        let bytes = vec![0x5a; 32 * 1024 * 1024];
17975        std::fs::write(directory.path().join(relative), &bytes).unwrap();
17976        let store = Store::open_strict(directory.path()).unwrap();
17977        let cfg = HubConfig {
17978            hub: format!("http://{address}"),
17979            key: None,
17980            agent_key: None,
17981            brain_key: None,
17982            state_dir: tempfile::tempdir().unwrap().keep(),
17983            store_selected: false,
17984        };
17985        let source = V2UploadSource {
17986            path: relative.to_string(),
17987            bytes: bytes.len() as u64,
17988        };
17989
17990        let started = Instant::now();
17991        let error = put_presigned_source_with_budget(
17992            &cfg,
17993            &url,
17994            &json!({ "content-length": source.bytes.to_string() }),
17995            &store,
17996            &source,
17997            None,
17998            Duration::from_millis(150),
17999        )
18000        .expect_err("a black-holed upload must leave at its shared deadline");
18001        assert!(
18002            started.elapsed() < Duration::from_millis(700),
18003            "presigned retries exceeded their shared budget: {error}"
18004        );
18005        let rendered = error.to_string();
18006        assert!(rendered.contains("the object store"));
18007        assert!(!rendered.contains(&url));
18008        assert!(!rendered.contains(signature));
18009        server.join().unwrap();
18010    }
18011
18012    #[test]
18013    fn verb_entry_gates_accept_the_hub_ref_shapes() {
18014        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
18015            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
18016            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
18017            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
18018        }
18019    }
18020
18021    #[test]
18022    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
18023        let cfg = dead_hub();
18024        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
18025            assert!(
18026                matches!(
18027                    sync_pull(&cfg, bad, None),
18028                    Err(LinkError::BadAddress { .. })
18029                ),
18030                "sync_pull must refuse {bad:?}"
18031            );
18032            assert!(
18033                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
18034                "sync_push must refuse {bad:?}"
18035            );
18036            assert!(
18037                matches!(
18038                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
18039                    Err(LinkError::BadAddress { .. })
18040                ),
18041                "grant_issue must refuse {bad:?}"
18042            );
18043            assert!(
18044                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
18045                "grant_list must refuse {bad:?}"
18046            );
18047            assert!(
18048                matches!(
18049                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
18050                    Err(LinkError::BadAddress { .. })
18051                ),
18052                "grant_revoke must refuse brain {bad:?}"
18053            );
18054            assert!(
18055                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
18056                "head must refuse {bad:?}"
18057            );
18058        }
18059    }
18060
18061    #[test]
18062    fn grant_revoke_refuses_url_reshaping_grant_ids() {
18063        let cfg = dead_hub();
18064        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
18065            assert!(
18066                matches!(
18067                    grant_revoke(&cfg, "acme", bad),
18068                    Err(LinkError::BadGrantId { .. })
18069                ),
18070                "grant_revoke must refuse grant id {bad:?}"
18071            );
18072        }
18073    }
18074
18075    #[test]
18076    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
18077        let cfg = dead_hub();
18078        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
18079            assert!(
18080                matches!(
18081                    propose(&cfg, bad, "intake", "hi"),
18082                    Err(LinkError::BadAddress { .. })
18083                ),
18084                "propose must refuse handle {bad:?}"
18085            );
18086        }
18087        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
18088        assert!(matches!(
18089            propose(&cfg, "acme-site", "intake", &oversize),
18090            Err(LinkError::ProposeTooLarge { .. })
18091        ));
18092        // A clean handle + in-cap body passes both gates: the failure is now
18093        // the (dead) wire, proving the gates refuse shape, not the verb.
18094        assert!(matches!(
18095            propose(&cfg, "acme-site", "intake", "hi"),
18096            Err(LinkError::Transport { .. })
18097        ));
18098    }
18099
18100    #[test]
18101    fn resolve_refuses_a_hand_built_unsafe_address() {
18102        let cfg = dead_hub();
18103        for brain in ["../up", "a/b", "a?x", "a#f"] {
18104            let addr = Address {
18105                brain: brain.to_string(),
18106                target: None,
18107            };
18108            assert!(
18109                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18110                "resolve must refuse brain {brain:?}"
18111            );
18112        }
18113        for target in [
18114            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
18115            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
18116            AddressTarget::Path("../up.md".to_string()),
18117            AddressTarget::Path("records/x.md#frag".to_string()),
18118        ] {
18119            let addr = Address {
18120                brain: "acme".to_string(),
18121                target: Some(target.clone()),
18122            };
18123            assert!(
18124                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18125                "resolve must refuse target {target:?}"
18126            );
18127        }
18128    }
18129
18130    #[test]
18131    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
18132        let mut local = std::collections::BTreeMap::new();
18133        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
18134        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
18135        let mut remote = std::collections::BTreeMap::new();
18136        remote.insert(
18137            "records/a.md".to_string(),
18138            V2BaselineFile {
18139                sha256: "c".repeat(64),
18140                bytes: 1,
18141                proof: None,
18142            },
18143        );
18144        remote.insert(
18145            "records/b.md".to_string(),
18146            V2BaselineFile {
18147                sha256: "b".repeat(64),
18148                bytes: 1,
18149                proof: None,
18150            },
18151        );
18152        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18153    }
18154
18155    #[test]
18156    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
18157        let local = std::collections::BTreeMap::new();
18158        let mut remote = std::collections::BTreeMap::new();
18159        remote.insert(
18160            "private/local.md".to_string(),
18161            V2BaselineFile {
18162                sha256: "d".repeat(64),
18163                bytes: 1,
18164                proof: None,
18165            },
18166        );
18167        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
18168        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18169    }
18170
18171    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
18172        V2VerifiedHead {
18173            requested: TEST_BRAIN_ID.to_string(),
18174            brain_id: TEST_BRAIN_ID.to_string(),
18175            view_kind: "scoped".to_string(),
18176            view_revision: revision.to_string(),
18177            control_revision: revision.to_string(),
18178            identity: V2HeadIdentity {
18179                custody: "hub".to_string(),
18180                fingerprint: "test".to_string(),
18181                public_key_spki: "test".to_string(),
18182                previous: Vec::new(),
18183                rotations: Vec::new(),
18184            },
18185            pointer: None,
18186            trust: TrustState {
18187                v: 2,
18188                origin: "https://hub.example".to_string(),
18189                requested: TEST_BRAIN_ID.to_string(),
18190                brain: TEST_BRAIN_ID.to_string(),
18191                home: None,
18192                anchor: "ed25519:test".to_string(),
18193                current: "ed25519:test".to_string(),
18194                head_seq: 0,
18195                feed_hash: None,
18196                rotations: Vec::new(),
18197                hub_signer: None,
18198                protocol_profile: Some("link-v2".to_string()),
18199            },
18200            alias: None,
18201        }
18202    }
18203
18204    #[test]
18205    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
18206        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
18207        assert!(accepted_as_v2(&trust));
18208
18209        trust.protocol_profile = None;
18210        trust.hub_signer = Some("ed25519:hub".to_string());
18211        assert!(accepted_as_v2(&trust));
18212
18213        trust.hub_signer = None;
18214        assert!(!accepted_as_v2(&trust));
18215    }
18216
18217    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
18218        V2SyncBaseline {
18219            v: 2,
18220            origin: "https://hub.example".to_string(),
18221            brain: TEST_BRAIN_ID.to_string(),
18222            checkout_id: Some("c".repeat(64)),
18223            head_seq: Some(0),
18224            commit_hash: None,
18225            content_root: None,
18226            asset_root: None,
18227            assets: std::collections::BTreeMap::new(),
18228            view_kind: Some("scoped".to_string()),
18229            view_revision: Some(revision.to_string()),
18230            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
18231            files: std::collections::BTreeMap::new(),
18232            local_policy_digest: None,
18233            local_eligibility: std::collections::BTreeMap::new(),
18234            remote_copy_remains: std::collections::BTreeMap::new(),
18235        }
18236    }
18237
18238    #[test]
18239    fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
18240        let cfg = test_hub_config(
18241            "https://hub.example".to_string(),
18242            tempfile::tempdir().unwrap().keep(),
18243        );
18244        let mut baseline = scoped_test_baseline(&"a".repeat(64));
18245        baseline.assets.insert(
18246            "assets/archive.bin".to_string(),
18247            V2BaselineAsset {
18248                blob_sha256: "b".repeat(64),
18249                bytes: MAX_STORE_BYTES + 1,
18250                media_type: "application/octet-stream".to_string(),
18251                wrappers: vec!["records/archive.md".to_string()],
18252                required: true,
18253                disposition: "hosted".to_string(),
18254                leaf_hash: "c".repeat(64),
18255            },
18256        );
18257
18258        let accepted = serde_json::to_vec(&baseline).unwrap();
18259        assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
18260
18261        baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
18262        let refused = serde_json::to_vec(&baseline).unwrap();
18263        assert!(matches!(
18264            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
18265            Err(LinkError::InvalidFeed { .. })
18266        ));
18267    }
18268
18269    #[test]
18270    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
18271        let directory = tempfile::tempdir().unwrap();
18272        std::fs::write(
18273            directory.path().join("DB.md"),
18274            scoped_projection_bytes(TEST_BRAIN_ID),
18275        )
18276        .unwrap();
18277        let store = Store::open_strict(directory.path()).unwrap();
18278        let head = scoped_test_head(&"a".repeat(64));
18279        let baseline = scoped_test_baseline(&"a".repeat(64));
18280        let mut view = v2_local_files(&store).unwrap();
18281        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
18282        assert!(!view.riding.contains_key("DB.md"));
18283        assert!(!view.eligibility.contains_key("DB.md"));
18284    }
18285
18286    #[test]
18287    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
18288        let directory = tempfile::tempdir().unwrap();
18289        std::fs::write(
18290            directory.path().join("DB.md"),
18291            scoped_projection_bytes(TEST_BRAIN_ID),
18292        )
18293        .unwrap();
18294        let store = Store::open_strict(directory.path()).unwrap();
18295        let head = scoped_test_head(&"a".repeat(64));
18296        let baseline = scoped_test_baseline(&"a".repeat(64));
18297
18298        let mut carried = v2_local_files(&store).unwrap();
18299        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
18300        let handed_off =
18301            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
18302        assert!(!handed_off.riding.contains_key("DB.md"));
18303
18304        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
18305        assert!(!freshly_scanned.riding.contains_key("DB.md"));
18306
18307        std::fs::write(
18308            directory.path().join("DB.md"),
18309            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18310        )
18311        .unwrap();
18312        let tampered = Store::open_strict(directory.path()).unwrap();
18313        assert!(matches!(
18314            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
18315            Err(LinkError::ScopedProjectionModified)
18316        ));
18317    }
18318
18319    #[test]
18320    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
18321        let directory = tempfile::tempdir().unwrap();
18322        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18323        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18324        std::fs::write(
18325            directory.path().join("DB.md"),
18326            b"---\nname: Kept home test\n---\n",
18327        )
18328        .unwrap();
18329        std::fs::write(
18330            directory.path().join("records/notes/a.md"),
18331            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
18332        )
18333        .unwrap();
18334        std::fs::write(
18335            directory.path().join("sources/private/secret.md"),
18336            b"---\ntype: note\n---\nlocal only\n",
18337        )
18338        .unwrap();
18339        std::fs::write(
18340            directory.path().join("sources/private/unlinked.md"),
18341            b"---\ntype: note\n---\nnot disclosed\n",
18342        )
18343        .unwrap();
18344        std::fs::write(
18345            directory.path().join(".sevralocal"),
18346            b"sources/private/**\n",
18347        )
18348        .unwrap();
18349
18350        let store = Store::open_strict(directory.path()).unwrap();
18351        let view = v2_local_files(&store).unwrap();
18352        assert!(!view.riding.contains_key("sources/private/secret.md"));
18353        assert_eq!(
18354            view.withheld_links,
18355            vec![V2WithheldLink {
18356                source: "records/notes/a.md".to_string(),
18357                target: "sources/private/secret.md".to_string(),
18358            }]
18359        );
18360    }
18361
18362    #[test]
18363    fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
18364        // An export omits kept-home content by construction, so a store
18365        // restored from one links to names it does not carry. Without this the
18366        // owner's own export could never be pushed back — the links would read
18367        // as broken and the commit would be refused.
18368        let directory = tempfile::tempdir().unwrap();
18369        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18370        std::fs::write(
18371            directory.path().join("DB.md"),
18372            b"---\nname: Restored export\n---\n",
18373        )
18374        .unwrap();
18375        std::fs::write(
18376            directory.path().join("records/notes/a.md"),
18377            b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
18378        )
18379        .unwrap();
18380        std::fs::write(
18381            directory.path().join(".sevralocal"),
18382            b"sources/private/**\n",
18383        )
18384        .unwrap();
18385
18386        let store = Store::open_strict(directory.path()).unwrap();
18387        let view = v2_local_files(&store).unwrap();
18388        assert_eq!(
18389            view.withheld_links,
18390            vec![V2WithheldLink {
18391                source: "records/notes/a.md".to_string(),
18392                target: "sources/private/absent.md".to_string(),
18393            }]
18394        );
18395        // A dangling link the owner never claimed is still dangling.
18396        std::fs::write(
18397            directory.path().join("records/notes/b.md"),
18398            b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
18399        )
18400        .unwrap();
18401        let store = Store::open_strict(directory.path()).unwrap();
18402        let view = v2_local_files(&store).unwrap();
18403        assert!(
18404            !view
18405                .withheld_links
18406                .iter()
18407                .any(|link| link.target == "records/notes/nowhere.md"),
18408            "an unclaimed dangling target must not be declared withheld"
18409        );
18410    }
18411
18412    #[test]
18413    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
18414        let directory = tempfile::tempdir().unwrap();
18415        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18416        std::fs::write(
18417            directory.path().join("DB.md"),
18418            b"---\nname: Withdrawal test\n---\n",
18419        )
18420        .unwrap();
18421        let source = b"---\ntype: note\n---\nlocal evidence\n";
18422        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
18423        std::fs::write(
18424            directory.path().join(".sevralocal"),
18425            b"sources/private/**\n",
18426        )
18427        .unwrap();
18428        let store = Store::open_strict(directory.path()).unwrap();
18429        let view = v2_local_files(&store).unwrap();
18430        let mut remote = std::collections::BTreeMap::new();
18431        remote.insert(
18432            "sources/private/evidence.md".to_string(),
18433            V2BaselineFile {
18434                sha256: content_sha256(source),
18435                bytes: source.len() as u64,
18436                proof: None,
18437            },
18438        );
18439        assert_eq!(
18440            v2_content_withdrawal_operation(
18441                &store,
18442                &view,
18443                &remote,
18444                "sources/private/evidence.md",
18445                "approved retention change",
18446            )
18447            .unwrap(),
18448            json!({
18449                "op": "withdraw_from_hosting",
18450                "path": "sources/private/evidence.md",
18451                "expected": { "kind": "blob", "hash": content_sha256(source) },
18452                "reason": "approved retention change",
18453            })
18454        );
18455
18456        std::fs::write(
18457            directory.path().join("sources/private/evidence.md"),
18458            b"changed after review",
18459        )
18460        .unwrap();
18461        assert!(matches!(
18462            v2_content_withdrawal_operation(
18463                &store,
18464                &view,
18465                &remote,
18466                "sources/private/evidence.md",
18467                "approved retention change",
18468            ),
18469            Err(LinkError::InvalidPack { .. })
18470        ));
18471    }
18472
18473    #[test]
18474    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
18475        let directory = tempfile::tempdir().unwrap();
18476        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
18477        std::fs::write(
18478            directory.path().join("DB.md"),
18479            b"---\nname: Asset withdrawal test\n---\n",
18480        )
18481        .unwrap();
18482        let bytes = b"private binary";
18483        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
18484        std::fs::write(
18485            directory.path().join(".sevralocal"),
18486            b"sources/files/private.pdf\n",
18487        )
18488        .unwrap();
18489        let store = Store::open_strict(directory.path()).unwrap();
18490        let view = v2_local_files(&store).unwrap();
18491        let local = crate::AssetRecord {
18492            path: "sources/files/private.pdf".to_string(),
18493            sha256: content_sha256(bytes),
18494            bytes: bytes.len() as u64,
18495            media_type: "application/pdf".to_string(),
18496            wrappers: vec!["sources/files/private.md".to_string()],
18497            required: true,
18498        };
18499        let current = V2BaselineAsset {
18500            blob_sha256: local.sha256.clone(),
18501            bytes: local.bytes,
18502            media_type: local.media_type.clone(),
18503            wrappers: local.wrappers.clone(),
18504            required: local.required,
18505            disposition: "hosted".to_string(),
18506            leaf_hash: "d".repeat(64),
18507        };
18508        assert_eq!(
18509            v2_asset_withdrawal_operation(
18510                &store,
18511                &view,
18512                &local.path,
18513                &local,
18514                &current,
18515                "approved retention change",
18516            )
18517            .unwrap(),
18518            json!({
18519                "op": "asset_withdraw",
18520                "path": local.path,
18521                "expected": { "kind": "asset", "hash": "d".repeat(64) },
18522                "reason": "approved retention change",
18523            })
18524        );
18525
18526        let mut mismatched = current.clone();
18527        mismatched.required = false;
18528        assert!(matches!(
18529            v2_asset_withdrawal_operation(
18530                &store,
18531                &view,
18532                &local.path,
18533                &local,
18534                &mismatched,
18535                "approved retention change",
18536            ),
18537            Err(LinkError::InvalidPack { .. })
18538        ));
18539    }
18540
18541    #[test]
18542    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
18543        let first = v2_checkout_id(None).unwrap();
18544        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
18545        assert_ne!(first, v2_checkout_id(None).unwrap());
18546        assert!(is_sha256(&first));
18547    }
18548
18549    #[test]
18550    fn scoped_projection_edit_and_scope_transition_fail_closed() {
18551        let directory = tempfile::tempdir().unwrap();
18552        std::fs::write(
18553            directory.path().join("DB.md"),
18554            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
18555        )
18556        .unwrap();
18557        let store = Store::open_strict(directory.path()).unwrap();
18558        let head = scoped_test_head(&"a".repeat(64));
18559        let baseline = scoped_test_baseline(&"a".repeat(64));
18560        let mut view = v2_local_files(&store).unwrap();
18561        assert!(matches!(
18562            remove_scoped_projection(&head, Some(&baseline), &mut view),
18563            Err(LinkError::ScopedProjectionModified)
18564        ));
18565
18566        let changed = scoped_test_head(&"b".repeat(64));
18567        assert!(matches!(
18568            ensure_v2_view_compatible(&changed, Some(&baseline)),
18569            Err(LinkError::ScopedViewChanged)
18570        ));
18571
18572        let mut same_view_new_control = head.clone();
18573        same_view_new_control.control_revision = "c".repeat(64);
18574        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
18575        assert!(!same_v2_head(&head, &same_view_new_control));
18576    }
18577
18578    #[test]
18579    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
18580        let scoped = scoped_test_head(&"a".repeat(64));
18581        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
18582        assert!(matches!(
18583            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18584            Err(LinkError::ScopedProjectionModified)
18585        ));
18586
18587        let mut full = scoped.clone();
18588        full.view_kind = "full".to_string();
18589        let mut full_baseline = scoped_baseline.clone();
18590        full_baseline.view_kind = Some("full".to_string());
18591        full_baseline.projection_sha256 = None;
18592        assert!(matches!(
18593            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18594            Err(LinkError::InvalidPack { .. })
18595        ));
18596
18597        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18598        assert!(
18599            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18600        );
18601    }
18602
18603    #[test]
18604    fn scoped_view_metadata_is_explicitly_non_authoritative() {
18605        let head = scoped_test_head(&"a".repeat(64));
18606        let value: Value =
18607            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18608        assert_eq!(value["kind"], "link.md-scoped-view");
18609        assert_eq!(value["authoritative"], false);
18610        assert_eq!(value["visible_files"], 7);
18611        assert_eq!(value["brain"], TEST_BRAIN_ID);
18612    }
18613
18614    #[test]
18615    fn local_scoped_marker_requires_the_exact_generated_projection() {
18616        let directory = tempfile::tempdir().unwrap();
18617        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18618        std::fs::write(
18619            directory.path().join("DB.md"),
18620            scoped_projection_bytes(TEST_BRAIN_ID),
18621        )
18622        .unwrap();
18623        let head = scoped_test_head(&"a".repeat(64));
18624        std::fs::write(
18625            directory.path().join(".dbmd/view.json"),
18626            scoped_view_metadata(&head, 0).unwrap(),
18627        )
18628        .unwrap();
18629        let store = Store::open_strict(directory.path()).unwrap();
18630        assert!(has_verified_local_scoped_view(&store));
18631
18632        std::fs::write(
18633            directory.path().join("DB.md"),
18634            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18635        )
18636        .unwrap();
18637        let altered = Store::open_strict(directory.path()).unwrap();
18638        assert!(!has_verified_local_scoped_view(&altered));
18639    }
18640
18641    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18642        use ring::signature::KeyPair as _;
18643
18644        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18645        let rng = ring::rand::SystemRandom::new();
18646        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18647        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18648        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18649        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18650        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18651        let blob = b"new";
18652        let blob_hash = content_sha256(blob);
18653        let changes = json!({
18654            "mutation_id": "sync:proposal-fixture",
18655            "operations": [{
18656                "blob": blob_hash,
18657                "bytes": blob.len(),
18658                "expected": null,
18659                "op": "put",
18660                "path": "records/new.md",
18661            }],
18662            "reason": "fixture",
18663            "v": 2,
18664        });
18665        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18666        let changes_base64 = STANDARD.encode(&changes_bytes);
18667        let descriptor = json!({
18668            "base": null,
18669            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18670            "changes_base64": changes_base64,
18671            "rebase": "strict",
18672            "v": 2,
18673        });
18674        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18675        let payload_hash = "b".repeat(64);
18676        let submitted_at = "2026-08-19T12:00:00.000Z";
18677        let claim = json!({
18678            "actor_root": {
18679                "actor_class": "foreign_key",
18680                "credential": "ed25519:fixture",
18681                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18682                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18683                "principal": "key:fixture",
18684                "role": null,
18685            },
18686            "brain": TEST_BRAIN_ID,
18687            "clear_sha256": clear_hash,
18688            "control_revision": "c".repeat(64),
18689            "mutation_id": "sync:proposal-fixture",
18690            "payload_sha256": payload_hash,
18691            "proposal_id": proposal_id,
18692            "submitted_at": submitted_at,
18693            "v": 2,
18694        });
18695        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
18696        let envelope = json!({
18697            "claim": claim,
18698            "fingerprint": fingerprint,
18699            "public_key": public_key,
18700            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
18701        });
18702        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18703        let submission_hash =
18704            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
18705        let mut head = scoped_test_head(&"c".repeat(64));
18706        head.view_kind = "full".to_string();
18707        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
18708        let value = json!({
18709            "proposal": {
18710                "base": null,
18711                "blobs": [{
18712                    "bytes": blob.len(),
18713                    "endpoint": format!(
18714                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
18715                    ),
18716                    "sha256": blob_hash,
18717                }],
18718                "changes_base64": changes_base64,
18719                "clear_sha256": clear_hash,
18720                "expires_at": "2026-08-26T12:00:00.000Z",
18721                "id": proposal_id,
18722                "payload_sha256": payload_hash,
18723                "proposer": { "class": "foreign_key" },
18724                "rebase": "strict",
18725                "state": "pending",
18726                "submission_claim_base64": STANDARD.encode(envelope_bytes),
18727                "submission_claim_sha256": submission_hash,
18728                "submitted_at": submitted_at,
18729            },
18730            "v": 2,
18731        });
18732        (head, proposal_id, value)
18733    }
18734
18735    #[test]
18736    fn v2_proposal_verifier_accepts_exact_signed_payload() {
18737        let (head, proposal_id, value) = signed_proposal_fixture();
18738        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18739        assert_eq!(verified.blobs.len(), 1);
18740        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18741    }
18742
18743    #[test]
18744    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18745        let (head, proposal_id, value) = signed_proposal_fixture();
18746
18747        let mut changed = value.clone();
18748        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18749        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18750
18751        let mut redirected = value.clone();
18752        redirected["proposal"]["blobs"][0]["endpoint"] =
18753            Value::String("https://attacker.example/blob".to_string());
18754        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18755
18756        let mut forged = value;
18757        let encoded = forged["proposal"]["submission_claim_base64"]
18758            .as_str()
18759            .unwrap();
18760        let mut envelope: Value =
18761            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18762        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18763        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18764        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18765        forged["proposal"]["submission_claim_sha256"] = Value::String(
18766            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18767        );
18768        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18769    }
18770
18771    #[cfg(unix)]
18772    #[test]
18773    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18774        let sandbox = tempfile::tempdir().unwrap();
18775        let destination = sandbox.path().join("brain");
18776        let entries = vec![
18777            (
18778                "DB.md".to_string(),
18779                scoped_projection_bytes(TEST_BRAIN_ID),
18780            ),
18781            (
18782                "records/contacts/a.md".to_string(),
18783                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18784                    .to_vec(),
18785            ),
18786        ];
18787        install_pulled_delta(&destination, &entries, &[], true).unwrap();
18788        assert!(destination.join("index.md").is_file());
18789        assert!(destination.join("records/index.md").is_file());
18790        assert!(destination.join("records/contacts/index.md").is_file());
18791        assert!(destination.join("records/contacts/index.jsonl").is_file());
18792    }
18793
18794    #[cfg(unix)]
18795    #[test]
18796    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
18797        let sandbox = tempfile::tempdir().unwrap();
18798        let destination = sandbox.path().join("brain");
18799        let cache = sandbox.path().join("cache");
18800        std::fs::create_dir(&cache).unwrap();
18801        let db = scoped_projection_bytes(TEST_BRAIN_ID);
18802        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
18803        let db_source = cache.join("db");
18804        let shared_source = cache.join("shared");
18805        crate::fsx::write_atomic(&db_source, &db).unwrap();
18806        crate::fsx::write_atomic(&shared_source, shared).unwrap();
18807        let mut entries = vec![V2StagedFile {
18808            path: "DB.md".to_string(),
18809            source: db_source,
18810            sha256: content_sha256(&db),
18811            bytes: db.len() as u64,
18812        }];
18813        for index in 0..512 {
18814            entries.push(V2StagedFile {
18815                path: format!("records/items/{index:05}.md"),
18816                source: shared_source.clone(),
18817                sha256: content_sha256(shared),
18818                bytes: shared.len() as u64,
18819            });
18820        }
18821        install_pulled_delta_sources(
18822            &destination,
18823            &entries,
18824            &[],
18825            false,
18826            None,
18827            &scoped_test_head(&"c".repeat(64)),
18828        )
18829        .unwrap();
18830        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
18831        for index in 0..512 {
18832            assert_eq!(
18833                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
18834                shared
18835            );
18836        }
18837        assert!(
18838            std::fs::read_dir(sandbox.path())
18839                .unwrap()
18840                .all(|entry| !entry
18841                    .unwrap()
18842                    .file_name()
18843                    .to_string_lossy()
18844                    .contains("pull-stage")),
18845            "the private stage must be atomically installed or removed"
18846        );
18847    }
18848
18849    #[cfg(unix)]
18850    #[test]
18851    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
18852        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
18853
18854        let sandbox = tempfile::tempdir().unwrap();
18855        let root = sandbox.path().join("brain");
18856        std::fs::create_dir_all(root.join("records/items")).unwrap();
18857        let db = scoped_projection_bytes(TEST_BRAIN_ID);
18858        let old = b"---\ntype: note\n---\n\nold\n";
18859        let new = b"---\ntype: note\n---\n\nnew\n";
18860        let removed = b"---\ntype: note\n---\n\nremove me\n";
18861        std::fs::write(root.join("DB.md"), &db).unwrap();
18862        std::fs::write(root.join("records/items/change.md"), old).unwrap();
18863        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
18864        for index in 0..512 {
18865            std::fs::write(
18866                root.join(format!("records/items/untouched-{index:04}.md")),
18867                old,
18868            )
18869            .unwrap();
18870        }
18871        let untouched = root.join("records/items/untouched-0256.md");
18872        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
18873        let source = sandbox.path().join("changed-source");
18874        crate::fsx::write_atomic(&source, new).unwrap();
18875        let same_source = sandbox.path().join("unchanged-source");
18876        crate::fsx::write_atomic(&same_source, old).unwrap();
18877        let same_entry = V2StagedFile {
18878            path: "records/items/change.md".to_string(),
18879            source: same_source,
18880            sha256: content_sha256(old),
18881            bytes: old.len() as u64,
18882        };
18883        let entry = V2StagedFile {
18884            path: "records/items/change.md".to_string(),
18885            source,
18886            sha256: content_sha256(new),
18887            bytes: new.len() as u64,
18888        };
18889        let head = scoped_test_head(&"c".repeat(64));
18890
18891        // A no-op established pull neither clones the checkout nor arms a
18892        // journal. Inode identity is a direct regression guard against the
18893        // former brain-wide private-copy implementation.
18894        install_established_v2_delta(
18895            Store::open_strict(&root).unwrap(),
18896            &[same_entry],
18897            &["records/items/already-absent.md".to_string()],
18898            true,
18899            None,
18900            &head,
18901        )
18902        .unwrap();
18903        assert_eq!(
18904            std::fs::metadata(&untouched).unwrap().ino(),
18905            untouched_inode
18906        );
18907        assert!(!root.join(V2_PULL_JOURNAL).exists());
18908
18909        install_established_v2_delta(
18910            Store::open_strict(&root).unwrap(),
18911            &[entry],
18912            &["records/items/delete.md".to_string()],
18913            false,
18914            None,
18915            &head,
18916        )
18917        .unwrap();
18918        assert_eq!(
18919            std::fs::read(root.join("records/items/change.md")).unwrap(),
18920            new
18921        );
18922        assert!(!root.join("records/items/delete.md").exists());
18923        assert_eq!(
18924            std::fs::metadata(&untouched).unwrap().ino(),
18925            untouched_inode
18926        );
18927        assert!(root.join(V2_PULL_JOURNAL).is_file());
18928        assert_eq!(
18929            std::fs::metadata(root.join(V2_PULL_JOURNAL))
18930                .unwrap()
18931                .permissions()
18932                .mode()
18933                & 0o777,
18934            0o600
18935        );
18936        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
18937            .unwrap()
18938            .unwrap();
18939        assert_eq!(
18940            std::fs::metadata(root.join(&journal.backup_dir))
18941                .unwrap()
18942                .permissions()
18943                .mode()
18944                & 0o777,
18945            0o700
18946        );
18947        for entry in &journal.entries {
18948            if let Some(backup) = &entry.backup {
18949                assert_eq!(
18950                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
18951                        .unwrap()
18952                        .permissions()
18953                        .mode()
18954                        & 0o777,
18955                    0o600
18956                );
18957            }
18958        }
18959
18960        let cfg = test_hub_config(
18961            "https://example.test".to_string(),
18962            sandbox.path().join("state"),
18963        );
18964        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18965        assert_eq!(
18966            std::fs::read(root.join("records/items/change.md")).unwrap(),
18967            old
18968        );
18969        assert_eq!(
18970            std::fs::read(root.join("records/items/delete.md")).unwrap(),
18971            removed
18972        );
18973        assert_eq!(
18974            std::fs::metadata(&untouched).unwrap().ino(),
18975            untouched_inode
18976        );
18977        assert!(!root.join(V2_PULL_JOURNAL).exists());
18978    }
18979
18980    #[test]
18981    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
18982        let body = b"bounded bytes";
18983        let path = "records/example.md".to_string();
18984        let file = V2BaselineFile {
18985            sha256: content_sha256(body),
18986            bytes: body.len() as u64,
18987            proof: None,
18988        };
18989        let header = serde_json::to_vec(&json!({
18990            "bytes": body.len(),
18991            "path": path,
18992            "sha256": file.sha256,
18993            "v": 2,
18994        }))
18995        .unwrap();
18996        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
18997        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
18998        stream.extend_from_slice(&header);
18999        stream.extend_from_slice(body);
19000        stream.extend_from_slice(&0_u32.to_be_bytes());
19001        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
19002        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
19003
19004        let mut tampered = stream.clone();
19005        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
19006        tampered[body_offset] ^= 1;
19007        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
19008
19009        let mut trailing = stream;
19010        trailing.push(0);
19011        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
19012    }
19013
19014    #[test]
19015    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
19016        let sandbox = tempfile::TempDir::new().unwrap();
19017        let root = sandbox.path().join("brain");
19018        std::fs::create_dir_all(&root).unwrap();
19019        std::fs::write(
19020            root.join("DB.md"),
19021            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19022        )
19023        .unwrap();
19024        let store = Store::open_strict(&root).unwrap();
19025        let incomplete = crate::ulid::mint();
19026        store
19027            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
19028            .unwrap();
19029        let expired = crate::ulid::mint();
19030        store
19031            .create_dir_all(&v2_conflict_relative(&expired, "files"))
19032            .unwrap();
19033        let plan = V2ConflictPlan {
19034            v: 2,
19035            class: "content_resolution_required".to_string(),
19036            bundle: expired.clone(),
19037            brain: TEST_BRAIN_ID.to_string(),
19038            origin: "https://example.test".to_string(),
19039            created_unix: 0,
19040            expires_unix: 0,
19041            base_seq: None,
19042            base_commit: None,
19043            remote_seq: 0,
19044            remote_commit: None,
19045            remote_content_root: None,
19046            view_kind: "full".to_string(),
19047            view_revision: "a".repeat(64),
19048            files: vec![V2ConflictFile {
19049                path: "records/value.md".to_string(),
19050                base: V2ConflictCoordinate {
19051                    sha256: None,
19052                    bytes: None,
19053                    file: None,
19054                },
19055                local: V2ConflictCoordinate {
19056                    sha256: None,
19057                    bytes: None,
19058                    file: None,
19059                },
19060                remote: V2ConflictCoordinate {
19061                    sha256: None,
19062                    bytes: None,
19063                    file: None,
19064                },
19065            }],
19066        };
19067        let mut bytes = serde_json::to_vec(&plan).unwrap();
19068        bytes.push(b'\n');
19069        store
19070            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
19071            .unwrap();
19072
19073        let listed = sync_conflicts(&root, false, false).unwrap();
19074        assert_eq!(listed["bundles"], 2);
19075        assert_eq!(listed["pruned"], 0);
19076        let pruned = sync_conflicts(&root, true, false).unwrap();
19077        assert_eq!(pruned["bundles"], 0);
19078        assert_eq!(pruned["pruned"], 2);
19079        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
19080        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
19081    }
19082
19083    #[test]
19084    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
19085        let sandbox = tempfile::TempDir::new().unwrap();
19086        let root = sandbox.path().join("brain");
19087        std::fs::create_dir_all(&root).unwrap();
19088        std::fs::write(
19089            root.join("DB.md"),
19090            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19091        )
19092        .unwrap();
19093        let store = Store::open_strict(&root).unwrap();
19094        let bundle = crate::ulid::mint();
19095        store
19096            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
19097            .unwrap();
19098        store
19099            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
19100            .unwrap();
19101
19102        assert!(sync_conflicts(&root, true, false).is_err());
19103        assert!(sync_conflicts(&root, false, true).is_err());
19104        let pruned = sync_conflicts(&root, true, true).unwrap();
19105        assert_eq!(pruned["pruned"], 1);
19106        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
19107    }
19108
19109    #[test]
19110    fn ready_pull_journal_rolls_back_exact_preimages() {
19111        let sandbox = tempfile::TempDir::new().unwrap();
19112        let root = sandbox.path().join("brain");
19113        std::fs::create_dir_all(root.join("records")).unwrap();
19114        std::fs::write(
19115            root.join("DB.md"),
19116            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19117        )
19118        .unwrap();
19119        let path = "records/value.md";
19120        let old = b"---\ntype: note\n---\n\nold\n";
19121        let new = b"---\ntype: note\n---\n\nnew\n";
19122        std::fs::write(root.join(path), old).unwrap();
19123        let store = Store::open_strict(&root).unwrap();
19124        let bundle = crate::ulid::mint();
19125        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19126        store
19127            .create_private_dir_all(Path::new(&backup_dir))
19128            .unwrap();
19129        store
19130            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
19131            .unwrap();
19132        let journal = V2PullJournal {
19133            v: 1,
19134            phase: V2PullPhase::Ready,
19135            brain: TEST_BRAIN_ID.to_string(),
19136            previous: V2PullCoordinate {
19137                head_seq: None,
19138                commit_hash: None,
19139                view_kind: None,
19140                view_revision: None,
19141            },
19142            next: V2PullCoordinate {
19143                head_seq: Some(2),
19144                commit_hash: Some("c".repeat(64)),
19145                view_kind: Some("full".to_string()),
19146                view_revision: Some("d".repeat(64)),
19147            },
19148            backup_dir: backup_dir.clone(),
19149            entries: vec![V2PullJournalEntry {
19150                path: path.to_string(),
19151                old: Some(V2PullFileCoordinate {
19152                    sha256: content_sha256(old),
19153                    bytes: old.len() as u64,
19154                }),
19155                new: Some(V2PullFileCoordinate {
19156                    sha256: content_sha256(new),
19157                    bytes: new.len() as u64,
19158                }),
19159                backup: Some("00000000".to_string()),
19160            }],
19161        };
19162        validate_v2_pull_journal(&journal).unwrap();
19163        store
19164            .write_private_atomic_new(
19165                Path::new(V2_PULL_JOURNAL),
19166                &v2_pull_journal_bytes(&journal).unwrap(),
19167            )
19168            .unwrap();
19169        store.write_atomic(Path::new(path), new).unwrap();
19170
19171        let cfg = test_hub_config(
19172            "https://example.test".to_string(),
19173            sandbox.path().join("state"),
19174        );
19175        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19176        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
19177        assert!(!root.join(V2_PULL_JOURNAL).exists());
19178        assert!(!root.join(backup_dir).exists());
19179    }
19180
19181    #[test]
19182    fn preparing_pull_journal_discards_only_private_staging() {
19183        let sandbox = tempfile::TempDir::new().unwrap();
19184        let root = sandbox.path().join("brain");
19185        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
19186        std::fs::write(
19187            root.join("DB.md"),
19188            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19189        )
19190        .unwrap();
19191        let store = Store::open_strict(&root).unwrap();
19192        let bundle = crate::ulid::mint();
19193        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19194        store
19195            .create_private_dir_all(Path::new(&backup_dir))
19196            .unwrap();
19197        let journal = V2PullJournal {
19198            v: 1,
19199            phase: V2PullPhase::Preparing,
19200            brain: TEST_BRAIN_ID.to_string(),
19201            previous: V2PullCoordinate {
19202                head_seq: None,
19203                commit_hash: None,
19204                view_kind: None,
19205                view_revision: None,
19206            },
19207            next: V2PullCoordinate {
19208                head_seq: Some(1),
19209                commit_hash: Some("a".repeat(64)),
19210                view_kind: Some("full".to_string()),
19211                view_revision: Some("b".repeat(64)),
19212            },
19213            backup_dir: backup_dir.clone(),
19214            entries: vec![V2PullJournalEntry {
19215                path: "records/new.md".to_string(),
19216                old: None,
19217                new: Some(V2PullFileCoordinate {
19218                    sha256: "c".repeat(64),
19219                    bytes: 1,
19220                }),
19221                backup: None,
19222            }],
19223        };
19224        store
19225            .write_private_atomic_new(
19226                Path::new(V2_PULL_JOURNAL),
19227                &v2_pull_journal_bytes(&journal).unwrap(),
19228            )
19229            .unwrap();
19230        let cfg = test_hub_config(
19231            "https://example.test".to_string(),
19232            sandbox.path().join("state"),
19233        );
19234
19235        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19236
19237        assert!(root.join("DB.md").is_file());
19238        assert!(!root.join(V2_PULL_JOURNAL).exists());
19239        assert!(!root.join(backup_dir).exists());
19240    }
19241
19242    #[test]
19243    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
19244        let sandbox = tempfile::TempDir::new().unwrap();
19245        let root = sandbox.path().join("brain");
19246        std::fs::create_dir_all(root.join("records")).unwrap();
19247        std::fs::write(
19248            root.join("DB.md"),
19249            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19250        )
19251        .unwrap();
19252        let new = b"---\ntype: note\n---\n\nnew\n";
19253        std::fs::write(root.join("records/value.md"), new).unwrap();
19254        let store = Store::open_strict(&root).unwrap();
19255        let bundle = crate::ulid::mint();
19256        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19257        store
19258            .create_private_dir_all(Path::new(&backup_dir))
19259            .unwrap();
19260        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
19261        store.create_private_dir_all(Path::new(&orphan)).unwrap();
19262        let next = V2PullCoordinate {
19263            head_seq: Some(2),
19264            commit_hash: Some("c".repeat(64)),
19265            view_kind: Some("full".to_string()),
19266            view_revision: Some("d".repeat(64)),
19267        };
19268        let journal = V2PullJournal {
19269            v: 1,
19270            phase: V2PullPhase::Ready,
19271            brain: TEST_BRAIN_ID.to_string(),
19272            previous: V2PullCoordinate {
19273                head_seq: Some(1),
19274                commit_hash: Some("a".repeat(64)),
19275                view_kind: Some("full".to_string()),
19276                view_revision: Some("b".repeat(64)),
19277            },
19278            next: next.clone(),
19279            backup_dir: backup_dir.clone(),
19280            entries: vec![V2PullJournalEntry {
19281                path: "records/value.md".to_string(),
19282                old: Some(V2PullFileCoordinate {
19283                    sha256: "e".repeat(64),
19284                    bytes: new.len() as u64,
19285                }),
19286                new: Some(V2PullFileCoordinate {
19287                    sha256: content_sha256(new),
19288                    bytes: new.len() as u64,
19289                }),
19290                backup: Some("00000000".to_string()),
19291            }],
19292        };
19293        store
19294            .write_private_atomic_new(
19295                Path::new(V2_PULL_JOURNAL),
19296                &v2_pull_journal_bytes(&journal).unwrap(),
19297            )
19298            .unwrap();
19299        let cfg = test_hub_config(
19300            "https://example.test".to_string(),
19301            sandbox.path().join("state"),
19302        );
19303        save_v2_baseline(
19304            &cfg,
19305            TEST_BRAIN_ID,
19306            &root,
19307            &V2SyncBaseline {
19308                v: 2,
19309                origin: "https://example.test".to_string(),
19310                brain: TEST_BRAIN_ID.to_string(),
19311                checkout_id: Some("c".repeat(64)),
19312                head_seq: next.head_seq,
19313                commit_hash: next.commit_hash.clone(),
19314                content_root: Some("f".repeat(64)),
19315                asset_root: None,
19316                assets: Default::default(),
19317                view_kind: next.view_kind.clone(),
19318                view_revision: next.view_revision.clone(),
19319                projection_sha256: None,
19320                files: Default::default(),
19321                local_policy_digest: None,
19322                local_eligibility: Default::default(),
19323                remote_copy_remains: Default::default(),
19324            },
19325        )
19326        .unwrap();
19327
19328        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19329
19330        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
19331        assert!(!root.join(V2_PULL_JOURNAL).exists());
19332        assert!(!root.join(backup_dir).exists());
19333        assert!(!root.join(orphan).exists());
19334    }
19335}