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    /// A currently hosted asset would become withheld. That custody change
526    /// requires an exact coordinate and a bounded company audit reason; a
527    /// manifest edit or `.sevralocal` rule cannot imply it.
528    #[error(
529        "hosted assets require explicit withdrawal {paths:?} — retry with one --withdraw-from-hosting <path> per asset and a non-empty --withdraw-reason"
530    )]
531    AssetWithdrawalRequired {
532        /// Bounded asset paths. They are never sent in telemetry.
533        paths: Vec<String>,
534    },
535
536    /// The exact mutation crosses a permissioned bulk-impact boundary. The
537    /// hub has not committed it; `preview` is the bounded, permission-filtered
538    /// receipt an agent must inspect before explicitly confirming the same
539    /// request.
540    #[error(
541        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
542    )]
543    BulkPreviewRequired {
544        /// Stable structured preview returned by the hub.
545        preview: Value,
546    },
547
548    /// A scoped checkout's generated store marker was edited or removed.
549    /// It is local projection metadata and is never accepted as brain data.
550    #[error(
551        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
552    )]
553    ScopedProjectionModified,
554
555    /// The effective permission slice changed after this checkout was pinned.
556    /// Reusing the directory could conceal removals or accidentally adopt files
557    /// revealed by a wider grant, so a new checkout is required.
558    #[error(
559        "the checkout's permission scope changed — clone into a new directory to accept the new view"
560    )]
561    ScopedViewChanged,
562
563    /// A v2 identity/head was previously accepted for this ref, but the hub
564    /// now hides it. Never reinterpret that as a v1 downgrade.
565    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
566    BrainUnavailable,
567
568    /// The verified remote head advanced while a pull or post-commit barrier
569    /// was in flight. The old baseline is deliberately retained.
570    #[error(
571        "the remote brain advanced during sync — retry to converge from the new verified head"
572    )]
573    RemoteAdvancedDuringSync,
574
575    /// This build cannot provide the no-follow, directory-handle-relative
576    /// filesystem semantics required for trust/key/snapshot state.
577    #[error(
578        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
579    )]
580    UnsupportedPlatform {
581        /// The operation that requires hardened local filesystem primitives.
582        operation: &'static str,
583    },
584
585    /// Local filesystem failure while materializing a pull or reading a push.
586    #[error(transparent)]
587    Io(#[from] std::io::Error),
588
589    /// A store-level failure (walking the local store for a push).
590    #[error(transparent)]
591    Store(#[from] crate::StoreError),
592}
593
594/// Result alias for link.md client operations.
595pub type LinkResult<T> = std::result::Result<T, LinkError>;
596
597/// Exact approval token returned by a permissioned v2 bulk preview.
598#[derive(Debug, Clone, PartialEq, Eq)]
599pub struct V2BulkConfirmation {
600    /// Opaque preview receipt id.
601    pub id: String,
602    /// Digest binding the receipt to principal, head, control revision, and
603    /// the exact strict mutation.
604    pub digest: String,
605}
606
607impl V2BulkConfirmation {
608    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
609    /// outside the protocol's exact lowercase shapes before network I/O.
610    pub fn parse(value: &str) -> LinkResult<Self> {
611        let (id, digest) = value
612            .split_once(':')
613            .ok_or_else(|| LinkError::InvalidPack {
614                message: "bulk confirmation must be <id>:<digest>".to_string(),
615            })?;
616        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
617            return Err(LinkError::InvalidPack {
618                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
619                    .to_string(),
620            });
621        }
622        Ok(Self {
623            id: id.to_string(),
624            digest: digest.to_string(),
625        })
626    }
627}
628
629/// Security-sensitive link.md state and destination writes require a hardened
630/// descriptor/handle-relative filesystem implementation. Unix uses
631/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
632/// refusal, exclusive share modes, and write-through replacement.
633fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
634    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
635    {
636        let _ = operation;
637        Ok(())
638    }
639    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
640    {
641        Err(LinkError::UnsupportedPlatform { operation })
642    }
643}
644
645// ─────────────────────────────────────────────────────────────────────────────
646// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
647// ─────────────────────────────────────────────────────────────────────────────
648
649/// What the part after `@brain/` names.
650#[derive(Debug, Clone, PartialEq, Eq)]
651pub enum AddressTarget {
652    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
653    Id(String),
654    /// A store-relative `.md` path — a client-side convenience the hub's
655    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
656    /// shape; unambiguous because a ULID is never a path.
657    Path(String),
658}
659
660/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
661/// and the per-verb entry gates so the two surfaces never drift.
662const BAD_BRAIN_REASON: &str =
663    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
664
665/// Why an address target failed its shape check — shared by [`Address::parse`]
666/// and the [`resolve`] entry gate.
667const BAD_TARGET_REASON: &str =
668    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
669
670/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
671/// brain's ULID id (works for any caller, including cross-party on a public
672/// brain) or a slug (which a hub resolves only against the caller's own
673/// brains; slugs are unique per owner, not globally).
674#[derive(Debug, Clone, PartialEq, Eq)]
675pub struct Address {
676    /// The brain reference (leading `@` stripped).
677    pub brain: String,
678    /// The record target, when the address names one.
679    pub target: Option<AddressTarget>,
680}
681
682impl Address {
683    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
684    /// sigil is optional (an agent piping ids around should not have to quote
685    /// it back on). Whitespace and empty segments are malformed.
686    pub fn parse(raw: &str) -> LinkResult<Address> {
687        let bad = |reason: &str| LinkError::BadAddress {
688            given: raw.to_string(),
689            reason: reason.to_string(),
690        };
691
692        let trimmed = raw.trim();
693        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
694        if body.is_empty() {
695            return Err(bad("empty address"));
696        }
697
698        let (brain, rest) = match body.split_once('/') {
699            Some((b, r)) => (b, Some(r)),
700            None => (body, None),
701        };
702
703        if brain.is_empty() {
704            return Err(bad("missing brain reference before `/`"));
705        }
706        if !is_safe_ref(brain) {
707            return Err(bad(BAD_BRAIN_REASON));
708        }
709
710        let target = match rest {
711            None => None,
712            Some("") => return Err(bad("trailing `/` with no record id or path")),
713            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
714            Some(r) => {
715                if !safe_store_rel_path(r) || !r.ends_with(".md") {
716                    return Err(bad(BAD_TARGET_REASON));
717                }
718                Some(AddressTarget::Path(r.to_string()))
719            }
720        };
721
722        Ok(Address {
723            brain: brain.to_string(),
724            target,
725        })
726    }
727}
728
729/// A brain reference safe to embed in a URL path segment: the shapes a hub
730/// accepts (ULID id or slug), which are also exactly URL-path-clean.
731fn is_safe_ref(s: &str) -> bool {
732    !s.is_empty()
733        && s.len() <= 64
734        && s.bytes()
735            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
736}
737
738/// A published-site handle (the `propose` target). Same lexical shape as a
739/// slug.
740pub fn is_valid_handle(s: &str) -> bool {
741    is_safe_ref(s)
742}
743
744/// True when `p` is a store-relative path this client will read from or write
745/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
746/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
747/// every path an export hands us (the hub is not trusted with local layout)
748/// and to every path a push sends (mirroring the hub's own gate).
749pub fn safe_store_rel_path(p: &str) -> bool {
750    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
751        return false;
752    }
753    if !p
754        .bytes()
755        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
756    {
757        return false;
758    }
759    p.split('/')
760        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
761}
762
763/// Entry gate for every verb that embeds a caller-supplied brain reference in
764/// a URL path segment. `resolve` reaches the same check through
765/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
766/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
767/// URL-reshaping byte is refused before a request exists (the `url` crate
768/// normalizes dot segments, so an unvalidated ref would redirect the
769/// authenticated request to a different hub path).
770fn require_safe_ref(brain: &str) -> LinkResult<()> {
771    if is_safe_ref(brain) {
772        Ok(())
773    } else {
774        Err(LinkError::BadAddress {
775            given: brain.to_string(),
776            reason: BAD_BRAIN_REASON.to_string(),
777        })
778    }
779}
780
781/// Entry gate for the published-site handle `propose` embeds in its URL path.
782fn require_valid_handle(handle: &str) -> LinkResult<()> {
783    if is_valid_handle(handle) {
784        Ok(())
785    } else {
786        Err(LinkError::BadAddress {
787            given: handle.to_string(),
788            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
789        })
790    }
791}
792
793/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
794/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
795/// shape as a brain ref rather than pinning one mint scheme.
796fn require_safe_grant_id(id: &str) -> LinkResult<()> {
797    if is_safe_ref(id) {
798        Ok(())
799    } else {
800        Err(LinkError::BadGrantId {
801            given: id.to_string(),
802        })
803    }
804}
805
806// ─────────────────────────────────────────────────────────────────────────────
807// Configuration — flag > env > .dbmd/config; credential from env only
808// ─────────────────────────────────────────────────────────────────────────────
809
810/// The resolved client configuration for one invocation.
811#[derive(Debug, Clone)]
812pub struct HubConfig {
813    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
814    pub hub: String,
815    /// The bearer credential, when the environment carries one.
816    pub key: Option<String>,
817    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
818    /// over the bearer for authenticated requests (link.md §8).
819    pub agent_key: Option<AgentSigningKey>,
820    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
821    /// names one — `sync --push` then signs feed entries locally (§2.4).
822    pub brain_key: Option<AgentSigningKey>,
823    /// User-owned global toolkit state root. Identity pins and monotonic feed
824    /// checkpoints live below `<state_dir>/trust/`, never under a store.
825    pub state_dir: PathBuf,
826    /// True only when the origin came from untrusted store-local configuration.
827    /// Such origins are resolved and public-IP-pinned for every request.
828    store_selected: bool,
829}
830
831/// A loaded agent signing key: the PKCS#8 secret plus its derived public
832/// multikey. Debug never prints key material.
833#[derive(Clone)]
834pub struct AgentSigningKey {
835    pkcs8: Vec<u8>,
836    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
837    pub multikey: String,
838    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
839    pub public_key_spki: String,
840}
841
842impl std::fmt::Debug for AgentSigningKey {
843    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
844        f.debug_struct("AgentSigningKey")
845            .field("multikey", &self.multikey)
846            .field("pkcs8", &"<redacted>")
847            .finish()
848    }
849}
850
851impl HubConfig {
852    /// The credential, or the canonical "not configured" error. Verbs that
853    /// authenticate call this; `propose` never does.
854    pub fn require_key(&self) -> LinkResult<&str> {
855        self.key.as_deref().ok_or(LinkError::NoCredential)
856    }
857}
858
859/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
860/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
861/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
862/// header token (never echoed on failure).
863pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
864    let explicit_hub = flag_hub
865        .map(str::to_string)
866        .or_else(|| env_nonempty(HUB_URL_ENV));
867    let selected_by_store = explicit_hub.is_none();
868    let hub = explicit_hub
869        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
870        .ok_or(LinkError::NoHub)?;
871    let hub = hub.trim().trim_end_matches('/').to_string();
872    assert_safe_hub(&hub)?;
873    if selected_by_store {
874        let parsed =
875            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
876        // A cloned store is not an operator opt-in to local-network access.
877        // Local/private development hubs must be selected explicitly by flag or
878        // environment, never by bytes inside the store.
879        if !parsed.scheme().eq_ignore_ascii_case("https")
880            || (parsed.path() != "/" && !parsed.path().is_empty())
881        {
882            return Err(LinkError::UnsafeHub { hub });
883        }
884    }
885
886    let key = match env_nonempty(HUB_KEY_ENV) {
887        Some(raw) => Some(clean_key(&raw)?),
888        None => None,
889    };
890
891    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
892        Some(path) => Some(load_agent_key(Path::new(&path))?),
893        None => None,
894    };
895
896    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
897        Some(path) => Some(load_agent_key(Path::new(&path))?),
898        None => None,
899    };
900
901    // A cloned store controls `.dbmd/config`. It must never be able to point
902    // the process at an attacker origin and harvest the user's ambient account
903    // bearer, signed agent identity, or self-custodied brain signatures/content.
904    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
905    // in the invocation environment; a store-selected target additionally
906    // needs an exact origin binding.
907    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
908        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
909            .and_then(|value| normalized_origin(&value).ok());
910        let selected_origin = normalized_origin(&hub)?;
911        if bound.as_deref() != Some(selected_origin.as_str()) {
912            return Err(LinkError::UnboundCredential);
913        }
914    }
915
916    Ok(HubConfig {
917        hub,
918        key,
919        agent_key,
920        brain_key,
921        state_dir: toolkit_state_dir()?,
922        store_selected: selected_by_store,
923    })
924}
925
926fn toolkit_state_dir() -> LinkResult<PathBuf> {
927    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
928        let path = PathBuf::from(path);
929        if !path.is_absolute() {
930            return Err(LinkError::UnsafePath {
931                path: path.display().to_string(),
932            });
933        }
934        return Ok(path);
935    }
936    #[cfg(windows)]
937    if let Some(base) = env_nonempty("LOCALAPPDATA") {
938        let base = PathBuf::from(base);
939        if base.is_absolute() {
940            return Ok(base.join("dbmd").join("state"));
941        }
942    }
943    #[cfg(windows)]
944    {
945        Err(LinkError::Io(std::io::Error::new(
946            std::io::ErrorKind::NotFound,
947            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
948        )))
949    }
950    #[cfg(not(windows))]
951    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
952        let base = PathBuf::from(base);
953        if base.is_absolute() {
954            return Ok(base.join("dbmd"));
955        }
956    }
957    #[cfg(not(windows))]
958    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
959        LinkError::Io(std::io::Error::new(
960            std::io::ErrorKind::NotFound,
961            format!("cannot locate user state; set {STATE_DIR_ENV}"),
962        ))
963    })?);
964    #[cfg(not(windows))]
965    if !home.is_absolute() {
966        return Err(LinkError::UnsafePath {
967            path: home.display().to_string(),
968        });
969    }
970    #[cfg(target_os = "macos")]
971    {
972        Ok(home
973            .join("Library")
974            .join("Application Support")
975            .join("dbmd")
976            .join("state"))
977    }
978    #[cfg(all(not(target_os = "macos"), not(windows)))]
979    {
980        Ok(home.join(".local").join("state").join("dbmd"))
981    }
982}
983
984fn normalized_origin(value: &str) -> LinkResult<String> {
985    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
986        hub: value.to_string(),
987    })?;
988    if !(parsed.scheme().eq_ignore_ascii_case("https")
989        || parsed.scheme().eq_ignore_ascii_case("http"))
990        || !parsed.username().is_empty()
991        || parsed.password().is_some()
992        || (parsed.path() != "/" && !parsed.path().is_empty())
993        || parsed.query().is_some()
994        || parsed.fragment().is_some()
995    {
996        return Err(LinkError::UnsafeHub {
997            hub: value.to_string(),
998        });
999    }
1000    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
1001        hub: value.to_string(),
1002    })?;
1003    let host = if host.contains(':') {
1004        format!("[{host}]")
1005    } else {
1006        host.to_ascii_lowercase()
1007    };
1008    let port = parsed
1009        .port_or_known_default()
1010        .ok_or_else(|| LinkError::UnsafeHub {
1011            hub: value.to_string(),
1012        })?;
1013    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
1014        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
1015    Ok(format!(
1016        "{}://{}{}",
1017        parsed.scheme().to_ascii_lowercase(),
1018        host,
1019        if default {
1020            String::new()
1021        } else {
1022            format!(":{port}")
1023        }
1024    ))
1025}
1026
1027// ─────────────────────────────────────────────────────────────────────────────
1028// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
1029// ─────────────────────────────────────────────────────────────────────────────
1030
1031/// The DER prefix that wraps a raw Ed25519 public key into a
1032/// SubjectPublicKeyInfo (RFC 8410).
1033const ED25519_SPKI_PREFIX: [u8; 12] = [
1034    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1035];
1036
1037fn bad_agent_key(message: &str) -> LinkError {
1038    LinkError::BadAgentKey {
1039        message: message.to_string(),
1040    }
1041}
1042
1043fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1044    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
1045    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
1046    // deriving the public half itself.
1047    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1048        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1049        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1050}
1051
1052/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
1053fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1054    use ring::signature::KeyPair as _;
1055    let mut spki = Vec::with_capacity(44);
1056    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1057    spki.extend_from_slice(pair.public_key().as_ref());
1058    (
1059        URL_SAFE_NO_PAD.encode(&spki),
1060        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1061    )
1062}
1063
1064/// Load and validate a signing-key file (agent or brain — same format):
1065/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
1066/// old key explicitly.
1067pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1068    load_agent_key(path)
1069}
1070
1071/// Load and validate the agent key file: one base64url line of PKCS#8.
1072fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1073    #[cfg(unix)]
1074    let file = {
1075        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1076        use std::os::unix::ffi::OsStrExt as _;
1077        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1078            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1079        let leaf = path
1080            .file_name()
1081            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1082        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1083        let fd = unsafe {
1084            libc::openat(
1085                parent.as_raw_fd(),
1086                leaf.as_ptr(),
1087                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1088            )
1089        };
1090        if fd < 0 {
1091            return Err(bad_agent_key(
1092                "the key path must be an existing regular file without symlink ancestors",
1093            ));
1094        }
1095        unsafe { std::fs::File::from_raw_fd(fd) }
1096    };
1097    #[cfg(not(unix))]
1098    let file = std::fs::File::open(path)
1099        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1100    let metadata = file
1101        .metadata()
1102        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1103    if !metadata.is_file() {
1104        return Err(bad_agent_key("the key path must be a regular file"));
1105    }
1106    #[cfg(unix)]
1107    {
1108        use std::os::unix::fs::PermissionsExt as _;
1109        if metadata.permissions().mode() & 0o077 != 0 {
1110            return Err(bad_agent_key(
1111                "the key file is accessible to group/other; set mode 0600",
1112            ));
1113        }
1114    }
1115    let mut text = String::new();
1116    file.take(1024 * 1024 + 1)
1117        .read_to_string(&mut text)
1118        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1119    if text.len() > 1024 * 1024 {
1120        return Err(bad_agent_key("the key file exceeds the size limit"));
1121    }
1122    let pkcs8 = URL_SAFE_NO_PAD
1123        .decode(text.trim())
1124        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1125    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1126    Ok(AgentSigningKey {
1127        pkcs8,
1128        multikey,
1129        public_key_spki,
1130    })
1131}
1132
1133/// Durably create a new secret file without an exists→write race and without
1134/// ever exposing a default-mode (commonly 0644) key between write and chmod.
1135/// `create_new` also refuses a planted symlink. The file and its parent
1136/// directory are synced before the caller can publish the corresponding
1137/// public identity remotely.
1138fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1139    #[cfg(unix)]
1140    let (mut file, parent, leaf) = {
1141        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1142        use std::os::unix::ffi::OsStrExt as _;
1143        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1144        let leaf_name = path
1145            .file_name()
1146            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1147        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1148        let fd = unsafe {
1149            libc::openat(
1150                parent.as_raw_fd(),
1151                leaf.as_ptr(),
1152                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1153                0o600,
1154            )
1155        };
1156        if fd < 0 {
1157            let error = std::io::Error::last_os_error();
1158            if error.kind() == std::io::ErrorKind::AlreadyExists {
1159                return Err(bad_agent_key(
1160                    "the output file already exists — refusing to overwrite a key",
1161                ));
1162            }
1163            return Err(error.into());
1164        }
1165        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1166    };
1167    #[cfg(not(unix))]
1168    let mut file = std::fs::OpenOptions::new()
1169        .write(true)
1170        .create_new(true)
1171        .open(path)
1172        .map_err(|error| {
1173            if error.kind() == std::io::ErrorKind::AlreadyExists {
1174                bad_agent_key("the output file already exists — refusing to overwrite a key")
1175            } else {
1176                LinkError::Io(error)
1177            }
1178        })?;
1179    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1180        drop(file);
1181        #[cfg(unix)]
1182        let _ =
1183            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1184        #[cfg(not(unix))]
1185        let _ = std::fs::remove_file(path);
1186        return Err(LinkError::Io(error));
1187    }
1188    drop(file);
1189    #[cfg(unix)]
1190    parent.sync_all()?;
1191    Ok(())
1192}
1193
1194/// What `dbmd key generate` returns: the public identity to register plus
1195/// where the secret landed.
1196#[derive(Debug, Serialize)]
1197pub struct GeneratedAgentKey {
1198    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1199    pub multikey: String,
1200    /// base64url SPKI DER — what a hub's register endpoint takes.
1201    #[serde(rename = "publicKeySpki")]
1202    pub public_key_spki: String,
1203    /// Where the PKCS#8 secret was written (mode 0600).
1204    #[serde(rename = "keyFile")]
1205    pub key_file: String,
1206}
1207
1208/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1209/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1210/// identity is returned. The private key never enters a store and never
1211/// travels — requests carry per-request signatures instead (link.md §8).
1212pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1213    require_hardened_filesystem("key generation")?;
1214    let rng = ring::rand::SystemRandom::new();
1215    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1216        .map_err(|_| bad_agent_key("key generation failed"))?;
1217    let pair = agent_keypair(pkcs8.as_ref())?;
1218    let (spki_b64u, multikey) = public_identity_for(&pair);
1219
1220    write_secret_new(
1221        out,
1222        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1223    )?;
1224
1225    Ok(GeneratedAgentKey {
1226        multikey,
1227        public_key_spki: spki_b64u,
1228        key_file: out.display().to_string(),
1229    })
1230}
1231
1232/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1233/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1234/// (sha256hex(body) | "-")`.
1235///
1236/// The origin is derived from the already-validated hub URL, never from
1237/// attacker-controlled request metadata. Binding it closes the v1 replay
1238/// class where a proof captured by one hub could be replayed to another hub
1239/// serving the same path inside the timestamp window.
1240fn linkmd_sig_header(
1241    key: &AgentSigningKey,
1242    origin: &str,
1243    method: &str,
1244    path: &str,
1245    body: Option<&str>,
1246) -> LinkResult<String> {
1247    let ts = std::time::SystemTime::now()
1248        .duration_since(std::time::UNIX_EPOCH)
1249        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1250        .as_secs();
1251    let body_hash = match body {
1252        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1253        None => "-".to_string(),
1254    };
1255    let canonical = format!(
1256        "v2\n{}\n{}\n{}\n{}\n{}",
1257        origin,
1258        method.to_uppercase(),
1259        path,
1260        ts,
1261        body_hash
1262    );
1263    let pair = agent_keypair(&key.pkcs8)?;
1264    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1265    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1266    Ok(format!(
1267        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1268    ))
1269}
1270
1271// ─────────────────────────────────────────────────────────────────────────────
1272// Self-custody feed entries — the client signs what the hub only verifies
1273// ─────────────────────────────────────────────────────────────────────────────
1274
1275/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1276/// exactly this order).
1277#[derive(Serialize)]
1278struct WireFeedFile {
1279    path: String,
1280    sha256: String,
1281    bytes: u64,
1282}
1283
1284/// The unsigned entry in the normative §5.1 field order — serde serializes
1285/// struct fields in declaration order, which IS the wire contract.
1286#[derive(Serialize)]
1287struct UnsignedWireEntry<'a> {
1288    v: u8,
1289    seq: u64,
1290    ts: String,
1291    brain: &'a str,
1292    public_key: &'a str,
1293    kind: &'a str,
1294    op: &'a str,
1295    pack_sha256: &'a str,
1296    files: &'a [WireFeedFile],
1297    removed: &'a [String],
1298    prev_entry_hash: Option<&'a str>,
1299}
1300
1301/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1302/// brain key: serialize the unsigned entry compactly in the normative order,
1303/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1304/// returned string is the exact serialization the hub stores verbatim (plus
1305/// one trailing newline) and every independent reader re-derives.
1306fn self_custody_entry(
1307    key: &AgentSigningKey,
1308    seq: u64,
1309    ts: String,
1310    pack_sha256: &str,
1311    files: &[WireFeedFile],
1312    prev_entry_hash: Option<&str>,
1313) -> LinkResult<String> {
1314    let removed: [String; 0] = [];
1315    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1316        v: 1,
1317        seq,
1318        ts,
1319        brain: &key.multikey,
1320        public_key: &key.public_key_spki,
1321        kind: "push",
1322        op: "snapshot",
1323        pack_sha256,
1324        files,
1325        removed: &removed,
1326        prev_entry_hash,
1327    })
1328    .expect("serialize feed entry");
1329    let pair = agent_keypair(&key.pkcs8)?;
1330    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1331    Ok(format!(
1332        "{},\"sig\":\"{}\"}}",
1333        &unsigned[..unsigned.len() - 1],
1334        sig
1335    ))
1336}
1337
1338/// An env var, treated as absent when unset or empty (an empty
1339/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1340fn env_nonempty(name: &str) -> Option<String> {
1341    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1342}
1343
1344/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1345/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1346/// ignored (forward-compatible). A missing or unreadable file is simply "not
1347/// configured here".
1348fn config_file_hub(path: &Path) -> Option<String> {
1349    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1350    #[cfg(unix)]
1351    let file = {
1352        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1353        use std::os::unix::ffi::OsStrExt as _;
1354        let parent =
1355            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1356        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1357        let fd = unsafe {
1358            libc::openat(
1359                parent.as_raw_fd(),
1360                leaf.as_ptr(),
1361                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1362            )
1363        };
1364        if fd < 0 {
1365            return None;
1366        }
1367        unsafe { std::fs::File::from_raw_fd(fd) }
1368    };
1369    #[cfg(not(unix))]
1370    let file = std::fs::File::open(path).ok()?;
1371    let metadata = file.metadata().ok()?;
1372    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1373        return None;
1374    }
1375    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1376    file.take(MAX_CONFIG_BYTES + 1)
1377        .read_to_end(&mut bytes)
1378        .ok()?;
1379    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1380        return None;
1381    }
1382    let text = String::from_utf8(bytes).ok()?;
1383    for line in text.lines() {
1384        let line = line.trim();
1385        if line.is_empty() || line.starts_with('#') {
1386            continue;
1387        }
1388        if let Some((k, v)) = line.split_once('=') {
1389            if k.trim() == "hub" {
1390                let v = v.trim();
1391                if !v.is_empty() {
1392                    return Some(v.to_string());
1393                }
1394            }
1395        }
1396    }
1397    None
1398}
1399
1400/// The bearer key must never travel in cleartext; only loopback hosts may
1401/// skip TLS (local development against a hub on localhost).
1402fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1403    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1404        hub: hub.to_string(),
1405    })?;
1406    if !(parsed.scheme().eq_ignore_ascii_case("https")
1407        || parsed.scheme().eq_ignore_ascii_case("http"))
1408        || !parsed.username().is_empty()
1409        || parsed.password().is_some()
1410        || (parsed.path() != "/" && !parsed.path().is_empty())
1411        || parsed.query().is_some()
1412        || parsed.fragment().is_some()
1413    {
1414        return Err(LinkError::UnsafeHub {
1415            hub: hub.to_string(),
1416        });
1417    }
1418    let loopback = match parsed.host() {
1419        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1420        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1421        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1422        None => false,
1423    };
1424    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1425        Ok(())
1426    } else {
1427        Err(LinkError::UnsafeHub {
1428            hub: hub.to_string(),
1429        })
1430    }
1431}
1432
1433/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1434/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1435/// value tends to echo the whole header line, credential included, so the
1436/// gate sits here instead.
1437fn clean_key(raw: &str) -> LinkResult<String> {
1438    let k = raw.trim();
1439    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1440        return Err(LinkError::BadKey);
1441    }
1442    Ok(k.to_string())
1443}
1444
1445// ─────────────────────────────────────────────────────────────────────────────
1446// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1447// ─────────────────────────────────────────────────────────────────────────────
1448
1449/// One hub response: the status plus the parsed JSON body when there was one.
1450#[derive(Debug)]
1451pub struct HubResponse {
1452    /// The HTTP status code.
1453    pub status: u16,
1454    /// The parsed JSON body, `None` when the body was empty or not JSON.
1455    pub body: Option<Value>,
1456}
1457
1458struct RawHubResponse {
1459    status: u16,
1460    body: Vec<u8>,
1461}
1462
1463/// Whether a request carries the bearer credential.
1464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1465enum Auth {
1466    /// Send `authorization: Bearer <key>`; error without a key.
1467    Required,
1468    /// Send no credential — the propose door is unauthenticated by design.
1469    None,
1470    /// Send the configured credential when one exists, otherwise nothing —
1471    /// brain-addressed propose works anonymously on public brains, and an
1472    /// authenticated caller earns a bigger actor-class budget.
1473    Optional,
1474}
1475
1476fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1477    ureq::AgentBuilder::new()
1478        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1479        // Never follow a redirect while a bearer, a store pack, or a signed
1480        // response is in flight. Callers see the 3xx as a non-success instead
1481        // of letting an origin steer sensitive material elsewhere.
1482        .redirects(0)
1483        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1484        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1485        .timeout_write(overall)
1486        .timeout(overall)
1487}
1488
1489fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1490    hub_agent_with_timeout(
1491        cfg,
1492        std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1493    )
1494}
1495
1496fn hub_agent_with_timeout(
1497    cfg: &HubConfig,
1498    overall: std::time::Duration,
1499) -> LinkResult<ureq::Agent> {
1500    if !cfg.store_selected {
1501        return Ok(agent_builder_with_timeout(overall).build());
1502    }
1503    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1504        hub: cfg.hub.clone(),
1505    })?;
1506    pinned_public_agent_pooled(
1507        &parsed,
1508        false,
1509        "store-selected hub",
1510        AgentShape {
1511            overall,
1512            ..AgentShape::default()
1513        },
1514    )
1515}
1516
1517/// Perform one hub request. `path` is the binding path (starts with `/`);
1518/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1519/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1520/// the [`HubResponse`] for [`ensure_ok`] to shape.
1521fn request_raw(
1522    cfg: &HubConfig,
1523    method: &str,
1524    path: &str,
1525    body: Option<&Value>,
1526    auth: Auth,
1527    max_response_bytes: u64,
1528) -> LinkResult<RawHubResponse> {
1529    let http = hub_agent(cfg)?;
1530    request_raw_with_agent(
1531        cfg,
1532        &http,
1533        method,
1534        path,
1535        body,
1536        RawRequestOptions {
1537            auth,
1538            max_response_bytes,
1539            request_id: None,
1540            retry_transport: false,
1541        },
1542    )
1543}
1544
1545/// A body-bearing request that reads immutable state without changing hub
1546/// authority. Some proof lists are too large for a URL, so link.md transports
1547/// them with POST even though repeating the request is side-effect free.
1548fn request_raw_retryable_read(
1549    cfg: &HubConfig,
1550    method: &str,
1551    path: &str,
1552    body: Option<&Value>,
1553    auth: Auth,
1554    max_response_bytes: u64,
1555) -> LinkResult<RawHubResponse> {
1556    let http = hub_agent(cfg)?;
1557    request_raw_with_agent(
1558        cfg,
1559        &http,
1560        method,
1561        path,
1562        body,
1563        RawRequestOptions {
1564            auth,
1565            max_response_bytes,
1566            request_id: None,
1567            retry_transport: true,
1568        },
1569    )
1570}
1571
1572struct RawRequestOptions<'a> {
1573    auth: Auth,
1574    max_response_bytes: u64,
1575    request_id: Option<&'a str>,
1576    retry_transport: bool,
1577}
1578
1579fn request_raw_with_agent(
1580    cfg: &HubConfig,
1581    http: &ureq::Agent,
1582    method: &str,
1583    path: &str,
1584    body: Option<&Value>,
1585    options: RawRequestOptions<'_>,
1586) -> LinkResult<RawHubResponse> {
1587    let url = format!("{}{}", cfg.hub, path);
1588    let encoded_body = body.map(Value::to_string);
1589    let origin = normalized_origin(&cfg.hub)?;
1590    let safe_read = (method == "GET" && encoded_body.is_none()) || options.retry_transport;
1591    let mut read_attempt = 0;
1592    loop {
1593        // An agent signing key outranks the bearer: possession proofs put
1594        // nothing reusable on the wire, so when both are configured the
1595        // stronger one wins. Regenerate its timestamp for a whole-request GET
1596        // retry; a long stalled read must not age the replacement signature
1597        // outside the hub's acceptance window. This applies only to GETs and
1598        // the explicitly marked read-only POST bindings above.
1599        let credential = match options.auth {
1600            Auth::Required => Some(match &cfg.agent_key {
1601                Some(key) => {
1602                    linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?
1603                }
1604                None => format!("Bearer {}", cfg.require_key()?),
1605            }),
1606            Auth::Optional => match &cfg.agent_key {
1607                Some(key) => Some(linkmd_sig_header(
1608                    key,
1609                    &origin,
1610                    method,
1611                    path,
1612                    encoded_body.as_deref(),
1613                )?),
1614                None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1615            },
1616            Auth::None => None,
1617        };
1618        let result = with_connect_retries(|| {
1619            let mut req = http.request(method, &url);
1620            if let Some(value) = &credential {
1621                req = req.set("authorization", value);
1622            }
1623            if let Some(value) = options.request_id {
1624                req = req.set("x-request-id", value);
1625            }
1626            match &encoded_body {
1627                Some(value) => req
1628                    .set("content-type", "application/json")
1629                    .send_string(value)
1630                    .map_err(Box::new),
1631                None => req.call().map_err(Box::new),
1632            }
1633        });
1634        let resp = match result {
1635            Ok(resp) => resp,
1636            Err(error) => match *error {
1637                ureq::Error::Status(_, resp) => resp,
1638                ureq::Error::Transport(error) => {
1639                    if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS {
1640                        std::thread::sleep(std::time::Duration::from_millis(
1641                            SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1642                        ));
1643                        read_attempt += 1;
1644                        continue;
1645                    }
1646                    return Err(LinkError::Transport {
1647                        hub: cfg.hub.clone(),
1648                        message: error.to_string(),
1649                    });
1650                }
1651            },
1652        };
1653
1654        let status = resp.status();
1655        let buf = match read_response_body(resp, options.max_response_bytes + 1, &cfg.hub) {
1656            Ok(buf) => buf,
1657            Err(LinkError::Transport { .. })
1658                if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS =>
1659            {
1660                std::thread::sleep(std::time::Duration::from_millis(
1661                    SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1662                ));
1663                read_attempt += 1;
1664                continue;
1665            }
1666            Err(error) => return Err(error),
1667        };
1668        if buf.len() as u64 > options.max_response_bytes {
1669            return Err(LinkError::ResponseTooLarge {
1670                limit_bytes: options.max_response_bytes,
1671            });
1672        }
1673        return Ok(RawHubResponse { status, body: buf });
1674    }
1675}
1676
1677fn request_capped(
1678    cfg: &HubConfig,
1679    method: &str,
1680    path: &str,
1681    body: Option<&Value>,
1682    auth: Auth,
1683    max_response_bytes: u64,
1684) -> LinkResult<HubResponse> {
1685    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1686    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1687    Ok(HubResponse {
1688        status: raw.status,
1689        body: parsed,
1690    })
1691}
1692
1693/// One request the hub is allowed to take brain-sized time over. Used for the
1694/// commit lane, where abandoning the attempt early does not cancel the work —
1695/// it only loses the receipt for it.
1696///
1697/// Which is why a lost connection is asked again rather than reported. The
1698/// mutation coordinate is derived from the observed base and the exact
1699/// operation set precisely so a repeat is the SAME effect: the hub answers a
1700/// mutation it already applied with that commit's receipt instead of a second
1701/// commit. Measured on a 32,338-file store: the commit landed durably and the
1702/// push reported a transport failure, because nobody went back for the
1703/// receipt.
1704fn request_patient(
1705    cfg: &HubConfig,
1706    method: &str,
1707    path: &str,
1708    body: Option<&Value>,
1709    auth: Auth,
1710) -> LinkResult<HubResponse> {
1711    let http = hub_agent_with_timeout(
1712        cfg,
1713        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1714    )?;
1715    let mut attempt = 0;
1716    loop {
1717        let sent = request_raw_with_agent(
1718            cfg,
1719            &http,
1720            method,
1721            path,
1722            body,
1723            RawRequestOptions {
1724                auth,
1725                max_response_bytes: MAX_RESPONSE_BYTES,
1726                request_id: None,
1727                retry_transport: false,
1728            },
1729        );
1730        match sent {
1731            Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1732                std::thread::sleep(std::time::Duration::from_millis(
1733                    COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1734                ));
1735                attempt += 1;
1736            }
1737            Err(error) => return Err(error),
1738            Ok(raw) => {
1739                return Ok(HubResponse {
1740                    status: raw.status,
1741                    body: serde_json::from_slice(&raw.body).ok(),
1742                })
1743            }
1744        }
1745    }
1746}
1747
1748fn request(
1749    cfg: &HubConfig,
1750    method: &str,
1751    path: &str,
1752    body: Option<&Value>,
1753    auth: Auth,
1754) -> LinkResult<HubResponse> {
1755    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1756}
1757
1758/// Repeat one challenge-response mutation with the exact request identity the
1759/// hub bound into its actor claim. A new request id would create a new actor
1760/// hash and therefore a different canonical commit candidate, making the
1761/// otherwise correct self-custody signature unusable.
1762fn request_with_request_id(
1763    cfg: &HubConfig,
1764    method: &str,
1765    path: &str,
1766    body: Option<&Value>,
1767    auth: Auth,
1768    request_id: &str,
1769) -> LinkResult<HubResponse> {
1770    if request_id.is_empty()
1771        || request_id.len() > 128
1772        || !request_id
1773            .bytes()
1774            .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1775    {
1776        return Err(invalid_feed("hub returned an unsafe request id"));
1777    }
1778    // Both callers are commit responses, so this shares the commit lane's
1779    // patience: the hub is finishing the same brain-sized work either way.
1780    let http = hub_agent_with_timeout(
1781        cfg,
1782        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1783    )?;
1784    let raw = request_raw_with_agent(
1785        cfg,
1786        &http,
1787        method,
1788        path,
1789        body,
1790        RawRequestOptions {
1791            auth,
1792            max_response_bytes: MAX_RESPONSE_BYTES,
1793            request_id: Some(request_id),
1794            retry_transport: false,
1795        },
1796    )?;
1797    Ok(HubResponse {
1798        status: raw.status,
1799        body: serde_json::from_slice(&raw.body).ok(),
1800    })
1801}
1802
1803fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1804    if (200..300).contains(&r.status) {
1805        return Ok(r.body);
1806    }
1807    ensure_ok(
1808        HubResponse {
1809            status: r.status,
1810            body: serde_json::from_slice(&r.body).ok(),
1811        },
1812        what,
1813    )
1814    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1815}
1816
1817/// These failures happen before any HTTP request reaches the hub, so retrying
1818/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1819/// bytes may have crossed the wire, the caller must rely on the verb's own
1820/// idempotency contract instead of guessing.
1821fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1822    matches!(
1823        kind,
1824        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1825    )
1826}
1827
1828fn with_connect_retries(
1829    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1830) -> Result<ureq::Response, Box<ureq::Error>> {
1831    let mut attempt = 0;
1832    loop {
1833        match send() {
1834            Err(error)
1835                if matches!(
1836                    error.as_ref(),
1837                    ureq::Error::Transport(transport)
1838                        if is_pre_request_transport(transport.kind())
1839                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1840            {
1841                std::thread::sleep(std::time::Duration::from_millis(
1842                    CONNECT_RETRY_BACKOFF_MS[attempt],
1843                ));
1844                attempt += 1;
1845            }
1846            result => return result,
1847        }
1848    }
1849}
1850
1851fn hub_is_loopback(hub: &str) -> bool {
1852    url::Url::parse(hub).ok().is_some_and(|parsed| {
1853        parsed.host().is_some_and(|host| match host {
1854            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1855            url::Host::Ipv4(ip) => ip.is_loopback(),
1856            url::Host::Ipv6(ip) => ip.is_loopback(),
1857        })
1858    })
1859}
1860
1861/// Every safety property a presigned object-store URL must carry, checked
1862/// before anything is sent to it. Split out from agent construction so a batch
1863/// can share one connection pool without ever skipping a per-URL check.
1864fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1865    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1866        message: "the hub returned an invalid object-store URL".to_string(),
1867    })?;
1868    let allow_private = hub_is_loopback(&cfg.hub)
1869        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1870    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1871        || !parsed.username().is_empty()
1872        || parsed.password().is_some()
1873        || parsed.fragment().is_some()
1874    {
1875        return Err(LinkError::InvalidPack {
1876            message: "the hub returned an unsafe object-store URL".to_string(),
1877        });
1878    }
1879    Ok((parsed, allow_private))
1880}
1881
1882fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1883    let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1884    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1885        LinkError::InvalidPack {
1886            message: "the hub returned an object-store URL with an unsafe network target"
1887                .to_string(),
1888        }
1889    })
1890}
1891
1892/// One pinned agent for a whole staging batch.
1893///
1894/// Every object in a batch is presigned against the same object-store
1895/// authority, so one agent can serve them all — and must: building one per
1896/// object costs a DNS resolution and a fresh TLS handshake per object, which is
1897/// the difference between a real store staging in minutes and in an hour. The
1898/// shared authority is verified rather than assumed, and a batch that ever
1899/// spans two of them falls back to a per-object agent.
1900fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1901    let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1902    let authority = (
1903        first.host_str()?.to_string(),
1904        first.port_or_known_default()?,
1905    );
1906    for raw in &urls[1..] {
1907        let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1908        if (parsed.host_str()?, parsed.port_or_known_default()?)
1909            != (authority.0.as_str(), authority.1)
1910        {
1911            return None;
1912        }
1913    }
1914    pinned_public_agent_pooled(
1915        &first,
1916        allow_private,
1917        "object-store URL",
1918        AgentShape {
1919            idle_per_host: V2_UPLOAD_CONCURRENCY,
1920            ..AgentShape::default()
1921        },
1922    )
1923    .ok()
1924}
1925
1926/// Turn an object-store transport failure into a useful but credential-safe
1927/// diagnostic. `ureq::Transport`'s Display implementation includes the full
1928/// request URL; for a presigned URL that means the short-lived signature and
1929/// every signed query coordinate. The error kind is enough to distinguish a
1930/// network failure without copying bearer material into terminals or logs.
1931fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1932    LinkError::Transport {
1933        hub: "the object store".to_string(),
1934        message: format!("network error ({:?})", error.kind()),
1935    }
1936}
1937
1938fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1939    let http = presigned_agent(cfg, raw)?;
1940    let deadline = std::time::Instant::now()
1941        .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1942        .ok_or_else(upload_deadline_error)?;
1943    let mut attempt = 0;
1944    let result = loop {
1945        // The reservation states content-length itself. This client sends the
1946        // body from memory, so it never needs to add one, and adding one under
1947        // a different capitalization would leave both on the wire.
1948        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1949        if let Some(map) = headers.as_object() {
1950            for (name, value) in map {
1951                if let Some(value) = value.as_str() {
1952                    req = req.set(name, value);
1953                }
1954            }
1955        }
1956        match req.send_bytes(bytes) {
1957            // An upload is idempotent here: fixed content address, exact
1958            // length and checksum, write-once precondition. A dropped
1959            // connection or a single overloaded refusal is worth replaying
1960            // rather than failing a whole migration on. 412 already means
1961            // "someone else stored these exact bytes".
1962            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1963                attempt += 1;
1964            }
1965            Err(ureq::Error::Status(status, _))
1966                if status != 412
1967                    && is_retryable_upload_status(status)
1968                    && wait_for_upload_retry(deadline, attempt) =>
1969            {
1970                attempt += 1;
1971            }
1972            result => break result,
1973        }
1974    };
1975    match result {
1976        Ok(resp) if (200..300).contains(&resp.status()) => {
1977            drain_presigned_response(resp);
1978            Ok(())
1979        }
1980        Ok(resp) => Err(presigned_upload_refusal(resp)),
1981        Err(error) => match error {
1982            // Immutable uploads use If-None-Match. A concurrent writer may
1983            // win the same content address; the commit/confirm endpoint reads
1984            // and hashes that object before accepting it, so 412 safely means
1985            // "continue to verification", never "trust the upload".
1986            ureq::Error::Status(412, _) => Ok(()),
1987            ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
1988            ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
1989        },
1990    }
1991}
1992
1993/// Read a response body, typed as the transport failure it is.
1994///
1995/// A connection that fails while the body is still arriving is the same class
1996/// of problem as one that fails before it arrives at all — but a bare `?` on
1997/// the read turned it into an anonymous io error that no retry path recognised
1998/// and no message attributed to a peer. On a migration making tens of
1999/// thousands of requests, one stalled body ended the whole push and reported
2000/// only "timed out reading response".
2001fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
2002    let mut buf = Vec::new();
2003    response
2004        .into_reader()
2005        .take(limit)
2006        .read_to_end(&mut buf)
2007        .map_err(|error| LinkError::Transport {
2008            hub: peer.to_string(),
2009            message: error.to_string(),
2010        })?;
2011    Ok(buf)
2012}
2013
2014/// Draining a response is what returns its connection to the pool: the client
2015/// cannot recycle a socket whose body may still have bytes pending, so a body
2016/// that is dropped instead of read costs the next object a fresh handshake.
2017/// An object-store PUT answers with almost nothing, so this is nearly free.
2018fn drain_presigned_response(response: ureq::Response) {
2019    let mut reader = response.into_reader().take(64 * 1024);
2020    let _ = std::io::copy(&mut reader, &mut std::io::sink());
2021}
2022
2023/// Carry the object store's own reason for a refusal. An unexplained status is
2024/// unactionable; the body states checksum mismatch, precondition, or expiry.
2025fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
2026    let status = response.status();
2027    let detail = response
2028        .into_string()
2029        .ok()
2030        .map(|body| body.chars().take(400).collect::<String>())
2031        .filter(|body| !body.trim().is_empty());
2032    LinkError::Http {
2033        what: "pack upload",
2034        status,
2035        message: match detail {
2036            Some(body) => format!(
2037                "object store rejected the upload: {}",
2038                body.replace('\n', " ")
2039            ),
2040            None => "object store rejected the upload".to_string(),
2041        },
2042        code: None,
2043        details: None,
2044    }
2045}
2046
2047fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
2048    max_bytes.checked_add(1)
2049}
2050
2051fn presigned_download_read_limit() -> u64 {
2052    one_past_bounded_limit(MAX_PACK_BYTES)
2053        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
2054}
2055
2056fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
2057    let http = presigned_agent(cfg, raw)?;
2058    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
2059        Ok(resp) => resp,
2060        Err(error) => match *error {
2061            ureq::Error::Status(_, resp) => {
2062                return Err(LinkError::Http {
2063                    what: "pack download",
2064                    status: resp.status(),
2065                    message: "object store rejected the download".to_string(),
2066                    code: None,
2067                    details: None,
2068                });
2069            }
2070            ureq::Error::Transport(err) => {
2071                return Err(LinkError::Transport {
2072                    hub: "the object store".to_string(),
2073                    message: err.to_string(),
2074                });
2075            }
2076        },
2077    };
2078    if !(200..300).contains(&resp.status()) {
2079        return Err(LinkError::Http {
2080            what: "pack download",
2081            status: resp.status(),
2082            message: "object store rejected the download".to_string(),
2083            code: None,
2084            details: None,
2085        });
2086    }
2087    let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2088    if bytes.len() as u64 > MAX_PACK_BYTES {
2089        return Err(LinkError::InvalidPack {
2090            message: "download exceeds the compressed-size limit".to_string(),
2091        });
2092    }
2093    Ok(bytes)
2094}
2095
2096/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
2097/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
2098/// answer.
2099fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2100    if !(200..300).contains(&r.status) {
2101        let message = r
2102            .body
2103            .as_ref()
2104            .and_then(|b| b.get("error"))
2105            .and_then(Value::as_str)
2106            .unwrap_or("unknown error")
2107            .to_string();
2108        let code = r
2109            .body
2110            .as_ref()
2111            .and_then(|b| b.get("code"))
2112            .and_then(Value::as_str)
2113            .map(str::to_string);
2114        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2115        return Err(LinkError::Http {
2116            what,
2117            status: r.status,
2118            message,
2119            code,
2120            details,
2121        });
2122    }
2123    r.body.ok_or(LinkError::NotJson {
2124        what,
2125        status: r.status,
2126    })
2127}
2128
2129// ─────────────────────────────────────────────────────────────────────────────
2130// resolve — handle → brain card; @brain/id → the record
2131// ─────────────────────────────────────────────────────────────────────────────
2132
2133/// Resolve an address. A bare `@brain` returns the brain card (metadata +
2134/// index stats — the v0 form of the card; keys arrive with the protocol's
2135/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
2136/// record, frontmatter + body.
2137fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2138    match ip {
2139        std::net::IpAddr::V4(ip) => {
2140            let [a, b, c, _] = ip.octets();
2141            !(a == 0
2142                || a == 10
2143                || a == 127
2144                || (a == 100 && (64..=127).contains(&b))
2145                || (a == 169 && b == 254)
2146                || (a == 172 && (16..=31).contains(&b))
2147                || (a == 192 && b == 0 && c == 0)
2148                || (a == 192 && b == 0 && c == 2)
2149                || (a == 192 && b == 88 && c == 99)
2150                || (a == 192 && b == 168)
2151                || (a == 198 && (b == 18 || b == 19))
2152                || (a == 198 && b == 51 && c == 100)
2153                || (a == 203 && b == 0 && c == 113)
2154                || a >= 224)
2155        }
2156        std::net::IpAddr::V6(ip) => {
2157            let segments = ip.segments();
2158            // Conservatively accept only global unicast 2000::/3, excluding
2159            // special-purpose/transition blocks. In particular, 6to4 embeds
2160            // an IPv4 destination and must not tunnel a validated public
2161            // connect to 127/8 or RFC1918.
2162            (segments[0] & 0xe000) == 0x2000
2163                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2164                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2165                && segments[0] != 0x2002
2166                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2167        }
2168    }
2169}
2170
2171#[derive(Clone)]
2172struct PinnedRegistryResolver {
2173    netloc: String,
2174    addresses: Vec<std::net::SocketAddr>,
2175}
2176
2177impl ureq::Resolver for PinnedRegistryResolver {
2178    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2179        if requested == self.netloc {
2180            Ok(self.addresses.clone())
2181        } else {
2182            Err(std::io::Error::new(
2183                std::io::ErrorKind::PermissionDenied,
2184                "registry request attempted to resolve an unvalidated authority",
2185            ))
2186        }
2187    }
2188}
2189
2190fn pinned_public_agent(
2191    url: &url::Url,
2192    allow_private: bool,
2193    label: &str,
2194) -> LinkResult<ureq::Agent> {
2195    pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2196}
2197
2198/// How one agent is built: `idle_per_host` sizes the connection pool (the
2199/// default of one is right for a lone request but throttles a concurrent batch
2200/// back to a single warm connection), and `overall` is the wall-clock budget
2201/// for one attempt.
2202struct AgentShape {
2203    idle_per_host: usize,
2204    overall: std::time::Duration,
2205}
2206
2207impl Default for AgentShape {
2208    fn default() -> Self {
2209        Self {
2210            idle_per_host: 1,
2211            overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2212        }
2213    }
2214}
2215
2216fn pinned_public_agent_pooled(
2217    url: &url::Url,
2218    allow_private: bool,
2219    label: &str,
2220    shape: AgentShape,
2221) -> LinkResult<ureq::Agent> {
2222    let host = url
2223        .host_str()
2224        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2225    let port = url
2226        .port_or_known_default()
2227        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2228    let addresses = resolve_addresses_with_deadline(
2229        host,
2230        port,
2231        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2232    )
2233    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2234    if addresses.is_empty() {
2235        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2236    }
2237    if !allow_private
2238        && addresses
2239            .iter()
2240            .any(|address| !is_public_registry_ip(address.ip()))
2241    {
2242        return Err(invalid_feed(format!(
2243            "{label} resolves to a non-public address"
2244        )));
2245    }
2246    let netloc = if host.contains(':') {
2247        format!("[{host}]:{port}")
2248    } else {
2249        format!("{host}:{port}")
2250    };
2251    Ok(agent_builder_with_timeout(shape.overall)
2252        .max_idle_connections_per_host(shape.idle_per_host.max(1))
2253        .resolver(PinnedRegistryResolver { netloc, addresses })
2254        .build())
2255}
2256
2257/// Resolve one authority without allowing libc DNS to hold the caller forever.
2258/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
2259/// worker and only its result channel is awaited. A late resolver result has no
2260/// side effects and is discarded after the deadline.
2261fn resolve_addresses_with_deadline(
2262    host: &str,
2263    port: u16,
2264    timeout: std::time::Duration,
2265) -> std::io::Result<Vec<std::net::SocketAddr>> {
2266    use std::net::ToSocketAddrs as _;
2267
2268    let host = host.to_string();
2269    let (send, receive) = std::sync::mpsc::sync_channel(1);
2270    std::thread::Builder::new()
2271        .name("dbmd-dns".to_string())
2272        .spawn(move || {
2273            let result = (host.as_str(), port)
2274                .to_socket_addrs()
2275                .map(|addresses| addresses.collect());
2276            let _ = send.send(result);
2277        })
2278        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2279    match receive.recv_timeout(timeout) {
2280        Ok(result) => result,
2281        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2282            std::io::ErrorKind::TimedOut,
2283            "resolution exceeded its deadline",
2284        )),
2285        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2286            "resolver stopped without returning a result",
2287        )),
2288    }
2289}
2290
2291fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2292    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2293    pinned_public_agent(url, allow_private, "registry home")
2294}
2295
2296/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
2297/// from a FOREIGN home during registry resolution. DNS is resolved once,
2298/// every answer is required to be public, and that exact answer set is pinned
2299/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
2300fn get_json_absolute(url: &str) -> LinkResult<Value> {
2301    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2302    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2303    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2304        || !parsed.username().is_empty()
2305        || parsed.password().is_some()
2306        || parsed.query().is_some()
2307        || parsed.fragment().is_some()
2308    {
2309        return Err(invalid_feed("unsafe registry home URL"));
2310    }
2311    let http = registry_agent(&parsed)?;
2312    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2313        Ok(resp) => resp,
2314        Err(error) => match *error {
2315            ureq::Error::Status(status, resp) => {
2316                let _ = resp;
2317                return Err(LinkError::Http {
2318                    what: "registry home fetch",
2319                    status,
2320                    message: "the home node rejected the card request".to_string(),
2321                    code: None,
2322                    details: None,
2323                });
2324            }
2325            ureq::Error::Transport(err) => {
2326                return Err(LinkError::Transport {
2327                    hub: url.to_string(),
2328                    message: err.to_string(),
2329                });
2330            }
2331        },
2332    };
2333    if !(200..300).contains(&resp.status()) {
2334        return Err(LinkError::Http {
2335            what: "registry home fetch",
2336            status: resp.status(),
2337            message: "the home node returned a redirect or error".to_string(),
2338            code: None,
2339            details: None,
2340        });
2341    }
2342    let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2343    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2344        return Err(LinkError::ResponseTooLarge {
2345            limit_bytes: MAX_REGISTRY_CARD_BYTES,
2346        });
2347    }
2348    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2349        message: "the home node returned invalid JSON".to_string(),
2350    })
2351}
2352
2353/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
2354/// E5): look the handle up in the hub's registry, fetch the brain card from
2355/// the returned HOME node, and PIN — the card's identity fingerprint must
2356/// equal the registry's, or resolution fails. Returns the card enriched with
2357/// the resolved `home`, or `Ok(None)` when the registry has no such handle
2358/// (so the caller can fall back to a direct lookup).
2359pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2360    require_safe_ref(handle)?;
2361    // Hold the validated trust-directory capability before any network I/O.
2362    // Every lock/load/save below remains relative to this exact inode even if
2363    // an attacker swaps an ancestor while the registry or home is answering.
2364    let trust_directory = open_trust_dir(cfg)?;
2365    let reg = request_capped(
2366        cfg,
2367        "GET",
2368        &format!("/api/hub/registry/{handle}"),
2369        None,
2370        Auth::None,
2371        MAX_REGISTRY_CARD_BYTES,
2372    )?;
2373    if reg.status == 404 {
2374        return Ok(None);
2375    }
2376    let body = ensure_ok(reg, "registry resolve")?;
2377    let home = body
2378        .get("home")
2379        .and_then(Value::as_str)
2380        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2381    let brain = body
2382        .get("brain")
2383        .and_then(Value::as_str)
2384        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2385    if !crate::ulid::is_ulid(brain) {
2386        return Err(invalid_feed(
2387            "registry entry brain is not a canonical lowercase ULID",
2388        ));
2389    }
2390    let want_fp = body
2391        .get("identity")
2392        .and_then(|i| i.get("fingerprint"))
2393        .and_then(Value::as_str)
2394        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2395
2396    let home = home.trim_end_matches('/');
2397    let origin = normalized_origin(home)?;
2398    if origin != home {
2399        return Err(invalid_feed(
2400            "registry home must be an origin without a path, query, or fragment",
2401        ));
2402    }
2403    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2404    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2405    if let Some(binding) = &alias_binding {
2406        if binding
2407            .home
2408            .as_deref()
2409            .is_some_and(|pinned_home| pinned_home != home)
2410        {
2411            return Err(invalid_feed(
2412                "registry relocated a pinned handle to a different home",
2413            ));
2414        }
2415    }
2416    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2417    if card.get("id").and_then(Value::as_str) != Some(brain) {
2418        return Err(invalid_feed(
2419            "the home node served a card for a different brain",
2420        ));
2421    }
2422    let identity: FeedIdentity = serde_json::from_value(
2423        card.get("identity")
2424            .cloned()
2425            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2426    )
2427    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2428    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2429    let got_fp = card
2430        .get("identity")
2431        .and_then(|i| i.get("fingerprint"))
2432        .and_then(Value::as_str)
2433        .unwrap_or_default();
2434    if got_fp != want_fp {
2435        return Err(invalid_feed(
2436            "the home node served an identity that does not match the registry — refusing",
2437        ));
2438    }
2439    let current = format!("ed25519:{}", identity.fingerprint);
2440    let advertised_seq = card
2441        .get("headSeq")
2442        .and_then(Value::as_u64)
2443        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2444    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2445    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2446        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2447    {
2448        return Err(invalid_feed(
2449            "the home node served an invalid feed head boundary",
2450        ));
2451    }
2452    // This is required even on first contact. Otherwise a syntactically valid
2453    // identity may claim that a rotation happened after a head the home has
2454    // never reached, then become the permanent TOFU anchor.
2455    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2456    let registry_alias = AliasBinding {
2457        v: 1,
2458        origin: normalized_origin(&cfg.hub)?,
2459        requested: handle.to_string(),
2460        brain: brain.to_string(),
2461        home: Some(home.to_string()),
2462    };
2463    save_canonical_pin_and_alias(
2464        cfg,
2465        &trust_directory,
2466        handle,
2467        brain,
2468        TrustState {
2469            v: 2,
2470            origin: normalized_origin(&cfg.hub)?,
2471            requested: brain.to_string(),
2472            brain: brain.to_string(),
2473            home: None,
2474            anchor,
2475            current,
2476            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2477            feed_hash: pinned
2478                .as_ref()
2479                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2480            rotations: identity.rotations.clone(),
2481            hub_signer: None,
2482            protocol_profile: None,
2483        },
2484        Some(&registry_alias),
2485    )?;
2486    let mut out = card;
2487    if let Value::Object(map) = &mut out {
2488        map.insert("home".to_string(), Value::String(home.to_string()));
2489        map.insert(
2490            "resolvedVia".to_string(),
2491            Value::String("registry".to_string()),
2492        );
2493    }
2494    Ok(Some(out))
2495}
2496
2497pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2498    // `Address::parse` refuses these shapes already, but `Address` has public
2499    // fields — re-assert at the wire so a hand-built address can never
2500    // reshape the request path.
2501    require_safe_ref(&addr.brain)?;
2502    if let Some(target) = &addr.target {
2503        let (given, ok) = match target {
2504            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2505            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2506        };
2507        if !ok {
2508            return Err(LinkError::BadAddress {
2509                given: given.clone(),
2510                reason: BAD_TARGET_REASON.to_string(),
2511            });
2512        }
2513    }
2514
2515    // A record is never accepted from the hub's mutable query/index response.
2516    // Under v2, an id lookup may suggest a path, but the client accepts only
2517    // bytes whose exact path proof reaches the verified content root and whose
2518    // parsed id still matches. Under v1, the signed snapshot pack remains the
2519    // binding. Both profiles therefore reject a forged query response.
2520    if let Some(target) = &addr.target {
2521        if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2522            let pointer = head.pointer.as_ref().ok_or_else(|| 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            let (path, file) = match target {
2530                AddressTarget::Path(path) => {
2531                    let file =
2532                        v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2533                            LinkError::Http {
2534                                what: "resolve",
2535                                status: 404,
2536                                message: "record not found".to_string(),
2537                                code: Some("NOT_FOUND".to_string()),
2538                                details: None,
2539                            }
2540                        })?;
2541                    (path.clone(), file)
2542                }
2543                AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2544            };
2545            let mut downloaded =
2546                download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2547            let (_, bytes) = downloaded
2548                .pop()
2549                .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2550            let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2551            accept_v2_head(cfg, &head)?;
2552            return Ok(resolved);
2553        }
2554        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2555        if !remote.head.verified {
2556            return Err(invalid_feed(
2557                "a path-scoped feed cannot prove a record against the full signed snapshot",
2558            ));
2559        }
2560        if remote.head.seq == 0 {
2561            return Err(LinkError::Http {
2562                what: "resolve",
2563                status: 404,
2564                message: "record not found".to_string(),
2565                code: Some("NOT_FOUND".to_string()),
2566                details: None,
2567            });
2568        }
2569        let brain = remote.head.brain.clone();
2570        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2571        return resolve_from_verified_pack(&brain, target, pack);
2572    }
2573
2574    let path = format!("/api/hub/brains/{}", addr.brain);
2575    // Direct first: the caller's own slug and hub-hosted public handles resolve
2576    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2577    // (404) falls through to the federation registry — how a handle reaches a
2578    // brain on ANOTHER node (link.md §7.1, E5).
2579    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2580    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2581        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2582            return Ok(card);
2583        }
2584    }
2585    let mut resolved = ensure_ok(direct, "resolve")?;
2586    if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2587        let v2 = v2_verified_head(cfg, &addr.brain)?
2588            .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2589        if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2590            return Err(invalid_feed(
2591                "resolve card is not bound to the verified v2 brain",
2592            ));
2593        }
2594        let card_identity: FeedIdentity = serde_json::from_value(
2595            resolved
2596                .get("identity")
2597                .cloned()
2598                .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2599        )
2600        .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2601        if card_identity != v2_identity(&v2.identity) {
2602            return Err(invalid_feed(
2603                "resolve card identity differs from the verified v2 identity",
2604            ));
2605        }
2606        accept_v2_head(cfg, &v2)?;
2607        if let Value::Object(card) = &mut resolved {
2608            card.insert(
2609                "headSeq".to_string(),
2610                json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2611            );
2612            card.insert(
2613                "feedHash".to_string(),
2614                v2.pointer
2615                    .as_ref()
2616                    .map(|pointer| Value::String(pointer.feed_hash.clone()))
2617                    .unwrap_or(Value::Null),
2618            );
2619            card.insert(
2620                "storageProfile".to_string(),
2621                Value::String("v2".to_string()),
2622            );
2623            if let Some(pointer) = &v2.pointer {
2624                card.insert(
2625                    "updatedAt".to_string(),
2626                    Value::String(pointer.signed_at.clone()),
2627                );
2628            }
2629        }
2630        return Ok(resolved);
2631    }
2632    // A successful direct response is accepted only after the same centralized
2633    // identity/rotation/feed checkpoint verification used by sync and
2634    // subscribe. No verb gets a weaker ad-hoc pinning path.
2635    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2636    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2637        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2638        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2639    {
2640        return Err(invalid_feed(
2641            "resolve card is not bound to the exact verified feed checkpoint",
2642        ));
2643    }
2644    let card_identity: FeedIdentity = serde_json::from_value(
2645        resolved
2646            .get("identity")
2647            .cloned()
2648            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2649    )
2650    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2651    if remote.identity.as_ref() != Some(&card_identity) {
2652        return Err(invalid_feed(
2653            "resolve card identity differs from the verified feed identity",
2654        ));
2655    }
2656    Ok(resolved)
2657}
2658
2659/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2660/// mutable query/index result is intentionally not consulted: a signed pack
2661/// digest is the only cryptographic binding between a feed checkpoint and
2662/// record bytes in wire profile v1.
2663fn resolve_from_verified_pack(
2664    brain: &str,
2665    target: &AddressTarget,
2666    pack: Vec<u8>,
2667) -> LinkResult<Value> {
2668    let entries = parse_store_pack(pack)?;
2669    let mut matched: Option<(String, Vec<u8>)> = None;
2670
2671    for (path, bytes) in entries {
2672        let is_candidate = match target {
2673            AddressTarget::Path(want) => &path == want,
2674            AddressTarget::Id(_) => {
2675                path.ends_with(".md")
2676                    && (path.starts_with("records/") || path.starts_with("sources/"))
2677            }
2678        };
2679        if !is_candidate {
2680            continue;
2681        }
2682        let text = std::str::from_utf8(&bytes)
2683            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2684        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2685            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2686        if let AddressTarget::Id(want) = target {
2687            let frontmatter =
2688                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2689                    .map_err(|_| {
2690                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2691                    })?;
2692            if frontmatter.id.as_deref() != Some(want) {
2693                continue;
2694            }
2695        }
2696        if matched.is_some() {
2697            return Err(invalid_feed(
2698                "signed snapshot contains more than one record for the requested target",
2699            ));
2700        }
2701        matched = Some((path, bytes));
2702    }
2703
2704    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2705        what: "resolve",
2706        status: 404,
2707        message: "record not found".to_string(),
2708        code: Some("NOT_FOUND".to_string()),
2709        details: None,
2710    })?;
2711    resolve_from_verified_record_bytes(brain, target, path, bytes)
2712}
2713
2714fn resolve_from_verified_record_bytes(
2715    brain: &str,
2716    target: &AddressTarget,
2717    path: String,
2718    bytes: Vec<u8>,
2719) -> LinkResult<Value> {
2720    match target {
2721        AddressTarget::Path(expected) if expected != &path => {
2722            return Err(invalid_feed(
2723                "verified record path differs from the requested path",
2724            ));
2725        }
2726        AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2727            return Err(invalid_feed(
2728                "verified id resolved outside records or sources",
2729            ));
2730        }
2731        _ => {}
2732    }
2733    let text = std::str::from_utf8(&bytes)
2734        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2735    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2736        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2737    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2738        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2739    let Value::Object(fields) = frontmatter else {
2740        return Err(invalid_feed(format!(
2741            "signed snapshot record `{path}` frontmatter is not a mapping"
2742        )));
2743    };
2744    if let AddressTarget::Id(expected) = target {
2745        if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2746            return Err(invalid_feed(
2747                "verified record id differs from the requested id",
2748            ));
2749        }
2750    }
2751    let mut document = serde_json::Map::new();
2752    document.insert("path".to_string(), Value::String(path));
2753    for (key, value) in fields {
2754        document.insert(key, value);
2755    }
2756    document.insert("body".to_string(), Value::String(parsed.body));
2757    document.insert(
2758        "contentSha".to_string(),
2759        Value::String(content_sha256(&bytes)),
2760    );
2761    Ok(json!({
2762        "brain": brain,
2763        "document": Value::Object(document),
2764    }))
2765}
2766
2767// ─────────────────────────────────────────────────────────────────────────────
2768// sync — pull the granted slice as files; push the local store as a snapshot
2769// ─────────────────────────────────────────────────────────────────────────────
2770
2771/// What a pull materialized.
2772#[derive(Debug, Clone, serde::Serialize)]
2773pub struct PullReport {
2774    /// The brain id the hub reported.
2775    pub brain: String,
2776    /// The brain's slug.
2777    pub slug: String,
2778    /// The hub's feed head at export time.
2779    #[serde(rename = "headSeq")]
2780    pub head_seq: u64,
2781    /// How many files were written.
2782    pub files: usize,
2783    /// Where they were written (as given or derived from the slug).
2784    pub dest: String,
2785    /// Local content files that the export did not carry — present so a
2786    /// caller sees divergence; nothing is ever deleted locally.
2787    #[serde(rename = "extraLocal")]
2788    pub extra_local: Vec<String>,
2789    /// Exact convergence result from the post-install barrier.
2790    #[serde(rename = "syncStatus")]
2791    pub sync_status: String,
2792}
2793
2794struct V2PulledSnapshot {
2795    report: PullReport,
2796    head: V2VerifiedHead,
2797    files: std::collections::BTreeMap<String, V2BaselineFile>,
2798    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2799    local: V2LocalView,
2800    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2801}
2802
2803fn download_verified_snapshot_pack(
2804    cfg: &HubConfig,
2805    brain: &str,
2806    remote: &VerifiedRemote,
2807) -> LinkResult<Vec<u8>> {
2808    let feed_hash = remote
2809        .head
2810        .feed_hash
2811        .as_deref()
2812        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2813    let signed_head = remote
2814        .head_entry
2815        .as_ref()
2816        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2817    let expected = &signed_head.entry.pack_sha256;
2818    if !is_sha256(expected) {
2819        return Err(invalid_feed(
2820            "signed head carries an invalid snapshot pack digest",
2821        ));
2822    }
2823    let path = format!(
2824        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2825        remote.head.seq
2826    );
2827    let body = ensure_ok(
2828        request(cfg, "GET", &path, None, Auth::Required)?,
2829        "sync pull",
2830    )?;
2831    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2832        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2833        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2834        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2835    {
2836        return Err(invalid_feed(
2837            "export response is not bound to the exact verified snapshot",
2838        ));
2839    }
2840    let url = body
2841        .get("url")
2842        .and_then(Value::as_str)
2843        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2844    let bytes = get_presigned(cfg, url)?;
2845    if content_sha256(&bytes) != *expected {
2846        return Err(LinkError::InvalidPack {
2847            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2848        });
2849    }
2850    let entries = parse_store_pack(bytes.clone())?;
2851    if signed_head.entry.kind == "push" {
2852        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2853    }
2854    Ok(bytes)
2855}
2856
2857#[derive(Debug, Clone, Deserialize, Serialize)]
2858struct V2PointerBody {
2859    v: u8,
2860    brain: String,
2861    seq: u64,
2862    commit_hash: String,
2863    feed_hash: String,
2864    content_root: Option<String>,
2865    asset_root: Option<String>,
2866    materializer: String,
2867    signer_epoch: u64,
2868    control_revision: String,
2869    backup_preparation: String,
2870    prior_pointer_hash: Option<String>,
2871    signed_at: String,
2872}
2873
2874#[derive(Debug, Clone, Deserialize)]
2875struct V2SignedPointer {
2876    pointer: V2PointerBody,
2877    hub_public_key: String,
2878    hub_fingerprint: String,
2879    sig: String,
2880}
2881
2882#[derive(Debug, Clone, Deserialize)]
2883struct V2HeadIdentity {
2884    #[serde(default)]
2885    custody: String,
2886    fingerprint: String,
2887    public_key_spki: String,
2888    #[serde(default)]
2889    previous: Vec<V2PreviousIdentity>,
2890    #[serde(default)]
2891    rotations: Vec<String>,
2892}
2893
2894#[derive(Debug, Clone, Deserialize)]
2895struct V2PreviousIdentity {
2896    fingerprint: String,
2897    public_key_spki: String,
2898}
2899
2900#[derive(Debug, Deserialize)]
2901struct V2HeadResponse {
2902    v: u8,
2903    brain_id: String,
2904    profile: String,
2905    view: Option<V2HeadView>,
2906    pointer: Option<V2SignedPointer>,
2907    identity: Option<V2HeadIdentity>,
2908}
2909
2910#[derive(Debug, Clone, Deserialize)]
2911struct V2HeadView {
2912    kind: String,
2913    #[serde(default)]
2914    id: Option<String>,
2915    control_revision: String,
2916}
2917
2918#[derive(Debug, Clone)]
2919struct V2VerifiedHead {
2920    requested: String,
2921    brain_id: String,
2922    view_kind: String,
2923    /// Stable identity of the exact current-content path projection.
2924    view_revision: String,
2925    /// Use-time authority revision independently bound into writes/signatures.
2926    control_revision: String,
2927    identity: V2HeadIdentity,
2928    pointer: Option<V2PointerBody>,
2929    trust: TrustState,
2930    alias: Option<AliasBinding>,
2931}
2932
2933fn verify_v2_spki_signature(
2934    public_key: &str,
2935    message: &[u8],
2936    signature: &str,
2937) -> LinkResult<Vec<u8>> {
2938    let der = URL_SAFE_NO_PAD
2939        .decode(public_key)
2940        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2941    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2942        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2943    }
2944    let sig = URL_SAFE_NO_PAD
2945        .decode(signature)
2946        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2947    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2948        .verify(message, &sig)
2949        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2950    Ok(der)
2951}
2952
2953fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2954    if pointer.pointer.v != 2
2955        || pointer.pointer.brain != expected_brain
2956        || pointer.pointer.seq == 0
2957        || !is_sha256(&pointer.pointer.commit_hash)
2958        || !is_sha256(&pointer.pointer.feed_hash)
2959        || pointer
2960            .pointer
2961            .content_root
2962            .as_deref()
2963            .is_some_and(|hash| !is_sha256(hash))
2964        || !is_sha256(&pointer.pointer.backup_preparation)
2965    {
2966        return Err(invalid_feed("v2 pointer fields are invalid"));
2967    }
2968    let value = serde_json::to_value(&pointer.pointer)
2969        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2970    let message = crate::linkmd_v2::canonical_bytes(&value)
2971        .map_err(|error| invalid_feed(error.to_string()))?;
2972    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2973    let fingerprint = format!("{:x}", Sha256::digest(&der));
2974    if fingerprint != pointer.hub_fingerprint {
2975        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2976    }
2977    Ok(format!(
2978        "{}:{}",
2979        pointer.hub_fingerprint, pointer.hub_public_key
2980    ))
2981}
2982
2983fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2984    FeedIdentity {
2985        fingerprint: identity.fingerprint.clone(),
2986        public_key_spki: identity.public_key_spki.clone(),
2987        previous: identity
2988            .previous
2989            .iter()
2990            .map(|previous| PreviousIdentity {
2991                fingerprint: previous.fingerprint.clone(),
2992                public_key_spki: previous.public_key_spki.clone(),
2993            })
2994            .collect(),
2995        rotations: identity.rotations.clone(),
2996    }
2997}
2998
2999fn verified_v2_commit_object(
3000    raw: &[u8],
3001    identity: &V2HeadIdentity,
3002) -> LinkResult<serde_json::Map<String, Value>> {
3003    let mut value: Value =
3004        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
3005    let canonical = crate::linkmd_v2::canonical_bytes(&value)
3006        .map_err(|error| invalid_feed(error.to_string()))?;
3007    if canonical != raw {
3008        return Err(invalid_feed("v2 commit is not canonical JSON"));
3009    }
3010    let object = value
3011        .as_object_mut()
3012        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
3013    let sig = object
3014        .remove("sig")
3015        .and_then(|value| value.as_str().map(str::to_string))
3016        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
3017    const FIELDS: [&str; 18] = [
3018        "actor_ref",
3019        "asset_root",
3020        "brain",
3021        "changes_sha256",
3022        "control_revision",
3023        "materializer",
3024        "op",
3025        "parent_asset_root",
3026        "parent_commit",
3027        "parent_root",
3028        "prev_entry_hash",
3029        "public_key",
3030        "seq",
3031        "signer_epoch",
3032        "state_root",
3033        "ts",
3034        "v",
3035        "v1_bridge",
3036    ];
3037    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
3038        return Err(invalid_feed("v2 commit has a non-normative field set"));
3039    }
3040    let seq = object
3041        .get("seq")
3042        .and_then(Value::as_u64)
3043        .filter(|seq| *seq > 0)
3044        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
3045    let signer_epoch = object
3046        .get("signer_epoch")
3047        .and_then(Value::as_u64)
3048        .filter(|epoch| *epoch > 0)
3049        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
3050    let hash_or_null = |field: &str| {
3051        object
3052            .get(field)
3053            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
3054    };
3055    if object.get("v").and_then(Value::as_u64) != Some(2)
3056        || object.get("op").and_then(Value::as_str) != Some("changeset")
3057        || !object
3058            .get("changes_sha256")
3059            .and_then(Value::as_str)
3060            .is_some_and(is_sha256)
3061        || !object
3062            .get("actor_ref")
3063            .and_then(Value::as_str)
3064            .is_some_and(is_sha256)
3065        || !object
3066            .get("control_revision")
3067            .and_then(Value::as_str)
3068            .is_some_and(is_sha256)
3069        || !object
3070            .get("state_root")
3071            .and_then(Value::as_str)
3072            .is_some_and(is_sha256)
3073        || !hash_or_null("parent_commit")
3074        || !hash_or_null("parent_root")
3075        || !hash_or_null("parent_asset_root")
3076        || !hash_or_null("asset_root")
3077        || !hash_or_null("prev_entry_hash")
3078        || !object
3079            .get("materializer")
3080            .and_then(Value::as_str)
3081            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
3082        || !object
3083            .get("ts")
3084            .and_then(Value::as_str)
3085            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3086    {
3087        return Err(invalid_feed("v2 commit fields are invalid"));
3088    }
3089    if (seq == 1
3090        && [
3091            "parent_commit",
3092            "parent_root",
3093            "parent_asset_root",
3094            "prev_entry_hash",
3095        ]
3096        .iter()
3097        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3098        || (seq > 1
3099            && ["parent_commit", "parent_root", "prev_entry_hash"]
3100                .iter()
3101                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3102    {
3103        return Err(invalid_feed("v2 commit parent shape is invalid"));
3104    }
3105    match object.get("v1_bridge") {
3106        Some(Value::Null) => {}
3107        Some(Value::Object(bridge))
3108            if seq == 1
3109                && bridge.len() == 3
3110                && bridge
3111                    .get("head_seq")
3112                    .and_then(Value::as_u64)
3113                    .is_some_and(|v| v > 0)
3114                && bridge
3115                    .get("feed_hash")
3116                    .and_then(Value::as_str)
3117                    .is_some_and(is_sha256)
3118                && bridge
3119                    .get("pack_sha256")
3120                    .and_then(Value::as_str)
3121                    .is_some_and(is_sha256) => {}
3122        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3123    }
3124    let public_key = object
3125        .get("public_key")
3126        .and_then(Value::as_str)
3127        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3128    let der = URL_SAFE_NO_PAD
3129        .decode(public_key)
3130        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3131    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3132    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3133        return Err(invalid_feed("v2 commit brain identity mismatch"));
3134    }
3135    // Verify the old-key-signed chain before trusting any prior public key.
3136    verify_identity_chain(&v2_identity(identity), None)?;
3137    // Old commits remain verifiable after brain-key rotation. `previous` is
3138    // newest-first, while signer epochs and rotation statements are oldest-first.
3139    let mut chain: Vec<(&str, &str)> = identity
3140        .previous
3141        .iter()
3142        .rev()
3143        .map(|previous| {
3144            (
3145                previous.fingerprint.as_str(),
3146                previous.public_key_spki.as_str(),
3147            )
3148        })
3149        .collect();
3150    chain.push((&identity.fingerprint, &identity.public_key_spki));
3151    let signer_index = chain.iter().position(|(fingerprint, spki)| {
3152        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3153    });
3154    let Some(signer_index) = signer_index else {
3155        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3156    };
3157    if signer_epoch != signer_index as u64 + 1 {
3158        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3159    }
3160    let lower_boundary = if signer_index == 0 {
3161        None
3162    } else {
3163        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3164            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3165        Some(prior.prior_head_seq)
3166    };
3167    let upper_boundary = if signer_index == identity.rotations.len() {
3168        None
3169    } else {
3170        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3171            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3172        Some(next.prior_head_seq)
3173    };
3174    if lower_boundary.is_some_and(|boundary| seq <= boundary)
3175        || upper_boundary.is_some_and(|boundary| seq > boundary)
3176    {
3177        return Err(invalid_feed(
3178            "v2 commit signer is outside its authenticated rotation epoch",
3179        ));
3180    }
3181    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3182        .map_err(|error| invalid_feed(error.to_string()))?;
3183    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3184    Ok(object.clone())
3185}
3186
3187#[derive(Debug, Deserialize)]
3188struct V2FeedWireEntry {
3189    seq: u64,
3190    commit_hash: String,
3191    feed_hash: String,
3192    bytes_base64: String,
3193}
3194
3195#[derive(Debug, Deserialize)]
3196struct V2FeedPage {
3197    v: u8,
3198    head_seq: u64,
3199    head_commit_hash: String,
3200    head_feed_hash: String,
3201    entries: Vec<V2FeedWireEntry>,
3202    next_after: u64,
3203    complete: bool,
3204}
3205
3206fn replay_v2_feed(
3207    cfg: &HubConfig,
3208    brain: &str,
3209    pointer: &V2PointerBody,
3210    identity: &V2HeadIdentity,
3211    start_after: u64,
3212    start_feed: Option<String>,
3213) -> LinkResult<()> {
3214    let mut after = start_after;
3215    let mut prior_feed = start_feed;
3216    let mut final_object = None;
3217    let mut replayed_entries = 0_u64;
3218    let mut replayed_bytes = 0_u64;
3219    while after < pointer.seq {
3220        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3221        let value = ensure_ok(
3222            request_capped(
3223                cfg,
3224                "GET",
3225                &path,
3226                None,
3227                Auth::Required,
3228                MAX_FEED_REPLAY_BYTES,
3229            )?,
3230            "v2 feed replay",
3231        )?;
3232        let page: V2FeedPage = serde_json::from_value(value)
3233            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3234        if page.v != 2
3235            || page.head_seq != pointer.seq
3236            || page.head_commit_hash != pointer.commit_hash
3237            || page.head_feed_hash != pointer.feed_hash
3238            || page.entries.is_empty()
3239            || page.entries.len() > FEED_PAGE_LIMIT
3240        {
3241            return Err(invalid_feed("v2 feed page differs from the signed head"));
3242        }
3243        for entry in page.entries {
3244            if entry.seq != after + 1
3245                || !is_sha256(&entry.commit_hash)
3246                || !is_sha256(&entry.feed_hash)
3247            {
3248                return Err(invalid_feed("v2 feed sequence is not contiguous"));
3249            }
3250            let raw = base64::engine::general_purpose::STANDARD
3251                .decode(&entry.bytes_base64)
3252                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3253            replayed_entries = replayed_entries
3254                .checked_add(1)
3255                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3256            replayed_bytes = replayed_bytes
3257                .checked_add(raw.len() as u64)
3258                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3259            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3260            {
3261                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3262            }
3263            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3264                .map_err(|error| invalid_feed(error.to_string()))?
3265                != entry.commit_hash
3266                || content_sha256(&raw) != entry.feed_hash
3267            {
3268                return Err(invalid_feed("v2 feed entry address mismatch"));
3269            }
3270            let object = verified_v2_commit_object(&raw, identity)?;
3271            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3272                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3273            {
3274                return Err(invalid_feed(
3275                    "v2 feed entry does not extend its predecessor",
3276                ));
3277            }
3278            after = entry.seq;
3279            prior_feed = Some(entry.feed_hash);
3280            final_object = Some((entry.commit_hash, object));
3281        }
3282        if page.next_after != after || (page.complete != (after == pointer.seq)) {
3283            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3284        }
3285    }
3286    let (final_hash, object) =
3287        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3288    if final_hash != pointer.commit_hash
3289        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3290        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3291        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3292        || object.get("control_revision").and_then(Value::as_str)
3293            != Some(pointer.control_revision.as_str())
3294        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3295    {
3296        return Err(invalid_feed(
3297            "v2 replay did not converge on the signed pointer",
3298        ));
3299    }
3300    Ok(())
3301}
3302
3303fn verify_v1_to_v2_bridge(
3304    cfg: &HubConfig,
3305    brain: &str,
3306    pointer: &V2PointerBody,
3307    identity: &V2HeadIdentity,
3308    checkpoint: &TrustState,
3309) -> LinkResult<()> {
3310    let value = ensure_ok(
3311        request_capped(
3312            cfg,
3313            "GET",
3314            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3315            None,
3316            Auth::Required,
3317            MAX_FEED_RESPONSE_BYTES,
3318        )?,
3319        "v2 genesis bridge",
3320    )?;
3321    let page: V2FeedPage = serde_json::from_value(value)
3322        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3323    if page.v != 2
3324        || page.head_seq != pointer.seq
3325        || page.head_commit_hash != pointer.commit_hash
3326        || page.head_feed_hash != pointer.feed_hash
3327        || page.entries.len() != 1
3328        || page.entries[0].seq != 1
3329        || !is_sha256(&page.entries[0].commit_hash)
3330        || !is_sha256(&page.entries[0].feed_hash)
3331    {
3332        return Err(invalid_feed(
3333            "v2 genesis bridge page differs from the signed head",
3334        ));
3335    }
3336    let first = &page.entries[0];
3337    let raw = STANDARD
3338        .decode(&first.bytes_base64)
3339        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3340    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3341        .map_err(|error| invalid_feed(error.to_string()))?
3342        != first.commit_hash
3343        || content_sha256(&raw) != first.feed_hash
3344    {
3345        return Err(invalid_feed("v2 genesis bridge address mismatch"));
3346    }
3347    let object = verified_v2_commit_object(&raw, identity)?;
3348    if checkpoint.head_seq == 0 {
3349        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3350            return Err(invalid_feed(
3351                "empty v1 checkpoint did not transition through an empty v2 genesis",
3352            ));
3353        }
3354        return Ok(());
3355    }
3356    let bridge = object
3357        .get("v1_bridge")
3358        .and_then(Value::as_object)
3359        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3360    let checkpoint_feed = checkpoint
3361        .feed_hash
3362        .as_deref()
3363        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3364    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3365        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3366    {
3367        return Err(invalid_feed(
3368            "v2 genesis bridge differs from the pinned v1 checkpoint",
3369        ));
3370    }
3371    let legacy_raw = ensure_raw_ok(
3372        request_raw(
3373            cfg,
3374            "GET",
3375            &format!(
3376                "/api/hub/brains/{brain}/feed?after={}&limit=1",
3377                checkpoint.head_seq - 1
3378            ),
3379            None,
3380            Auth::Required,
3381            MAX_FEED_RESPONSE_BYTES,
3382        )?,
3383        "v1 bridge boundary",
3384    )?;
3385    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3386        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3387    let legacy_identity = legacy
3388        .identity
3389        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3390    let item = legacy
3391        .entries
3392        .first()
3393        .filter(|_| legacy.entries.len() == 1)
3394        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3395    if legacy.scope_limited
3396        || legacy.head_seq != checkpoint.head_seq
3397        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3398        || item.entry.seq != checkpoint.head_seq
3399        || item.hash != checkpoint_feed
3400        || legacy_identity != v2_identity(identity)
3401        || bridge.get("pack_sha256").and_then(Value::as_str)
3402            != Some(item.entry.pack_sha256.as_str())
3403    {
3404        return Err(invalid_feed(
3405            "v1 bridge boundary differs from its signed legacy head",
3406        ));
3407    }
3408    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3409    if anchor != checkpoint.anchor {
3410        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3411    }
3412    verify_feed_item(item, &legacy_identity)?;
3413    verify_rotation_feed_boundaries(
3414        &legacy_identity,
3415        Some(checkpoint),
3416        std::slice::from_ref(item),
3417        checkpoint.head_seq,
3418    )?;
3419    Ok(())
3420}
3421
3422fn verify_v2_commit(
3423    cfg: &HubConfig,
3424    brain: &str,
3425    pointer: &V2PointerBody,
3426    identity: &V2HeadIdentity,
3427    pinned: Option<&TrustState>,
3428) -> LinkResult<()> {
3429    let path = format!(
3430        "/api/hub/brains/{brain}/v2/commit?commit={}",
3431        pointer.commit_hash
3432    );
3433    let raw = ensure_raw_ok(
3434        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3435        "v2 commit",
3436    )?;
3437    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3438        .map_err(|error| invalid_feed(error.to_string()))?
3439        != pointer.commit_hash
3440        || content_sha256(&raw) != pointer.feed_hash
3441    {
3442        return Err(invalid_feed("v2 commit address differs from the pointer"));
3443    }
3444    let object = verified_v2_commit_object(&raw, identity)?;
3445    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3446        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3447        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3448        || object.get("control_revision").and_then(Value::as_str)
3449            != Some(pointer.control_revision.as_str())
3450        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3451    {
3452        return Err(invalid_feed("v2 commit fields differ from the pointer"));
3453    }
3454    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3455        if pointer.seq == checkpoint.head_seq + 1
3456            && object.get("prev_entry_hash").and_then(Value::as_str)
3457                != checkpoint.feed_hash.as_deref()
3458        {
3459            return Err(invalid_feed(
3460                "v2 commit does not extend the pinned feed hash",
3461            ));
3462        }
3463        if pointer.seq > checkpoint.head_seq + 1 {
3464            return replay_v2_feed(
3465                cfg,
3466                brain,
3467                pointer,
3468                identity,
3469                checkpoint.head_seq,
3470                checkpoint.feed_hash.clone(),
3471            );
3472        }
3473    } else {
3474        if let Some(checkpoint) = pinned {
3475            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3476        }
3477        if pointer.seq > 1 {
3478            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3479        }
3480    }
3481    Ok(())
3482}
3483
3484fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3485    require_hardened_filesystem("verified link.md v2 state")?;
3486    require_safe_ref(brain)?;
3487    // Open and pin the private trust directory before a credential can leave
3488    // the process. A hostile checkout must not turn an unsafe state root into
3489    // a network oracle or receive a bearer before checkpoint safety is known.
3490    let trust_directory = open_trust_dir(cfg)?;
3491    let path = format!("/api/hub/brains/{brain}/v2/head");
3492    let response = request(cfg, "GET", &path, None, Auth::Required)?;
3493    if response.status == 404 {
3494        if has_accepted_v2_ref(cfg, brain)? {
3495            return Err(LinkError::BrainUnavailable);
3496        }
3497        return Ok(None);
3498    }
3499    let body = ensure_ok(response, "v2 head")?;
3500    let head: V2HeadResponse = serde_json::from_value(body)
3501        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3502    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3503        return Err(invalid_feed("v2 head has no canonical brain id"));
3504    }
3505    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3506        return Err(invalid_feed("v2 head resolved a different brain id"));
3507    }
3508    if head.profile == "v1" {
3509        return Ok(None);
3510    }
3511    if head.profile != "v2" && head.profile != "v2-empty" {
3512        return Err(invalid_feed("v2 head advertised an unknown profile"));
3513    }
3514    let view = head
3515        .view
3516        .as_ref()
3517        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3518    if !matches!(view.kind.as_str(), "full" | "scoped")
3519        || !is_sha256(&view.control_revision)
3520        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3521    {
3522        return Err(invalid_feed("v2 head has an invalid permission view"));
3523    }
3524    let view_kind = view.kind.clone();
3525    // `id` is the stable materialized namespace. Older v2 hubs did not expose
3526    // it, so retain the fail-closed control-revision fallback for compatibility.
3527    let view_revision = view
3528        .id
3529        .clone()
3530        .unwrap_or_else(|| view.control_revision.clone());
3531    let control_revision = view.control_revision.clone();
3532    let identity = head
3533        .identity
3534        .as_ref()
3535        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3536    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3537    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3538    let feed_identity = v2_identity(identity);
3539    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3540    let (seq, feed_hash, hub_signer) = match &head.pointer {
3541        None => {
3542            if head.profile != "v2-empty" {
3543                return Err(invalid_feed("initialized v2 head has no pointer"));
3544            }
3545            (
3546                0,
3547                None,
3548                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3549            )
3550        }
3551        Some(signed) => {
3552            let signer = verify_v2_pointer(signed, &head.brain_id)?;
3553            if pinned
3554                .as_ref()
3555                .and_then(|state| state.hub_signer.as_ref())
3556                .is_some_and(|known| known != &signer)
3557            {
3558                return Err(invalid_feed(
3559                    "v2 hub pointer signer changed without a trust transition",
3560                ));
3561            }
3562            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3563                if signed.pointer.seq < checkpoint.head_seq
3564                    || (signed.pointer.seq == checkpoint.head_seq
3565                        && checkpoint.feed_hash.as_deref()
3566                            != Some(signed.pointer.feed_hash.as_str()))
3567                {
3568                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3569                }
3570            }
3571            verify_v2_commit(
3572                cfg,
3573                &head.brain_id,
3574                &signed.pointer,
3575                identity,
3576                pinned.as_ref(),
3577            )?;
3578            (
3579                signed.pointer.seq,
3580                Some(signed.pointer.feed_hash.clone()),
3581                Some(signer),
3582            )
3583        }
3584    };
3585    let trust = TrustState {
3586        v: 2,
3587        origin: normalized_origin(&cfg.hub)?,
3588        requested: head.brain_id.clone(),
3589        brain: head.brain_id.clone(),
3590        home: None,
3591        anchor,
3592        current: format!("ed25519:{}", identity.fingerprint),
3593        head_seq: seq,
3594        feed_hash,
3595        rotations: identity.rotations.clone(),
3596        hub_signer,
3597        protocol_profile: Some("link-v2".to_string()),
3598    };
3599    Ok(Some(V2VerifiedHead {
3600        requested: brain.to_string(),
3601        brain_id: head.brain_id,
3602        view_kind,
3603        view_revision,
3604        control_revision,
3605        identity: identity.clone(),
3606        pointer: head.pointer.map(|signed| signed.pointer),
3607        trust,
3608        alias: alias_binding,
3609    }))
3610}
3611
3612fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3613    let directory = open_trust_dir(cfg)?;
3614    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3615    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3616    if let Some(current) = current {
3617        let common_invalid = head.trust.anchor != current.anchor
3618            || !head.trust.rotations.starts_with(&current.rotations);
3619        let profile_invalid = if accepted_as_v2(&current) {
3620            head.trust.head_seq < current.head_seq
3621                || (head.trust.head_seq == current.head_seq
3622                    && head.trust.feed_hash != current.feed_hash)
3623                || current
3624                    .hub_signer
3625                    .as_ref()
3626                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3627        } else {
3628            head.trust.protocol_profile.as_deref() != Some("link-v2")
3629                || head.trust.hub_signer.is_none()
3630        };
3631        if common_invalid || profile_invalid {
3632            return Err(invalid_feed(
3633                "v2 head cannot advance the currently accepted trust checkpoint",
3634            ));
3635        }
3636    }
3637    save_canonical_pin_and_alias(
3638        cfg,
3639        &directory,
3640        &head.requested,
3641        &head.brain_id,
3642        head.trust.clone(),
3643        alias.as_ref().or(head.alias.as_ref()),
3644    )
3645}
3646
3647#[derive(Debug, Clone, Deserialize, Serialize)]
3648struct V2BaselineFile {
3649    sha256: String,
3650    bytes: u64,
3651    #[serde(skip)]
3652    proof: Option<Vec<V2ProofStep>>,
3653}
3654
3655#[derive(Debug, Clone, Deserialize, Serialize)]
3656struct V2SyncBaseline {
3657    v: u8,
3658    origin: String,
3659    brain: String,
3660    #[serde(default)]
3661    checkout_id: Option<String>,
3662    #[serde(default)]
3663    head_seq: Option<u64>,
3664    commit_hash: Option<String>,
3665    content_root: Option<String>,
3666    #[serde(default)]
3667    asset_root: Option<String>,
3668    #[serde(default)]
3669    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3670    #[serde(default)]
3671    view_kind: Option<String>,
3672    #[serde(default)]
3673    view_revision: Option<String>,
3674    /// The authority revision observed with this materialized view. Older
3675    /// baselines omit it and deliberately take one full verified refresh
3676    /// before they become eligible for unchanged-head reuse.
3677    #[serde(default)]
3678    control_revision: Option<String>,
3679    #[serde(default)]
3680    projection_sha256: Option<String>,
3681    files: std::collections::BTreeMap<String, V2BaselineFile>,
3682    #[serde(default)]
3683    local_policy_digest: Option<String>,
3684    #[serde(default)]
3685    local_eligibility: std::collections::BTreeMap<String, bool>,
3686    #[serde(default)]
3687    remote_copy_remains: std::collections::BTreeMap<String, String>,
3688}
3689
3690struct V2LocalView {
3691    riding: std::collections::BTreeMap<String, (String, u64)>,
3692    eligibility: std::collections::BTreeMap<String, bool>,
3693    policy: crate::linkmd_sync_policy::SyncPolicy,
3694    withheld_links: Vec<V2WithheldLink>,
3695}
3696
3697#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3698struct V2WithheldLink {
3699    source: String,
3700    target: String,
3701}
3702
3703#[derive(Debug, Clone, Deserialize, Serialize)]
3704struct V2ProofStep {
3705    directory_root: String,
3706    component: String,
3707    proof: crate::linkmd_v2::HamtProof,
3708}
3709
3710#[derive(Debug, Deserialize)]
3711struct V2ManifestFile {
3712    path: String,
3713    sha256: String,
3714    bytes: u64,
3715    proof: Vec<V2ProofStep>,
3716}
3717
3718#[derive(Debug, Deserialize)]
3719struct V2ManifestPage {
3720    v: u8,
3721    commit: String,
3722    content_root: Option<String>,
3723    files: Vec<V2ManifestFile>,
3724    next_cursor: Option<String>,
3725}
3726
3727#[derive(Debug, Clone, Deserialize, Serialize)]
3728struct V2BaselineAsset {
3729    blob_sha256: String,
3730    bytes: u64,
3731    media_type: String,
3732    wrappers: Vec<String>,
3733    required: bool,
3734    disposition: String,
3735    leaf_hash: String,
3736}
3737
3738#[derive(Debug, Deserialize)]
3739struct V2AssetManifestItem {
3740    path: String,
3741    blob_sha256: String,
3742    bytes: u64,
3743    media_type: String,
3744    wrappers: Vec<String>,
3745    required: bool,
3746    disposition: String,
3747    leaf_hash: String,
3748    proof: crate::linkmd_v2::HamtProof,
3749}
3750
3751#[derive(Debug, Deserialize)]
3752struct V2AssetManifestPage {
3753    v: u8,
3754    commit: String,
3755    asset_root: Option<String>,
3756    assets: Vec<V2AssetManifestItem>,
3757    next_cursor: Option<String>,
3758}
3759
3760#[derive(Debug, Deserialize)]
3761struct V2SigningCandidate {
3762    seq: u64,
3763    content_root: Option<String>,
3764    asset_root: Option<String>,
3765    signing_bytes_base64: String,
3766    changes_base64: String,
3767    actor_claim_base64: String,
3768}
3769
3770#[derive(Debug, Deserialize)]
3771struct V2SigningCandidatePage {
3772    v: u8,
3773    challenge_id: String,
3774    mutation_id: String,
3775    request_hash: String,
3776    parent: V2SigningParent,
3777    candidate: V2SigningCandidate,
3778    files: Vec<V2ManifestFile>,
3779    #[serde(default)]
3780    assets: Vec<V2AssetManifestItem>,
3781    next_cursor: Option<String>,
3782    expires_at: String,
3783}
3784
3785#[derive(Debug, Deserialize)]
3786struct V2SigningParent {
3787    seq: u64,
3788    commit_hash: Option<String>,
3789}
3790
3791fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3792    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3793        .map_err(|error| invalid_feed(error.to_string()))?;
3794    let components = normalized.split('/').collect::<Vec<_>>();
3795    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3796        return Err(invalid_feed("v2 file proof has the wrong shape"));
3797    }
3798    let mut directory_root = root.to_string();
3799    for (index, step) in file.proof.iter().enumerate() {
3800        if step.directory_root != directory_root || step.component != components[index] {
3801            return Err(invalid_feed(
3802                "v2 file proof path chain differs from its manifest",
3803            ));
3804        }
3805        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3806            .map_err(|error| invalid_feed(error.to_string()))?
3807        {
3808            return Err(invalid_feed("v2 file proof failed verification"));
3809        }
3810        let entry = match &step.proof {
3811            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3812            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3813                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3814            }
3815        };
3816        if index + 1 == components.len() {
3817            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3818                || entry.child_hash != file.sha256
3819                || entry.bytes != Some(file.bytes)
3820            {
3821                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3822            }
3823        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3824            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3825        } else {
3826            directory_root = entry.child_hash.clone();
3827        }
3828    }
3829    Ok(())
3830}
3831
3832fn v2_manifest(
3833    cfg: &HubConfig,
3834    brain: &str,
3835    pointer: Option<&V2PointerBody>,
3836) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3837    let Some(pointer) = pointer else {
3838        return Ok(std::collections::BTreeMap::new());
3839    };
3840    let Some(root) = pointer.content_root.as_deref() else {
3841        return Ok(std::collections::BTreeMap::new());
3842    };
3843    let mut files = std::collections::BTreeMap::new();
3844    let mut after = String::new();
3845    loop {
3846        let encoded_after: String =
3847            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3848        let path = format!(
3849            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3850            pointer.commit_hash
3851        );
3852        let value = ensure_ok(
3853            request_capped(
3854                cfg,
3855                "GET",
3856                &path,
3857                None,
3858                Auth::Required,
3859                MAX_FEED_RESPONSE_BYTES,
3860            )?,
3861            "v2 file manifest",
3862        )?;
3863        let page: V2ManifestPage = serde_json::from_value(value)
3864            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3865        if page.v != 2
3866            || page.commit != pointer.commit_hash
3867            || page.content_root.as_deref() != Some(root)
3868            || page.files.len() > 500
3869        {
3870            return Err(invalid_feed(
3871                "v2 file manifest is not bound to the verified head",
3872            ));
3873        }
3874        for file in page.files {
3875            verify_v2_file_proof(root, &file)?;
3876            if files
3877                .insert(
3878                    file.path.clone(),
3879                    V2BaselineFile {
3880                        sha256: file.sha256,
3881                        bytes: file.bytes,
3882                        proof: Some(file.proof),
3883                    },
3884                )
3885                .is_some()
3886            {
3887                return Err(invalid_feed("v2 file manifest repeats a path"));
3888            }
3889            if files.len() > MAX_PUSH_FILES {
3890                return Err(invalid_feed(
3891                    "v2 file manifest exceeds the file-count bound",
3892                ));
3893            }
3894        }
3895        match page.next_cursor {
3896            None => break,
3897            Some(next) if next > after => after = next,
3898            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3899        }
3900    }
3901    Ok(files)
3902}
3903
3904/// Fetch one exact path proof instead of enumerating the whole permission
3905/// view. This keeps `resolve @brain/path` proportional to path depth even for
3906/// a very large brain. The returned leaf is accepted only after its complete
3907/// proof chain reaches the content root signed by `pointer`.
3908fn v2_manifest_file(
3909    cfg: &HubConfig,
3910    brain: &str,
3911    pointer: &V2PointerBody,
3912    path: &str,
3913) -> LinkResult<Option<V2BaselineFile>> {
3914    let Some(root) = pointer.content_root.as_deref() else {
3915        return Ok(None);
3916    };
3917    crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3918        path: error.to_string(),
3919    })?;
3920    let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3921    let response = request_capped(
3922        cfg,
3923        "GET",
3924        &format!(
3925            "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3926            pointer.commit_hash
3927        ),
3928        None,
3929        Auth::Required,
3930        MAX_FEED_RESPONSE_BYTES,
3931    )?;
3932    // Exact v2 file reads deliberately collapse absent and unreadable paths
3933    // into the same 404. Preserve that non-oracle as a normal `None`; callers
3934    // such as `resolve` then produce the stable, local `NOT_FOUND` contract.
3935    if response.status == 404 {
3936        return Ok(None);
3937    }
3938    let value = ensure_ok(response, "v2 exact file proof")?;
3939    let mut page: V2ManifestPage = serde_json::from_value(value)
3940        .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3941    if page.v != 2
3942        || page.commit != pointer.commit_hash
3943        || page.content_root.as_deref() != Some(root)
3944        || page.next_cursor.is_some()
3945        || page.files.len() != 1
3946        || page.files[0].path != path
3947    {
3948        return Err(invalid_feed(
3949            "v2 exact file proof is not bound to the requested signed path",
3950        ));
3951    }
3952    let file = page.files.pop().expect("exactly one file was checked");
3953    verify_v2_file_proof(root, &file)?;
3954    Ok(Some(V2BaselineFile {
3955        sha256: file.sha256,
3956        bytes: file.bytes,
3957        proof: Some(file.proof),
3958    }))
3959}
3960
3961/// Resolve an id through the hub's synchronously maintained, content-light
3962/// validation certificate, then require the same exact signed path proof as a
3963/// path lookup. The certificate is only an efficient locator: the downloaded
3964/// bytes are parsed again and their id is checked by the caller.
3965fn v2_manifest_file_by_id(
3966    cfg: &HubConfig,
3967    brain: &str,
3968    pointer: &V2PointerBody,
3969    id: &str,
3970) -> LinkResult<(String, V2BaselineFile)> {
3971    let root = pointer
3972        .content_root
3973        .as_deref()
3974        .ok_or_else(|| LinkError::Http {
3975            what: "resolve",
3976            status: 404,
3977            message: "record not found".to_string(),
3978            code: Some("NOT_FOUND".to_string()),
3979            details: None,
3980        })?;
3981    if !crate::ulid::is_ulid(id) {
3982        return Err(LinkError::BadAddress {
3983            given: id.to_string(),
3984            reason: BAD_TARGET_REASON.to_string(),
3985        });
3986    }
3987    let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3988    let value = ensure_ok(
3989        request_capped(
3990            cfg,
3991            "GET",
3992            &format!(
3993                "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3994                pointer.commit_hash
3995            ),
3996            None,
3997            Auth::Required,
3998            MAX_FEED_RESPONSE_BYTES,
3999        )?,
4000        "v2 exact id proof",
4001    )?;
4002    let mut page: V2ManifestPage = serde_json::from_value(value)
4003        .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
4004    if page.v != 2
4005        || page.commit != pointer.commit_hash
4006        || page.content_root.as_deref() != Some(root)
4007        || page.next_cursor.is_some()
4008        || page.files.len() != 1
4009    {
4010        return Err(invalid_feed(
4011            "v2 exact id proof is not bound to one signed path",
4012        ));
4013    }
4014    let file = page.files.pop().expect("exactly one file was checked");
4015    if !safe_store_rel_path(&file.path)
4016        || !file.path.ends_with(".md")
4017        || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4018    {
4019        return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4020    }
4021    verify_v2_file_proof(root, &file)?;
4022    Ok((
4023        file.path,
4024        V2BaselineFile {
4025            sha256: file.sha256,
4026            bytes: file.bytes,
4027            proof: Some(file.proof),
4028        },
4029    ))
4030}
4031
4032fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4033    crate::linkmd_v2::normalize_path(&item.path)
4034        .map_err(|error| invalid_feed(error.to_string()))?;
4035    if !is_sha256(&item.blob_sha256)
4036        || !is_sha256(&item.leaf_hash)
4037        || item.bytes > MAX_ASSET_BYTES
4038        || item.wrappers.is_empty()
4039        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4040        || item
4041            .wrappers
4042            .iter()
4043            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4044    {
4045        return Err(invalid_feed("v2 asset manifest item is invalid"));
4046    }
4047    let leaf = json!({
4048        "blob_sha256": item.blob_sha256,
4049        "bytes": item.bytes,
4050        "disposition": item.disposition,
4051        "media_type": item.media_type,
4052        "path": item.path,
4053        "required": item.required,
4054        "v": 2,
4055        "wrappers": item.wrappers,
4056    });
4057    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4058        .map_err(|error| invalid_feed(error.to_string()))?
4059        != item.leaf_hash
4060        || !crate::linkmd_v2::verify_proof_with_domain(
4061            root,
4062            &item.path,
4063            &item.proof,
4064            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4065        )
4066        .map_err(|error| invalid_feed(error.to_string()))?
4067    {
4068        return Err(invalid_feed("v2 asset inclusion proof failed"));
4069    }
4070    match &item.proof {
4071        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4072            if entry.name == item.path
4073                && entry.kind == crate::linkmd_v2::EntryKind::Blob
4074                && entry.child_hash == item.leaf_hash
4075                && entry.bytes == Some(item.bytes) =>
4076        {
4077            Ok(())
4078        }
4079        _ => Err(invalid_feed(
4080            "v2 asset proof leaf differs from its manifest",
4081        )),
4082    }
4083}
4084
4085fn v2_asset_manifest(
4086    cfg: &HubConfig,
4087    brain: &str,
4088    pointer: Option<&V2PointerBody>,
4089) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4090    let Some(pointer) = pointer else {
4091        return Ok(std::collections::BTreeMap::new());
4092    };
4093    let Some(root) = pointer.asset_root.as_deref() else {
4094        return Ok(std::collections::BTreeMap::new());
4095    };
4096    let mut assets = std::collections::BTreeMap::new();
4097    let mut after = String::new();
4098    loop {
4099        let encoded_after: String =
4100            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4101        let path = format!(
4102            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4103            pointer.commit_hash
4104        );
4105        let value = ensure_ok(
4106            request_capped(
4107                cfg,
4108                "GET",
4109                &path,
4110                None,
4111                Auth::Required,
4112                MAX_FEED_RESPONSE_BYTES,
4113            )?,
4114            "v2 asset manifest",
4115        )?;
4116        let page: V2AssetManifestPage = serde_json::from_value(value)
4117            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4118        if page.v != 2
4119            || page.commit != pointer.commit_hash
4120            || page.asset_root.as_deref() != Some(root)
4121            || page.assets.len() > 500
4122        {
4123            return Err(invalid_feed(
4124                "v2 asset manifest is not bound to the verified head",
4125            ));
4126        }
4127        for item in page.assets {
4128            verify_v2_asset_proof(root, &item)?;
4129            let path = item.path.clone();
4130            if assets
4131                .insert(
4132                    path,
4133                    V2BaselineAsset {
4134                        blob_sha256: item.blob_sha256,
4135                        bytes: item.bytes,
4136                        media_type: item.media_type,
4137                        wrappers: item.wrappers,
4138                        required: item.required,
4139                        disposition: item.disposition,
4140                        leaf_hash: item.leaf_hash,
4141                    },
4142                )
4143                .is_some()
4144            {
4145                return Err(invalid_feed("v2 asset manifest repeats a path"));
4146            }
4147            if assets.len() > MAX_PUSH_FILES {
4148                return Err(invalid_feed(
4149                    "v2 asset manifest exceeds the item-count bound",
4150                ));
4151            }
4152        }
4153        match page.next_cursor {
4154            None => break,
4155            Some(next) if next > after => after = next,
4156            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4157        }
4158    }
4159    Ok(assets)
4160}
4161
4162fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4163    crate::AssetRecord {
4164        path: path.to_string(),
4165        sha256: asset.blob_sha256.clone(),
4166        bytes: asset.bytes,
4167        media_type: asset.media_type.clone(),
4168        wrappers: asset.wrappers.clone(),
4169        required: asset.required,
4170    }
4171}
4172
4173fn v2_asset_resumes_hosting(
4174    remote: Option<&V2BaselineAsset>,
4175    path: &str,
4176    record: &crate::AssetRecord,
4177    disposition: &str,
4178) -> bool {
4179    remote.is_some_and(|asset| {
4180        asset.disposition == "withheld"
4181            && disposition == "hosted"
4182            && v2_asset_record(asset, path) == *record
4183    })
4184}
4185
4186fn v2_asset_inherits_withheld_absence(
4187    base: Option<&V2BaselineAsset>,
4188    base_record: Option<&crate::AssetRecord>,
4189    local_record: Option<&crate::AssetRecord>,
4190    raw_present: bool,
4191) -> bool {
4192    !raw_present
4193        && base.is_some_and(|asset| asset.disposition == "withheld")
4194        && local_record == base_record
4195}
4196
4197fn v2_asset_record_manifest_bytes(
4198    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4199) -> LinkResult<Vec<u8>> {
4200    let mut bytes = Vec::new();
4201    for (path, asset) in assets {
4202        if asset.path != *path {
4203            return Err(invalid_feed(
4204                "local asset manifest key differs from its record path",
4205            ));
4206        }
4207        serde_json::to_writer(&mut bytes, asset)
4208            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4209        bytes.push(b'\n');
4210    }
4211    Ok(bytes)
4212}
4213
4214fn v2_local_asset_records(
4215    store: &Store,
4216) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4217    let assets = crate::assets::read_manifest(store)
4218        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4219    if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4220        return Err(LinkError::InvalidPack {
4221            message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4222        });
4223    }
4224    Ok(assets
4225        .into_iter()
4226        .map(|asset| (asset.path.clone(), asset))
4227        .collect())
4228}
4229
4230fn v2_asset_records_match_remote(
4231    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4232    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4233) -> bool {
4234    local.len() == remote.len()
4235        && remote
4236            .iter()
4237            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4238}
4239
4240#[derive(Debug, Clone, PartialEq, Eq)]
4241struct V2PulledMerge<T> {
4242    records: std::collections::BTreeMap<String, T>,
4243    accept_remote: std::collections::BTreeSet<String>,
4244    conflicts: Vec<String>,
4245}
4246
4247/// Compute the local checkout that a pull may install without discarding a
4248/// local-only edit. Remote-only changes are accepted, identical concurrent
4249/// changes converge, and divergent concurrent changes are reported. The
4250/// caller can exempt kept-home coordinates from both remote installation and
4251/// conflict disclosure.
4252fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4253    base: &std::collections::BTreeMap<String, Base>,
4254    remote: &std::collections::BTreeMap<String, Remote>,
4255    local: &std::collections::BTreeMap<String, Record>,
4256    base_record: BaseRecord,
4257    remote_record: RemoteRecord,
4258    keep_local: KeepLocal,
4259) -> V2PulledMerge<Record>
4260where
4261    Record: Clone + Eq,
4262    BaseRecord: Fn(&Base, &str) -> Record,
4263    RemoteRecord: Fn(&Remote, &str) -> Record,
4264    KeepLocal: Fn(&str) -> bool,
4265{
4266    let paths = base
4267        .keys()
4268        .chain(remote.keys())
4269        .chain(local.keys())
4270        .cloned()
4271        .collect::<std::collections::BTreeSet<_>>();
4272    let mut records = local.clone();
4273    let mut accept_remote = std::collections::BTreeSet::new();
4274    let mut conflicts = Vec::new();
4275    for path in paths {
4276        if keep_local(&path) {
4277            continue;
4278        }
4279        let base_value = base.get(&path).map(|value| base_record(value, &path));
4280        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4281        let local_value = local.get(&path).cloned();
4282        if local_value != base_value && remote_value != base_value && local_value != remote_value {
4283            conflicts.push(path);
4284            continue;
4285        }
4286        if local_value == base_value || local_value == remote_value {
4287            accept_remote.insert(path.clone());
4288            match remote_value {
4289                Some(value) => {
4290                    records.insert(path, value);
4291                }
4292                None => {
4293                    records.remove(&path);
4294                }
4295            }
4296        }
4297    }
4298    V2PulledMerge {
4299        records,
4300        accept_remote,
4301        conflicts,
4302    }
4303}
4304
4305fn sign_verified_v2_candidate(
4306    cfg: &HubConfig,
4307    head: &V2VerifiedHead,
4308    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4309    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4310    mutation_id: &str,
4311    request_body: &Value,
4312    challenge_value: &Value,
4313) -> LinkResult<(String, String, String)> {
4314    if head.view_kind != "full" {
4315        return Err(invalid_feed(
4316            "a scoped self-custody writer must use the proposal workflow",
4317        ));
4318    }
4319    if head.identity.custody != "self" {
4320        return Err(invalid_feed(
4321            "a hub-custodied brain unexpectedly requested an external signature",
4322        ));
4323    }
4324    let key = cfg
4325        .brain_key
4326        .as_ref()
4327        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4328    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4329        || key.public_key_spki != head.identity.public_key_spki
4330    {
4331        return Err(bad_agent_key(
4332            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4333        ));
4334    }
4335    let challenge_id = challenge_value
4336        .get("id")
4337        .and_then(Value::as_str)
4338        .filter(|id| crate::ulid::is_ulid(id))
4339        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4340    let expected_endpoint = format!(
4341        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4342        head.brain_id
4343    );
4344    if challenge_value
4345        .get("candidate_endpoint")
4346        .and_then(Value::as_str)
4347        != Some(expected_endpoint.as_str())
4348    {
4349        return Err(invalid_feed(
4350            "self-custody challenge candidate endpoint is not origin-bound",
4351        ));
4352    }
4353
4354    let mut files = std::collections::BTreeMap::new();
4355    let mut after = String::new();
4356    type CandidateCoordinate = (
4357        String,
4358        String,
4359        String,
4360        String,
4361        Option<String>,
4362        Option<String>,
4363        u64,
4364        Option<String>,
4365    );
4366    let mut pinned: Option<CandidateCoordinate> = None;
4367    loop {
4368        let encoded_after: String =
4369            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4370        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4371        let value = ensure_ok(
4372            request_capped(
4373                cfg,
4374                "GET",
4375                &path,
4376                None,
4377                Auth::Required,
4378                MAX_FEED_RESPONSE_BYTES,
4379            )?,
4380            "v2 self-custody candidate",
4381        )?;
4382        let page: V2SigningCandidatePage = serde_json::from_value(value)
4383            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4384        if page.v != 2
4385            || page.challenge_id != challenge_id
4386            || page.mutation_id != mutation_id
4387            || page.candidate.seq != page.parent.seq + 1
4388            || page.files.len() > 500
4389            || page.expires_at.is_empty()
4390        {
4391            return Err(invalid_feed(
4392                "self-custody candidate is not bound to this mutation",
4393            ));
4394        }
4395        let coordinate = (
4396            page.request_hash.clone(),
4397            page.candidate.signing_bytes_base64.clone(),
4398            page.candidate.changes_base64.clone(),
4399            page.candidate.actor_claim_base64.clone(),
4400            page.candidate.content_root.clone(),
4401            page.candidate.asset_root.clone(),
4402            page.parent.seq,
4403            page.parent.commit_hash.clone(),
4404        );
4405        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4406            return Err(invalid_feed(
4407                "self-custody candidate changed between manifest pages",
4408            ));
4409        }
4410        pinned = Some(coordinate);
4411        let root = page
4412            .candidate
4413            .content_root
4414            .as_deref()
4415            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4416        for file in page.files {
4417            verify_v2_file_proof(root, &file)?;
4418            if files
4419                .insert(
4420                    file.path.clone(),
4421                    V2BaselineFile {
4422                        sha256: file.sha256,
4423                        bytes: file.bytes,
4424                        proof: Some(file.proof),
4425                    },
4426                )
4427                .is_some()
4428            {
4429                return Err(invalid_feed(
4430                    "self-custody candidate repeats a manifest path",
4431                ));
4432            }
4433            if files.len() > MAX_PUSH_FILES {
4434                return Err(invalid_feed(
4435                    "self-custody candidate exceeds the file-count bound",
4436                ));
4437            }
4438        }
4439        match page.next_cursor {
4440            None => break,
4441            Some(next) if next > after => after = next,
4442            Some(_) => {
4443                return Err(invalid_feed(
4444                    "self-custody candidate cursor did not advance",
4445                ))
4446            }
4447        }
4448    }
4449    if files.len() != expected.len()
4450        || files.iter().any(|(path, file)| {
4451            expected.get(path).is_none_or(|expected| {
4452                expected.sha256 != file.sha256 || expected.bytes != file.bytes
4453            })
4454        })
4455    {
4456        return Err(invalid_feed(
4457            "self-custody candidate contains an unexpected file mutation",
4458        ));
4459    }
4460    let mut assets = std::collections::BTreeMap::new();
4461    after.clear();
4462    loop {
4463        let encoded_after: String =
4464            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4465        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4466        let value = ensure_ok(
4467            request_capped(
4468                cfg,
4469                "GET",
4470                &path,
4471                None,
4472                Auth::Required,
4473                MAX_FEED_RESPONSE_BYTES,
4474            )?,
4475            "v2 self-custody asset candidate",
4476        )?;
4477        let page: V2SigningCandidatePage = serde_json::from_value(value)
4478            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4479        let coordinate = (
4480            page.request_hash.clone(),
4481            page.candidate.signing_bytes_base64.clone(),
4482            page.candidate.changes_base64.clone(),
4483            page.candidate.actor_claim_base64.clone(),
4484            page.candidate.content_root.clone(),
4485            page.candidate.asset_root.clone(),
4486            page.parent.seq,
4487            page.parent.commit_hash.clone(),
4488        );
4489        if page.v != 2
4490            || page.challenge_id != challenge_id
4491            || page.mutation_id != mutation_id
4492            || page.assets.len() > 500
4493            || pinned.as_ref() != Some(&coordinate)
4494        {
4495            return Err(invalid_feed(
4496                "self-custody asset candidate changed or is not bound",
4497            ));
4498        }
4499        let root = page.candidate.asset_root.as_deref();
4500        if !page.assets.is_empty() && root.is_none() {
4501            return Err(invalid_feed("asset candidate has no asset root"));
4502        }
4503        for item in page.assets {
4504            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4505            if assets
4506                .insert(
4507                    item.path.clone(),
4508                    V2BaselineAsset {
4509                        blob_sha256: item.blob_sha256,
4510                        bytes: item.bytes,
4511                        media_type: item.media_type,
4512                        wrappers: item.wrappers,
4513                        required: item.required,
4514                        disposition: item.disposition,
4515                        leaf_hash: item.leaf_hash,
4516                    },
4517                )
4518                .is_some()
4519            {
4520                return Err(invalid_feed("self-custody candidate repeats an asset"));
4521            }
4522        }
4523        match page.next_cursor {
4524            None => break,
4525            Some(next) if next > after => after = next,
4526            Some(_) => {
4527                return Err(invalid_feed(
4528                    "self-custody asset candidate cursor did not advance",
4529                ))
4530            }
4531        }
4532    }
4533    if assets.len() != expected_assets.len()
4534        || assets.iter().any(|(path, asset)| {
4535            expected_assets.get(path).is_none_or(|expected| {
4536                asset.blob_sha256 != expected.blob_sha256
4537                    || asset.bytes != expected.bytes
4538                    || asset.media_type != expected.media_type
4539                    || asset.wrappers != expected.wrappers
4540                    || asset.required != expected.required
4541                    || asset.disposition != expected.disposition
4542            })
4543        })
4544    {
4545        return Err(invalid_feed(
4546            "self-custody candidate contains an unexpected asset mutation",
4547        ));
4548    }
4549    let Some((
4550        request_hash,
4551        signing_b64,
4552        changes_b64,
4553        actor_b64,
4554        root,
4555        asset_root,
4556        parent_seq,
4557        parent,
4558    )) = pinned
4559    else {
4560        return Err(invalid_feed("self-custody candidate has no manifest"));
4561    };
4562    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4563    let current_commit = head
4564        .pointer
4565        .as_ref()
4566        .map(|pointer| pointer.commit_hash.clone());
4567    if parent_seq != current_seq || parent != current_commit {
4568        return Err(LinkError::RemoteAdvancedDuringSync);
4569    }
4570    let changes = STANDARD
4571        .decode(changes_b64)
4572        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4573    let mut expected_changes = json!({
4574        "mutation_id": mutation_id,
4575        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4576        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4577        "v": 2,
4578    });
4579    if let Some(withheld_links) = request_body.get("withheld_links") {
4580        expected_changes["withheld_links"] = withheld_links.clone();
4581    }
4582    if let Some(checkout_id) = request_body.get("checkout_id") {
4583        expected_changes["checkout_id"] = checkout_id.clone();
4584    }
4585    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4586        .map_err(|error| invalid_feed(error.to_string()))?;
4587    if changes != expected_changes_bytes {
4588        return Err(invalid_feed(
4589            "self-custody changeset differs from the requested mutation",
4590        ));
4591    }
4592    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4593        .map_err(|error| invalid_feed(error.to_string()))?;
4594    let request_value = json!({
4595        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4596        "brain": head.brain_id,
4597        "changes_sha256": changes_hash,
4598        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4599        "v": 2,
4600        "v1_bridge": Value::Null,
4601    });
4602    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4603        .map_err(|error| invalid_feed(error.to_string()))?;
4604    if request_hash != expected_request_hash {
4605        return Err(invalid_feed(
4606            "self-custody request hash differs from the requested mutation",
4607        ));
4608    }
4609    let actor = STANDARD
4610        .decode(actor_b64)
4611        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4612    let actor_value: Value = serde_json::from_slice(&actor)
4613        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4614    if crate::linkmd_v2::canonical_bytes(&actor_value)
4615        .map_err(|error| invalid_feed(error.to_string()))?
4616        != actor
4617    {
4618        return Err(invalid_feed("self-custody actor claim is not canonical"));
4619    }
4620    let actor_object = actor_value
4621        .as_object()
4622        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4623    let actor_claim = actor_object
4624        .get("claim")
4625        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4626    let actor_public_key = actor_object
4627        .get("public_key")
4628        .and_then(Value::as_str)
4629        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4630    let actor_fingerprint = actor_object
4631        .get("fingerprint")
4632        .and_then(Value::as_str)
4633        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4634    let actor_signature = actor_object
4635        .get("sig")
4636        .and_then(Value::as_str)
4637        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4638    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4639        .map_err(|error| invalid_feed(error.to_string()))?;
4640    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4641    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4642    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4643    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4644    let impact = actor_claim
4645        .get("result")
4646        .and_then(|result| result.get("impact"))
4647        .and_then(Value::as_object);
4648    let impact_fields = [
4649        "creates",
4650        "updates",
4651        "deletes",
4652        "withdrawals",
4653        "renames",
4654        "restores",
4655        "asset_changes",
4656        "public_expansions",
4657        "executable_activations",
4658    ];
4659    let impact_is_valid = impact.is_some_and(|impact| {
4660        impact.len() == impact_fields.len() + 1
4661            && impact.get("v").and_then(Value::as_u64) == Some(1)
4662            && impact_fields
4663                .iter()
4664                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4665    });
4666    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4667        || head
4668            .trust
4669            .hub_signer
4670            .as_ref()
4671            .is_some_and(|known| known != &expected_actor_signer)
4672        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4673        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4674        || actor_claim
4675            .get("candidate")
4676            .and_then(|candidate| candidate.get("changes_sha256"))
4677            .and_then(Value::as_str)
4678            != Some(changes_hash.as_str())
4679        || actor_claim
4680            .get("candidate")
4681            .and_then(|candidate| candidate.get("state_root"))
4682            != Some(&expected_actor_root)
4683        || actor_claim
4684            .get("candidate")
4685            .and_then(|candidate| candidate.get("asset_root"))
4686            != Some(&expected_actor_asset_root)
4687        || actor_claim
4688            .get("candidate")
4689            .and_then(|candidate| candidate.get("control_revision"))
4690            .and_then(Value::as_str)
4691            != Some(head.control_revision.as_str())
4692        || !impact_is_valid
4693    {
4694        return Err(invalid_feed(
4695            "self-custody actor claim does not bind the verified authority",
4696        ));
4697    }
4698    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4699        .map_err(|error| invalid_feed(error.to_string()))?;
4700    let signing = STANDARD
4701        .decode(signing_b64)
4702        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4703    let signing_value: Value = serde_json::from_slice(&signing)
4704        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4705    if crate::linkmd_v2::canonical_bytes(&signing_value)
4706        .map_err(|error| invalid_feed(error.to_string()))?
4707        != signing
4708    {
4709        return Err(invalid_feed("self-custody signing bytes are not canonical"));
4710    }
4711    let pointer = head.pointer.as_ref();
4712    let expected_materializer = pointer
4713        .map(|value| value.materializer.as_str())
4714        .unwrap_or("dbmd-projection-v1");
4715    let expected_parent_commit = request_body
4716        .get("base")
4717        .and_then(|base| base.get("commit_hash"))
4718        .cloned()
4719        .unwrap_or(Value::Null);
4720    let expected_parent_root = request_body
4721        .get("base")
4722        .and_then(|base| base.get("content_root"))
4723        .cloned()
4724        .unwrap_or(Value::Null);
4725    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4726    let expected_parent_asset_root = request_body
4727        .get("base")
4728        .and_then(|base| base.get("asset_root"))
4729        .cloned()
4730        .unwrap_or(Value::Null);
4731    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4732    let expected_prev_entry = pointer
4733        .map(|value| Value::String(value.feed_hash.clone()))
4734        .unwrap_or(Value::Null);
4735    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4736        .map_err(|_| invalid_feed("brain identity history is too large"))?
4737        + 1;
4738    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4739        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4740        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4741        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4742        || signing_value.get("public_key").and_then(Value::as_str)
4743            != Some(key.public_key_spki.as_str())
4744        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4745        || signing_value.get("parent_root") != Some(&expected_parent_root)
4746        || signing_value.get("state_root") != Some(&expected_state_root)
4747        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4748        || signing_value.get("asset_root") != Some(&expected_asset_root)
4749        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4750        || signing_value.get("changes_sha256").and_then(Value::as_str)
4751            != Some(changes_hash.as_str())
4752        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4753        || signing_value
4754            .get("control_revision")
4755            .and_then(Value::as_str)
4756            != Some(head.control_revision.as_str())
4757        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4758        || signing_value.get("v1_bridge") != Some(&Value::Null)
4759        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4760    {
4761        return Err(invalid_feed(
4762            "self-custody signing bytes do not bind the verified candidate",
4763        ));
4764    }
4765    let pair = agent_keypair(&key.pkcs8)?;
4766    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4767    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4768}
4769
4770fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4771    let origin = normalized_origin(&cfg.hub)?;
4772    let absolute = if checkout.is_absolute() {
4773        checkout.to_path_buf()
4774    } else {
4775        std::env::current_dir()?.join(checkout)
4776    };
4777    Ok(format!(
4778        "sync-{}.json",
4779        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4780    ))
4781}
4782
4783fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4784    if let Some(value) = existing {
4785        if !is_sha256(value) {
4786            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4787        }
4788        return Ok(value.to_string());
4789    }
4790    use ring::rand::SecureRandom as _;
4791    let mut random = [0_u8; 32];
4792    ring::rand::SystemRandom::new()
4793        .fill(&mut random)
4794        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4795    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4796}
4797
4798#[cfg(any(unix, windows))]
4799fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4800    let directory = open_trust_dir(cfg)?;
4801    let origin = normalized_origin(&cfg.hub)?;
4802    let name = format!(
4803        "operation-{}.lock",
4804        content_sha256(format!("{origin}\0{brain}").as_bytes())
4805    );
4806    lock_trust_name(&directory, &name)
4807}
4808
4809#[cfg(not(any(unix, windows)))]
4810fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4811    Err(LinkError::UnsupportedPlatform {
4812        operation: "serialized link.md v2 sync",
4813    })
4814}
4815
4816fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4817    left.brain_id == right.brain_id
4818        && left.view_kind == right.view_kind
4819        && left.view_revision == right.view_revision
4820        && left.control_revision == right.control_revision
4821        && match (&left.pointer, &right.pointer) {
4822            (None, None) => true,
4823            (Some(left), Some(right)) => {
4824                left.seq == right.seq
4825                    && left.commit_hash == right.commit_hash
4826                    && left.content_root == right.content_root
4827                    && left.asset_root == right.asset_root
4828                    && left.feed_hash == right.feed_hash
4829            }
4830            _ => false,
4831        }
4832}
4833
4834/// A private sync baseline is an already verified materialization of the
4835/// remote manifest. It may replace another paginated manifest download only
4836/// when a freshly verified signed head names the exact same content, assets,
4837/// view, and authority revision. Any remote or permission movement falls back
4838/// to proof-bearing hub reads.
4839fn v2_baseline_matches_head(head: &V2VerifiedHead, baseline: &V2SyncBaseline) -> bool {
4840    let pointer = head.pointer.as_ref();
4841    baseline.head_seq == Some(pointer.map_or(0, |value| value.seq))
4842        && baseline.commit_hash.as_deref() == pointer.map(|value| value.commit_hash.as_str())
4843        && baseline.content_root.as_deref()
4844            == pointer.and_then(|value| value.content_root.as_deref())
4845        && baseline.asset_root.as_deref() == pointer.and_then(|value| value.asset_root.as_deref())
4846        && baseline.view_kind.as_deref() == Some(head.view_kind.as_str())
4847        && baseline.view_revision.as_deref() == Some(head.view_revision.as_str())
4848        && baseline.control_revision.as_deref() == Some(head.control_revision.as_str())
4849}
4850
4851fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4852    format!(
4853        "---\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"
4854    )
4855    .into_bytes()
4856}
4857
4858fn scoped_projection_sha256(brain: &str) -> String {
4859    content_sha256(&scoped_projection_bytes(brain))
4860}
4861
4862#[derive(Deserialize)]
4863struct LocalScopedViewMarker {
4864    v: u8,
4865    kind: String,
4866    authoritative: bool,
4867    brain: String,
4868    projection_sha256: String,
4869}
4870
4871/// True only when this store carries the exact generated marker for a local
4872/// link.md scoped view. This is presentation context, never authorization;
4873/// the hub remains the only authority for reads and writes.
4874pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4875    let marker = store
4876        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4877        .ok()
4878        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4879    let Some(marker) = marker else {
4880        return false;
4881    };
4882    if marker.v != 1
4883        || marker.kind != "link.md-scoped-view"
4884        || marker.authoritative
4885        || !crate::ulid::is_ulid(&marker.brain)
4886        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4887    {
4888        return false;
4889    }
4890    store
4891        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4892        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4893}
4894
4895fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4896    let mut bytes = serde_json::to_vec_pretty(&json!({
4897        "v": 1,
4898        "kind": "link.md-scoped-view",
4899        "authoritative": false,
4900        "brain": head.brain_id,
4901        "view_revision": head.view_revision,
4902        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4903        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4904        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4905        "visible_files": files,
4906        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4907    }))
4908    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4909    bytes.push(b'\n');
4910    Ok(bytes)
4911}
4912
4913fn refresh_scoped_view_marker(
4914    store: &Store,
4915    head: &V2VerifiedHead,
4916    files: usize,
4917) -> LinkResult<()> {
4918    if head.view_kind == "scoped" {
4919        store.write_atomic(
4920            Path::new(".dbmd/view.json"),
4921            &scoped_view_metadata(head, files)?,
4922        )?;
4923    }
4924    Ok(())
4925}
4926
4927fn ensure_v2_view_compatible(
4928    head: &V2VerifiedHead,
4929    baseline: Option<&V2SyncBaseline>,
4930) -> LinkResult<()> {
4931    let Some(baseline) = baseline else {
4932        return Ok(());
4933    };
4934    match (
4935        baseline.view_kind.as_deref(),
4936        baseline.view_revision.as_deref(),
4937    ) {
4938        (None, None) if head.view_kind == "full" => Ok(()),
4939        (Some(kind), Some(revision))
4940            if kind == head.view_kind && revision == head.view_revision =>
4941        {
4942            Ok(())
4943        }
4944        _ => Err(LinkError::ScopedViewChanged),
4945    }
4946}
4947
4948fn ensure_established_v2_checkout_opened(
4949    head: &V2VerifiedHead,
4950    baseline: Option<&V2SyncBaseline>,
4951    opened: bool,
4952) -> LinkResult<()> {
4953    if baseline.is_none() || opened {
4954        return Ok(());
4955    }
4956    if head.view_kind == "scoped" {
4957        return Err(LinkError::ScopedProjectionModified);
4958    }
4959    Err(LinkError::InvalidPack {
4960        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4961    })
4962}
4963
4964fn remove_scoped_projection(
4965    head: &V2VerifiedHead,
4966    baseline: Option<&V2SyncBaseline>,
4967    view: &mut V2LocalView,
4968) -> LinkResult<()> {
4969    if head.view_kind != "scoped" {
4970        return Ok(());
4971    }
4972    let expected = scoped_projection_sha256(&head.brain_id);
4973    if baseline
4974        .and_then(|state| state.projection_sha256.as_deref())
4975        .is_some_and(|pinned| pinned != expected)
4976    {
4977        return Err(LinkError::ScopedViewChanged);
4978    }
4979    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4980        return Err(LinkError::ScopedProjectionModified);
4981    }
4982    view.riding.remove("DB.md");
4983    view.eligibility.remove("DB.md");
4984    Ok(())
4985}
4986
4987fn local_view_for_v2_push(
4988    store: &Store,
4989    head: &V2VerifiedHead,
4990    baseline: Option<&V2SyncBaseline>,
4991    carried: Option<V2LocalView>,
4992) -> LinkResult<V2LocalView> {
4993    match carried {
4994        // The pull half already verified and removed the generated scoped
4995        // projection before constructing its in-memory handoff. Requiring it
4996        // a second time would turn every scoped bidirectional write into a
4997        // false tamper refusal.
4998        Some(view) => Ok(view),
4999        None => {
5000            let mut view = v2_local_files(store)?;
5001            remove_scoped_projection(head, baseline, &mut view)?;
5002            Ok(view)
5003        }
5004    }
5005}
5006
5007fn files_for_v2_view(
5008    head: &V2VerifiedHead,
5009    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
5010) -> std::collections::BTreeMap<String, V2BaselineFile> {
5011    if head.view_kind == "scoped" {
5012        // Even when a grant explicitly includes canonical DB.md, a partial
5013        // checkout uses a generated marker. Canonical validation remains a
5014        // server-side operation over the complete brain.
5015        files.remove("DB.md");
5016    }
5017    files
5018}
5019
5020fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
5021    let baseline: V2SyncBaseline =
5022        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
5023    if baseline.v != 2
5024        || baseline.origin != normalized_origin(&cfg.hub)?
5025        || baseline.brain != brain
5026        || baseline
5027            .commit_hash
5028            .as_deref()
5029            .is_some_and(|hash| !is_sha256(hash))
5030        || baseline
5031            .content_root
5032            .as_deref()
5033            .is_some_and(|hash| !is_sha256(hash))
5034        || baseline
5035            .asset_root
5036            .as_deref()
5037            .is_some_and(|hash| !is_sha256(hash))
5038        || baseline
5039            .local_policy_digest
5040            .as_deref()
5041            .is_some_and(|hash| !is_sha256(hash))
5042        || baseline
5043            .view_kind
5044            .as_deref()
5045            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5046        || baseline
5047            .view_revision
5048            .as_deref()
5049            .is_some_and(|hash| !is_sha256(hash))
5050        || baseline
5051            .control_revision
5052            .as_deref()
5053            .is_some_and(|hash| !is_sha256(hash))
5054        || baseline
5055            .projection_sha256
5056            .as_deref()
5057            .is_some_and(|hash| !is_sha256(hash))
5058        || (baseline.view_kind.as_deref() == Some("scoped")
5059            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5060        || baseline.files.len() > MAX_PUSH_FILES
5061        || baseline.assets.len() > MAX_PUSH_FILES
5062        || baseline.local_eligibility.len() > MAX_PUSH_FILES
5063        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5064        || baseline.files.iter().any(|(path, file)| {
5065            crate::linkmd_v2::normalize_path(path).is_err()
5066                || !is_sha256(&file.sha256)
5067                || file.bytes > MAX_STORE_BYTES
5068        })
5069        || baseline.assets.iter().any(|(path, asset)| {
5070            crate::linkmd_v2::normalize_path(path).is_err()
5071                || !is_sha256(&asset.blob_sha256)
5072                || !is_sha256(&asset.leaf_hash)
5073                || asset.bytes > MAX_ASSET_BYTES
5074                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5075                || asset.wrappers.is_empty()
5076                || asset
5077                    .wrappers
5078                    .iter()
5079                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5080        })
5081        || baseline
5082            .local_eligibility
5083            .keys()
5084            .chain(baseline.remote_copy_remains.keys())
5085            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5086        || baseline
5087            .remote_copy_remains
5088            .values()
5089            .any(|hash| !is_sha256(hash))
5090        || baseline
5091            .checkout_id
5092            .as_deref()
5093            .is_some_and(|checkout_id| !is_sha256(checkout_id))
5094    {
5095        return Err(invalid_feed("v2 sync baseline failed validation"));
5096    }
5097    Ok(baseline)
5098}
5099
5100#[cfg(unix)]
5101fn load_v2_baseline(
5102    cfg: &HubConfig,
5103    brain: &str,
5104    checkout: &Path,
5105) -> LinkResult<Option<V2SyncBaseline>> {
5106    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5107    let directory = open_trust_dir(cfg)?;
5108    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5109    let _lock = lock_trust_name(&directory, &name_string)?;
5110    let name = c_name(name_string.as_bytes(), &name_string)?;
5111    let fd = unsafe {
5112        libc::openat(
5113            directory.as_raw_fd(),
5114            name.as_ptr(),
5115            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5116        )
5117    };
5118    if fd < 0 {
5119        let error = std::io::Error::last_os_error();
5120        return if error.kind() == std::io::ErrorKind::NotFound {
5121            Ok(None)
5122        } else {
5123            Err(LinkError::UnsafePath { path: name_string })
5124        };
5125    }
5126    let file = unsafe { std::fs::File::from_raw_fd(fd) };
5127    let mut bytes = Vec::new();
5128    file.take(MAX_FEED_RESPONSE_BYTES + 1)
5129        .read_to_end(&mut bytes)?;
5130    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
5131        return Err(invalid_feed("v2 sync baseline is oversized"));
5132    }
5133    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5134}
5135
5136#[cfg(windows)]
5137fn load_v2_baseline(
5138    cfg: &HubConfig,
5139    brain: &str,
5140    checkout: &Path,
5141) -> LinkResult<Option<V2SyncBaseline>> {
5142    let directory = open_trust_dir(cfg)?;
5143    let name = v2_baseline_name(cfg, brain, checkout)?;
5144    let _lock = lock_trust_name(&directory, &name)?;
5145    let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5146    match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5147        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5148        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5149        Err(_) => Err(LinkError::UnsafePath { path: name }),
5150    }
5151}
5152
5153#[cfg(not(any(unix, windows)))]
5154fn load_v2_baseline(
5155    _cfg: &HubConfig,
5156    _brain: &str,
5157    _checkout: &Path,
5158) -> LinkResult<Option<V2SyncBaseline>> {
5159    Err(LinkError::UnsupportedPlatform {
5160        operation: "verified link.md v2 baseline",
5161    })
5162}
5163
5164#[cfg(unix)]
5165fn save_v2_baseline(
5166    cfg: &HubConfig,
5167    brain: &str,
5168    checkout: &Path,
5169    baseline: &V2SyncBaseline,
5170) -> LinkResult<()> {
5171    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5172    let directory = open_trust_dir(cfg)?;
5173    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5174    let _lock = lock_trust_name(&directory, &name_string)?;
5175    let name = c_name(name_string.as_bytes(), &name_string)?;
5176    let mut bytes = serde_json::to_vec(baseline)
5177        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5178    bytes.push(b'\n');
5179    let temp_string = format!(
5180        ".{name_string}.tmp.{}-{}",
5181        std::process::id(),
5182        std::time::SystemTime::now()
5183            .duration_since(std::time::UNIX_EPOCH)
5184            .unwrap_or_default()
5185            .as_nanos()
5186    );
5187    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5188    let fd = unsafe {
5189        libc::openat(
5190            directory.as_raw_fd(),
5191            temp.as_ptr(),
5192            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5193            0o600,
5194        )
5195    };
5196    if fd < 0 {
5197        return Err(std::io::Error::last_os_error().into());
5198    }
5199    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5200    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5201        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5202        return Err(error.into());
5203    }
5204    drop(file);
5205    if unsafe {
5206        libc::renameat(
5207            directory.as_raw_fd(),
5208            temp.as_ptr(),
5209            directory.as_raw_fd(),
5210            name.as_ptr(),
5211        )
5212    } != 0
5213    {
5214        let error = std::io::Error::last_os_error();
5215        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5216        return Err(error.into());
5217    }
5218    directory.sync_all()?;
5219    Ok(())
5220}
5221
5222#[cfg(windows)]
5223fn save_v2_baseline(
5224    cfg: &HubConfig,
5225    brain: &str,
5226    checkout: &Path,
5227    baseline: &V2SyncBaseline,
5228) -> LinkResult<()> {
5229    let directory = open_trust_dir(cfg)?;
5230    let name = v2_baseline_name(cfg, brain, checkout)?;
5231    let _lock = lock_trust_name(&directory, &name)?;
5232    let mut bytes = serde_json::to_vec(baseline)
5233        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5234    bytes.push(b'\n');
5235    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5236    Ok(())
5237}
5238
5239#[cfg(not(any(unix, windows)))]
5240fn save_v2_baseline(
5241    _cfg: &HubConfig,
5242    _brain: &str,
5243    _checkout: &Path,
5244    _baseline: &V2SyncBaseline,
5245) -> LinkResult<()> {
5246    Err(LinkError::UnsupportedPlatform {
5247        operation: "verified link.md v2 baseline",
5248    })
5249}
5250
5251fn v2_baseline_from_head(
5252    cfg: &HubConfig,
5253    head: &V2VerifiedHead,
5254    files: std::collections::BTreeMap<String, V2BaselineFile>,
5255    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5256    local: Option<&V2LocalView>,
5257    checkout_id: Option<&str>,
5258) -> LinkResult<V2SyncBaseline> {
5259    let mut local_eligibility = local
5260        .map(|view| view.eligibility.clone())
5261        .unwrap_or_default();
5262    if let Some(view) = local {
5263        for path in files.keys() {
5264            local_eligibility
5265                .entry(path.clone())
5266                .or_insert_with(|| !view.policy.keeps_home(path));
5267        }
5268    }
5269    let remote_copy_remains = local_eligibility
5270        .iter()
5271        .filter(|(_, riding)| !**riding)
5272        .filter_map(|(path, _)| {
5273            files
5274                .get(path)
5275                .map(|file| (path.clone(), file.sha256.clone()))
5276        })
5277        .collect();
5278    Ok(V2SyncBaseline {
5279        v: 2,
5280        origin: normalized_origin(&cfg.hub)?,
5281        brain: head.brain_id.clone(),
5282        checkout_id: Some(v2_checkout_id(checkout_id)?),
5283        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5284        commit_hash: head
5285            .pointer
5286            .as_ref()
5287            .map(|pointer| pointer.commit_hash.clone()),
5288        content_root: head
5289            .pointer
5290            .as_ref()
5291            .and_then(|pointer| pointer.content_root.clone()),
5292        asset_root: head
5293            .pointer
5294            .as_ref()
5295            .and_then(|pointer| pointer.asset_root.clone()),
5296        assets,
5297        view_kind: Some(head.view_kind.clone()),
5298        view_revision: Some(head.view_revision.clone()),
5299        control_revision: Some(head.control_revision.clone()),
5300        projection_sha256: (head.view_kind == "scoped")
5301            .then(|| scoped_projection_sha256(&head.brain_id)),
5302        files,
5303        local_policy_digest: local.map(|view| view.policy.digest.clone()),
5304        local_eligibility,
5305        remote_copy_remains,
5306    })
5307}
5308
5309fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5310    let policy = crate::linkmd_sync_policy::load(store)
5311        .map_err(|message| LinkError::InvalidPack { message })?;
5312    let asset_paths = crate::assets::read_manifest(store)
5313        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5314        .into_iter()
5315        .map(|asset| asset.path)
5316        .collect::<std::collections::BTreeSet<_>>();
5317    let mut result = std::collections::BTreeMap::new();
5318    let mut eligibility = std::collections::BTreeMap::new();
5319    let mut riding_links = Vec::<(String, Vec<String>)>::new();
5320    let mut total = 0_u64;
5321    let mut paths = vec![PathBuf::from("DB.md")];
5322    paths.extend(store.walk()?);
5323    for relative in paths {
5324        let path = relative.to_string_lossy().replace('\\', "/");
5325        // v2 catalogs and asset inventory are materialized/signed separately.
5326        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5327            continue;
5328        }
5329        if asset_paths.contains(&path) {
5330            continue;
5331        }
5332        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5333            path: error.to_string(),
5334        })?;
5335        let riding = !policy.keeps_home(&path);
5336        eligibility.insert(path.clone(), riding);
5337        if !riding {
5338            continue;
5339        }
5340        let remaining = MAX_STORE_BYTES.saturating_sub(total);
5341        let bytes = store.read_bounded(&relative, remaining)?;
5342        total = total
5343            .checked_add(bytes.len() as u64)
5344            .ok_or_else(|| LinkError::PushTooLarge {
5345                detail: "v2 local byte count overflow".to_string(),
5346            })?;
5347        if total > MAX_STORE_BYTES {
5348            return Err(LinkError::PushTooLarge {
5349                detail: format!("{total} uncompressed bytes"),
5350            });
5351        }
5352        if std::str::from_utf8(&bytes).is_err() {
5353            return Err(LinkError::NotUtf8 { path });
5354        }
5355        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5356        riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5357        result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5358    }
5359    let kept_home = eligibility
5360        .iter()
5361        .filter(|(_, riding)| !**riding)
5362        .map(|(path, _)| path.clone())
5363        .collect::<std::collections::BTreeSet<_>>();
5364    let mut withheld_links = riding_links
5365        .into_iter()
5366        .flat_map(|(source, targets)| {
5367            let kept_home = &kept_home;
5368            let policy = &policy;
5369            targets.into_iter().filter_map(move |target| {
5370                let target = format!("{target}.md");
5371                // A kept-home target need not be a file on THIS machine. An
5372                // export omits kept-home content by construction, so a store
5373                // restored from one links to names it does not carry — and
5374                // without this the owner's own export could never be pushed
5375                // back, which is the opposite of owning your data. The policy
5376                // is the owner's statement about where a path lives, and it
5377                // stands whether or not this checkout holds a copy. It
5378                // discloses nothing either way: the name is already written in
5379                // the content that links to it.
5380                (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5381                    V2WithheldLink {
5382                        source: source.clone(),
5383                        target,
5384                    },
5385                )
5386            })
5387        })
5388        .collect::<Vec<_>>();
5389    withheld_links.sort();
5390    withheld_links.dedup();
5391    Ok(V2LocalView {
5392        riding: result,
5393        eligibility,
5394        policy,
5395        withheld_links,
5396    })
5397}
5398
5399#[derive(Debug, Clone, Deserialize)]
5400struct V2DownloadItem {
5401    path: String,
5402    sha256: String,
5403    bytes: u64,
5404    url: String,
5405    method: String,
5406}
5407
5408#[derive(Debug, Deserialize)]
5409struct V2DownloadWindow {
5410    v: u8,
5411    commit: String,
5412    downloads: Vec<V2DownloadItem>,
5413}
5414
5415#[derive(Debug, Deserialize)]
5416struct V2BulkStreamHeader {
5417    v: u8,
5418    path: String,
5419    sha256: String,
5420    bytes: u64,
5421}
5422
5423fn parse_v2_bulk_stream(
5424    bytes: &[u8],
5425    expected: &[(&String, &V2BaselineFile)],
5426) -> LinkResult<Vec<(String, Vec<u8>)>> {
5427    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5428        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5429    }
5430    let mut cursor = V2_BULK_STREAM_MAGIC.len();
5431    let mut result = Vec::with_capacity(expected.len());
5432    for (expected_path, expected_file) in expected {
5433        let length_bytes = bytes
5434            .get(cursor..cursor + 4)
5435            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5436        cursor += 4;
5437        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5438        if header_len == 0 || header_len > 4 * 1024 {
5439            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5440        }
5441        let header_bytes = bytes
5442            .get(cursor..cursor + header_len)
5443            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5444        cursor += header_len;
5445        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5446            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5447        if header.v != 2
5448            || &header.path != *expected_path
5449            || header.sha256 != expected_file.sha256
5450            || header.bytes != expected_file.bytes
5451            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5452        {
5453            return Err(invalid_feed(
5454                "v2 bulk stream frame differs from its proven manifest entry",
5455            ));
5456        }
5457        let body_len = usize::try_from(header.bytes)
5458            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5459        let body = bytes
5460            .get(cursor..cursor + body_len)
5461            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5462        cursor += body_len;
5463        if content_sha256(body) != header.sha256 {
5464            return Err(invalid_feed(
5465                "v2 bulk stream file differs from its proven manifest entry",
5466            ));
5467        }
5468        result.push((header.path, body.to_vec()));
5469    }
5470    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5471        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5472    }
5473    cursor += 4;
5474    if cursor != bytes.len() {
5475        return Err(invalid_feed("v2 bulk stream carries trailing data"));
5476    }
5477    Ok(result)
5478}
5479
5480fn download_v2_bulk_stream(
5481    cfg: &HubConfig,
5482    brain: &str,
5483    pointer: &V2PointerBody,
5484    pending: &[(&String, &V2BaselineFile)],
5485) -> LinkResult<Vec<(String, Vec<u8>)>> {
5486    let claims = pending
5487        .iter()
5488        .map(|(path, file)| {
5489            Ok(json!({
5490                "path": path,
5491                "sha256": file.sha256,
5492                "bytes": file.bytes,
5493                "proof": file.proof.as_ref().ok_or_else(|| {
5494                    invalid_feed("v2 manifest omitted a bulk-stream proof")
5495                })?,
5496            }))
5497        })
5498        .collect::<LinkResult<Vec<_>>>()?;
5499    let raw = request_raw_retryable_read(
5500        cfg,
5501        "POST",
5502        &format!("/api/hub/brains/{brain}/v2/stream"),
5503        Some(&json!({
5504            "commit": pointer.commit_hash,
5505            "files": claims,
5506        })),
5507        Auth::Required,
5508        V2_BULK_STREAM_RESPONSE_BYTES,
5509    )?;
5510    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5511    parse_v2_bulk_stream(&body, pending)
5512}
5513
5514fn request_capped_retryable_read(
5515    cfg: &HubConfig,
5516    method: &str,
5517    path: &str,
5518    body: Option<&Value>,
5519    auth: Auth,
5520    max_response_bytes: u64,
5521) -> LinkResult<HubResponse> {
5522    let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5523    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5524    Ok(HubResponse {
5525        status: raw.status,
5526        body: parsed,
5527    })
5528}
5529
5530fn prepare_v2_downloads(
5531    cfg: &HubConfig,
5532    brain: &str,
5533    pointer: &V2PointerBody,
5534    pending: &[(&String, &V2BaselineFile)],
5535) -> LinkResult<Vec<V2DownloadItem>> {
5536    let mut result = Vec::with_capacity(pending.len());
5537    for chunk in pending.chunks(128) {
5538        let claims = chunk
5539            .iter()
5540            .map(|(path, file)| {
5541                Ok(json!({
5542                    "path": path,
5543                    "sha256": file.sha256,
5544                    "bytes": file.bytes,
5545                    "proof": file.proof.as_ref().ok_or_else(|| {
5546                        invalid_feed("v2 manifest omitted a download proof")
5547                    })?,
5548                }))
5549            })
5550            .collect::<LinkResult<Vec<_>>>()?;
5551        let value = ensure_ok(
5552            request_capped_retryable_read(
5553                cfg,
5554                "POST",
5555                &format!("/api/hub/brains/{brain}/v2/downloads"),
5556                Some(&json!({
5557                    "commit": pointer.commit_hash,
5558                    "files": claims,
5559                })),
5560                Auth::Required,
5561                MAX_FEED_RESPONSE_BYTES,
5562            )?,
5563            "prepare v2 blob downloads",
5564        )?;
5565        let window: V2DownloadWindow = serde_json::from_value(value)
5566            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5567        if window.v != 2
5568            || window.commit != pointer.commit_hash
5569            || window.downloads.len() != chunk.len()
5570        {
5571            return Err(invalid_feed(
5572                "v2 download window is not bound to the requested files",
5573            ));
5574        }
5575        let mut by_path = window
5576            .downloads
5577            .into_iter()
5578            .map(|item| (item.path.clone(), item))
5579            .collect::<std::collections::BTreeMap<_, _>>();
5580        if by_path.len() != chunk.len() {
5581            return Err(invalid_feed("v2 download window repeats a path"));
5582        }
5583        for (path, file) in chunk {
5584            let item = by_path
5585                .remove(*path)
5586                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5587            if item.method != "GET"
5588                || item.sha256 != file.sha256
5589                || item.bytes != file.bytes
5590                || item.url.is_empty()
5591            {
5592                return Err(invalid_feed(
5593                    "v2 download capability differs from its proven file",
5594                ));
5595            }
5596            result.push(item);
5597        }
5598    }
5599    Ok(result)
5600}
5601
5602fn prepare_v2_asset_downloads(
5603    cfg: &HubConfig,
5604    brain: &str,
5605    pointer: &V2PointerBody,
5606    pending: &[(&String, &V2BaselineAsset)],
5607) -> LinkResult<Vec<V2DownloadItem>> {
5608    let mut result = Vec::with_capacity(pending.len());
5609    for chunk in pending.chunks(128) {
5610        let claims = chunk
5611            .iter()
5612            .map(|(path, asset)| {
5613                json!({
5614                    "path": path,
5615                    "sha256": asset.blob_sha256,
5616                    "bytes": asset.bytes,
5617                    "leaf_hash": asset.leaf_hash,
5618                })
5619            })
5620            .collect::<Vec<_>>();
5621        let value = ensure_ok(
5622            request_capped_retryable_read(
5623                cfg,
5624                "POST",
5625                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5626                Some(&json!({
5627                    "commit": pointer.commit_hash,
5628                    "assets": claims,
5629                })),
5630                Auth::Required,
5631                MAX_FEED_RESPONSE_BYTES,
5632            )?,
5633            "prepare v2 asset downloads",
5634        )?;
5635        let window: V2DownloadWindow = serde_json::from_value(value)
5636            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5637        if window.v != 2
5638            || window.commit != pointer.commit_hash
5639            || window.downloads.len() != chunk.len()
5640        {
5641            return Err(invalid_feed(
5642                "v2 asset download window is not bound to the requested assets",
5643            ));
5644        }
5645        let mut by_path = window
5646            .downloads
5647            .into_iter()
5648            .map(|item| (item.path.clone(), item))
5649            .collect::<std::collections::BTreeMap<_, _>>();
5650        if by_path.len() != chunk.len() {
5651            return Err(invalid_feed("v2 asset download window repeats a path"));
5652        }
5653        for (path, asset) in chunk {
5654            let item = by_path
5655                .remove(*path)
5656                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5657            if item.method != "GET"
5658                || item.sha256 != asset.blob_sha256
5659                || item.bytes != asset.bytes
5660                || item.url.is_empty()
5661            {
5662                return Err(invalid_feed(
5663                    "v2 asset download capability differs from its signed leaf",
5664                ));
5665            }
5666            result.push(item);
5667        }
5668    }
5669    Ok(result)
5670}
5671
5672#[cfg(any(unix, windows))]
5673fn stage_v2_asset_download_window(
5674    cfg: &HubConfig,
5675    brain: &str,
5676    pointer: &V2PointerBody,
5677    cache_dir: &Path,
5678    pending: &[(&String, &V2BaselineAsset)],
5679) -> LinkResult<Vec<V2StagedFile>> {
5680    if pending.is_empty() {
5681        return Ok(Vec::new());
5682    }
5683    if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5684        return Err(invalid_feed("v2 asset capability window is oversized"));
5685    }
5686
5687    let mut last_error = None;
5688    for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5689        .iter()
5690        .copied()
5691        .map(Some)
5692        .chain(std::iter::once(None))
5693    {
5694        // This POST rechecks the exact head and current path authority before
5695        // issuing fresh, immutable GET capabilities. Successful prior workers
5696        // are already durable in the content-addressed cache, so retrying the
5697        // complete small window neither redownloads them nor widens access.
5698        let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5699        let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5700        for item in downloads {
5701            match unique.get(&item.sha256) {
5702                Some(prior) if prior.bytes != item.bytes => {
5703                    return Err(invalid_feed(
5704                        "one v2 asset hash has conflicting byte lengths",
5705                    ));
5706                }
5707                Some(_) => {}
5708                None => {
5709                    unique.insert(item.sha256.clone(), item);
5710                }
5711            }
5712        }
5713        let downloads = unique.into_values().collect::<Vec<_>>();
5714        let next = std::sync::atomic::AtomicUsize::new(0);
5715        let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5716        let mut results = std::iter::repeat_with(|| None)
5717            .take(downloads.len())
5718            .collect::<Vec<Option<LinkResult<PathBuf>>>>();
5719        std::thread::scope(|scope| {
5720            let (sender, receiver) = std::sync::mpsc::channel();
5721            for _ in 0..worker_count {
5722                let sender = sender.clone();
5723                let downloads = &downloads;
5724                let next = &next;
5725                scope.spawn(move || loop {
5726                    let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5727                    let Some(item) = downloads.get(index) else {
5728                        break;
5729                    };
5730                    let result = download_presigned_to_cache(
5731                        cfg,
5732                        &item.url,
5733                        cache_dir,
5734                        &item.sha256,
5735                        item.bytes,
5736                    );
5737                    if sender.send((index, result)).is_err() {
5738                        break;
5739                    }
5740                });
5741            }
5742            drop(sender);
5743            for (index, result) in receiver {
5744                results[index] = Some(result);
5745            }
5746        });
5747
5748        let mut failed = None;
5749        for result in results {
5750            match result {
5751                Some(Ok(_)) => {}
5752                Some(Err(error)) if failed.is_none() => failed = Some(error),
5753                Some(Err(_)) => {}
5754                None if failed.is_none() => {
5755                    failed = Some(LinkError::Transport {
5756                        hub: cfg.hub.clone(),
5757                        message: "a bounded v2 asset worker stopped before reporting its result"
5758                            .to_string(),
5759                    });
5760                }
5761                None => {}
5762            }
5763        }
5764        if let Some(error) = failed {
5765            last_error = Some(error);
5766            if let Some(milliseconds) = retry_delay {
5767                std::thread::sleep(std::time::Duration::from_millis(milliseconds));
5768                continue;
5769            }
5770            break;
5771        }
5772
5773        return pending
5774            .iter()
5775            .map(|(path, asset)| {
5776                let source = cache_dir.join(&asset.blob_sha256);
5777                if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
5778                    return Err(invalid_feed(
5779                        "v2 asset download cache omitted a proven blob",
5780                    ));
5781                }
5782                Ok(V2StagedFile {
5783                    path: (*path).clone(),
5784                    source,
5785                    sha256: asset.blob_sha256.clone(),
5786                    bytes: asset.bytes,
5787                })
5788            })
5789            .collect();
5790    }
5791    Err(last_error
5792        .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
5793}
5794
5795fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5796    let bytes = get_presigned(cfg, &item.url)?;
5797    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5798        return Err(invalid_feed("v2 blob differs from its proven path entry"));
5799    }
5800    Ok(bytes)
5801}
5802
5803#[derive(Debug, Clone)]
5804struct V2StagedFile {
5805    path: String,
5806    source: PathBuf,
5807    sha256: String,
5808    bytes: u64,
5809}
5810
5811#[cfg(unix)]
5812fn v2_download_cache_dir(
5813    cfg: &HubConfig,
5814    brain: &str,
5815    pointer: &V2PointerBody,
5816) -> LinkResult<PathBuf> {
5817    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5818}
5819
5820#[cfg(unix)]
5821fn v2_download_cache_dir_for(
5822    cfg: &HubConfig,
5823    brain: &str,
5824    transaction: &str,
5825) -> LinkResult<PathBuf> {
5826    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5827        return Err(invalid_feed("v2 download cache address is invalid"));
5828    }
5829    let path = cfg
5830        .state_dir
5831        .join("downloads")
5832        .join(brain)
5833        .join(transaction);
5834    let directory = open_or_create_dir_nofollow(&path)?;
5835    use std::os::fd::AsRawFd as _;
5836    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5837        return Err(std::io::Error::last_os_error().into());
5838    }
5839    directory.sync_all()?;
5840    Ok(path)
5841}
5842
5843#[cfg(windows)]
5844fn v2_download_cache_dir(
5845    cfg: &HubConfig,
5846    brain: &str,
5847    pointer: &V2PointerBody,
5848) -> LinkResult<PathBuf> {
5849    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5850}
5851
5852#[cfg(windows)]
5853fn v2_download_cache_dir_for(
5854    cfg: &HubConfig,
5855    brain: &str,
5856    transaction: &str,
5857) -> LinkResult<PathBuf> {
5858    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5859        return Err(invalid_feed("v2 download cache address is invalid"));
5860    }
5861    let path = cfg
5862        .state_dir
5863        .join("downloads")
5864        .join(brain)
5865        .join(transaction);
5866    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5867    crate::fsx::open_directory_nofollow(&path)?;
5868    Ok(path)
5869}
5870
5871#[cfg(unix)]
5872fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5873    use std::os::fd::AsRawFd as _;
5874    let parent = cfg.state_dir.join("downloads").join(brain);
5875    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5876        return;
5877    };
5878    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5879        return;
5880    };
5881    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5882    let _ = directory.sync_all();
5883}
5884
5885#[cfg(windows)]
5886fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5887    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5888        return;
5889    }
5890    let parent = cfg.state_dir.join("downloads").join(brain);
5891    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5892        return;
5893    };
5894    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5895}
5896
5897#[cfg(not(any(unix, windows)))]
5898fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5899
5900#[cfg(not(any(unix, windows)))]
5901fn v2_download_cache_dir_for(
5902    _cfg: &HubConfig,
5903    _brain: &str,
5904    _transaction: &str,
5905) -> LinkResult<PathBuf> {
5906    Err(LinkError::UnsupportedPlatform {
5907        operation: "resumable v2 download staging",
5908    })
5909}
5910
5911#[cfg(any(unix, windows))]
5912fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5913    let file = match crate::fsx::open_regular_nofollow(path) {
5914        Ok(file) => file,
5915        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5916        Err(error) => return Err(error.into()),
5917    };
5918    if file.metadata()?.len() != bytes {
5919        return Ok(false);
5920    }
5921    Ok(content_sha256_reader(file)? == sha256)
5922}
5923
5924#[cfg(any(unix, windows))]
5925fn cache_v2_blob_bytes(
5926    cache_dir: &Path,
5927    sha256: &str,
5928    expected_bytes: u64,
5929    bytes: &[u8],
5930) -> LinkResult<PathBuf> {
5931    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5932        return Err(invalid_feed("v2 cached blob differs from its declaration"));
5933    }
5934    let path = cache_dir.join(sha256);
5935    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5936        crate::fsx::write_atomic(&path, bytes)?;
5937    }
5938    Ok(path)
5939}
5940
5941#[cfg(not(any(unix, windows)))]
5942fn cache_v2_blob_bytes(
5943    _cache_dir: &Path,
5944    _sha256: &str,
5945    _expected_bytes: u64,
5946    _bytes: &[u8],
5947) -> LinkResult<PathBuf> {
5948    Err(LinkError::UnsupportedPlatform {
5949        operation: "resumable v2 download staging",
5950    })
5951}
5952
5953#[cfg(unix)]
5954fn download_presigned_to_cache(
5955    cfg: &HubConfig,
5956    url: &str,
5957    cache_dir: &Path,
5958    sha256: &str,
5959    expected_bytes: u64,
5960) -> LinkResult<PathBuf> {
5961    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5962
5963    let target = cache_dir.join(sha256);
5964    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5965        return Ok(target);
5966    }
5967    let directory = open_existing_dir_nofollow(cache_dir)?;
5968    let mut nonce = [0_u8; 16];
5969    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5970        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5971    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5972    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5973    let fd = unsafe {
5974        libc::openat(
5975            directory.as_raw_fd(),
5976            temp.as_ptr(),
5977            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5978            0o600,
5979        )
5980    };
5981    if fd < 0 {
5982        return Err(std::io::Error::last_os_error().into());
5983    }
5984    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5985    let response = match presigned_agent(cfg, url)?.get(url).call() {
5986        Ok(response) => response,
5987        Err(ureq::Error::Status(_, response)) => {
5988            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5989            return Err(LinkError::Http {
5990                what: "v2 direct download",
5991                status: response.status(),
5992                message: "object store rejected the download".to_string(),
5993                code: None,
5994                details: None,
5995            });
5996        }
5997        Err(ureq::Error::Transport(error)) => {
5998            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5999            return Err(LinkError::Transport {
6000                hub: cfg.hub.clone(),
6001                message: error.to_string(),
6002            });
6003        }
6004    };
6005    let mut reader = response
6006        .into_reader()
6007        .take(expected_bytes.saturating_add(1));
6008    let mut digest = Sha256::new();
6009    let mut total = 0_u64;
6010    let mut buffer = [0_u8; 64 * 1024];
6011    // Reading the socket and writing the file are different failures wearing
6012    // the same io::Error: a stalled download is the peer's problem and a full
6013    // disk is this machine's, and an operator who is told only "timed out"
6014    // cannot tell which one to act on.
6015    let write_result = (|| -> LinkResult<()> {
6016        loop {
6017            let read = reader
6018                .read(&mut buffer)
6019                .map_err(|error| LinkError::Transport {
6020                    hub: cfg.hub.clone(),
6021                    message: error.to_string(),
6022                })?;
6023            if read == 0 {
6024                break;
6025            }
6026            total = total.saturating_add(read as u64);
6027            digest.update(&buffer[..read]);
6028            output.write_all(&buffer[..read])?;
6029        }
6030        output.sync_all().map_err(LinkError::from)
6031    })();
6032    if let Err(error) = write_result {
6033        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6034        return Err(error);
6035    }
6036    drop(output);
6037    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6038        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6039        return Err(invalid_feed(
6040            "v2 direct download failed integrity verification",
6041        ));
6042    }
6043    let target_name = c_name(sha256.as_bytes(), sha256)?;
6044    // Replace only a previously inspected cache leaf. Cache bytes are private
6045    // and content-addressed; final install re-verifies them again.
6046    if unsafe {
6047        libc::renameat(
6048            directory.as_raw_fd(),
6049            temp.as_ptr(),
6050            directory.as_raw_fd(),
6051            target_name.as_ptr(),
6052        )
6053    } != 0
6054    {
6055        let error = std::io::Error::last_os_error();
6056        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6057        return Err(error.into());
6058    }
6059    directory.sync_all()?;
6060    Ok(target)
6061}
6062
6063#[cfg(windows)]
6064fn download_presigned_to_cache(
6065    cfg: &HubConfig,
6066    url: &str,
6067    cache_dir: &Path,
6068    sha256: &str,
6069    expected_bytes: u64,
6070) -> LinkResult<PathBuf> {
6071    use std::fs::OpenOptions;
6072
6073    let target = cache_dir.join(sha256);
6074    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6075        return Ok(target);
6076    }
6077    // Keep the checked directory handle alive while the private cache path is
6078    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
6079    // an ancestor can be swapped after validation.
6080    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6081    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6082    let mut output = OpenOptions::new()
6083        .write(true)
6084        .create_new(true)
6085        .open(&temp)?;
6086    let response = match presigned_agent(cfg, url)?.get(url).call() {
6087        Ok(response) => response,
6088        Err(ureq::Error::Status(_, response)) => {
6089            let _ = std::fs::remove_file(&temp);
6090            return Err(LinkError::Http {
6091                what: "v2 direct download",
6092                status: response.status(),
6093                message: "object store rejected the download".to_string(),
6094                code: None,
6095                details: None,
6096            });
6097        }
6098        Err(ureq::Error::Transport(error)) => {
6099            let _ = std::fs::remove_file(&temp);
6100            return Err(LinkError::Transport {
6101                hub: cfg.hub.clone(),
6102                message: error.to_string(),
6103            });
6104        }
6105    };
6106    let mut reader = response
6107        .into_reader()
6108        .take(expected_bytes.saturating_add(1));
6109    let mut digest = Sha256::new();
6110    let mut total = 0_u64;
6111    let mut buffer = [0_u8; 64 * 1024];
6112    // Same split as the cache-fill path: a stalled socket is not a disk error.
6113    let copied = (|| -> LinkResult<()> {
6114        loop {
6115            let read = reader
6116                .read(&mut buffer)
6117                .map_err(|error| LinkError::Transport {
6118                    hub: cfg.hub.clone(),
6119                    message: error.to_string(),
6120                })?;
6121            if read == 0 {
6122                break;
6123            }
6124            total = total.saturating_add(read as u64);
6125            digest.update(&buffer[..read]);
6126            output.write_all(&buffer[..read])?;
6127        }
6128        output.sync_all()?;
6129        Ok(())
6130    })();
6131    if let Err(error) = copied {
6132        let _ = std::fs::remove_file(&temp);
6133        return Err(error);
6134    }
6135    drop(output);
6136    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6137        let _ = std::fs::remove_file(&temp);
6138        return Err(invalid_feed(
6139            "v2 direct download failed integrity verification",
6140        ));
6141    }
6142    if target.exists() {
6143        std::fs::remove_file(&target)?;
6144    }
6145    if let Err(error) = std::fs::rename(&temp, &target) {
6146        let _ = std::fs::remove_file(&temp);
6147        return Err(error.into());
6148    }
6149    Ok(target)
6150}
6151
6152#[cfg(not(any(unix, windows)))]
6153fn download_presigned_to_cache(
6154    _cfg: &HubConfig,
6155    _url: &str,
6156    _cache_dir: &Path,
6157    _sha256: &str,
6158    _expected_bytes: u64,
6159) -> LinkResult<PathBuf> {
6160    Err(LinkError::UnsupportedPlatform {
6161        operation: "resumable v2 download staging",
6162    })
6163}
6164
6165fn download_v2_blobs(
6166    cfg: &HubConfig,
6167    brain: &str,
6168    pointer: &V2PointerBody,
6169    pending: Vec<(&String, &V2BaselineFile)>,
6170) -> LinkResult<Vec<(String, Vec<u8>)>> {
6171    if pending.is_empty() {
6172        return Ok(Vec::new());
6173    }
6174    let expected_order = pending
6175        .iter()
6176        .map(|(path, _)| (*path).clone())
6177        .collect::<Vec<_>>();
6178    let mut streamed = std::collections::BTreeMap::new();
6179    let mut direct = Vec::new();
6180    let mut window = Vec::new();
6181    let mut window_bytes = 0_u64;
6182    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6183                 window_bytes: &mut u64,
6184                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6185     -> LinkResult<()> {
6186        if window.is_empty() {
6187            return Ok(());
6188        }
6189        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6190            if streamed.insert(path, bytes).is_some() {
6191                return Err(invalid_feed("v2 bulk streams repeated a path"));
6192            }
6193        }
6194        window.clear();
6195        *window_bytes = 0;
6196        Ok(())
6197    };
6198    for &(path, file) in &pending {
6199        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6200            flush(&mut window, &mut window_bytes, &mut streamed)?;
6201            direct.push((path, file));
6202            continue;
6203        }
6204        if window.len() == V2_BULK_STREAM_FILES
6205            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6206        {
6207            flush(&mut window, &mut window_bytes, &mut streamed)?;
6208        }
6209        window.push((path, file));
6210        window_bytes += file.bytes;
6211    }
6212    flush(&mut window, &mut window_bytes, &mut streamed)?;
6213
6214    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6215    let next = std::sync::atomic::AtomicUsize::new(0);
6216    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6217    let mut results = std::iter::repeat_with(|| None)
6218        .take(downloads.len())
6219        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6220    std::thread::scope(|scope| {
6221        let (sender, receiver) = std::sync::mpsc::channel();
6222        for _ in 0..worker_count {
6223            let sender = sender.clone();
6224            let downloads = &downloads;
6225            let next = &next;
6226            scope.spawn(move || loop {
6227                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6228                let Some(item) = downloads.get(index) else {
6229                    break;
6230                };
6231                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6232                if sender.send((index, result)).is_err() {
6233                    break;
6234                }
6235            });
6236        }
6237        drop(sender);
6238        for (index, result) in receiver {
6239            results[index] = Some(result);
6240        }
6241    });
6242    for result in results.into_iter().map(|result| {
6243        result.ok_or_else(|| LinkError::Transport {
6244            hub: cfg.hub.clone(),
6245            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6246        })?
6247    }) {
6248        let (path, bytes) = result?;
6249        if streamed.insert(path, bytes).is_some() {
6250            return Err(invalid_feed("v2 download lanes repeated a path"));
6251        }
6252    }
6253    expected_order
6254        .into_iter()
6255        .map(|path| {
6256            streamed
6257                .remove(&path)
6258                .map(|bytes| (path, bytes))
6259                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6260        })
6261        .collect()
6262}
6263
6264/// Resume an exact-head materialization into the private global cache. Each
6265/// verified frame is durable before the next network window begins, so a
6266/// retry skips it and memory never grows with total changed bytes.
6267#[cfg(any(unix, windows))]
6268fn stage_v2_blobs(
6269    cfg: &HubConfig,
6270    brain: &str,
6271    pointer: &V2PointerBody,
6272    pending: Vec<(&String, &V2BaselineFile)>,
6273) -> LinkResult<Vec<V2StagedFile>> {
6274    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6275    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6276    let mut direct = Vec::new();
6277    let mut window = Vec::new();
6278    let mut window_bytes = 0_u64;
6279    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6280                 window_bytes: &mut u64,
6281                 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6282     -> LinkResult<()> {
6283        if window.is_empty() {
6284            return Ok(());
6285        }
6286        let missing = window
6287            .iter()
6288            .filter_map(|(path, file)| {
6289                let target = cache_dir.join(&file.sha256);
6290                match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6291                    Ok(true) => {
6292                        staged.insert(
6293                            (*path).clone(),
6294                            V2StagedFile {
6295                                path: (*path).clone(),
6296                                source: target,
6297                                sha256: file.sha256.clone(),
6298                                bytes: file.bytes,
6299                            },
6300                        );
6301                        None
6302                    }
6303                    Ok(false) => Some(Ok((*path, *file))),
6304                    Err(error) => Some(Err(error)),
6305                }
6306            })
6307            .collect::<LinkResult<Vec<_>>>()?;
6308        if !missing.is_empty() {
6309            for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6310                let file = missing
6311                    .iter()
6312                    .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6313                    .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6314                let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6315                staged.insert(
6316                    path.clone(),
6317                    V2StagedFile {
6318                        path,
6319                        source,
6320                        sha256: file.sha256.clone(),
6321                        bytes: file.bytes,
6322                    },
6323                );
6324            }
6325        }
6326        window.clear();
6327        *window_bytes = 0;
6328        Ok(())
6329    };
6330    for &(path, file) in &pending {
6331        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6332            flush(&mut window, &mut window_bytes, &mut staged)?;
6333            direct.push((path, file));
6334            continue;
6335        }
6336        if window.len() == V2_BULK_STREAM_FILES
6337            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6338        {
6339            flush(&mut window, &mut window_bytes, &mut staged)?;
6340        }
6341        window.push((path, file));
6342        window_bytes += file.bytes;
6343    }
6344    flush(&mut window, &mut window_bytes, &mut staged)?;
6345    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6346        let source =
6347            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6348        staged.insert(
6349            item.path.clone(),
6350            V2StagedFile {
6351                path: item.path,
6352                source,
6353                sha256: item.sha256,
6354                bytes: item.bytes,
6355            },
6356        );
6357    }
6358    pending
6359        .into_iter()
6360        .map(|(path, _)| {
6361            staged
6362                .remove(path)
6363                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6364        })
6365        .collect()
6366}
6367
6368#[cfg(not(any(unix, windows)))]
6369fn stage_v2_blobs(
6370    _cfg: &HubConfig,
6371    _brain: &str,
6372    _pointer: &V2PointerBody,
6373    _pending: Vec<(&String, &V2BaselineFile)>,
6374) -> LinkResult<Vec<V2StagedFile>> {
6375    Err(LinkError::UnsupportedPlatform {
6376        operation: "resumable v2 download staging",
6377    })
6378}
6379
6380const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6381const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6382const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6383
6384#[derive(Debug, Clone, Deserialize, Serialize)]
6385struct V2ConflictCoordinate {
6386    sha256: Option<String>,
6387    bytes: Option<u64>,
6388    file: Option<String>,
6389}
6390
6391#[derive(Debug, Clone, Deserialize, Serialize)]
6392struct V2ConflictFile {
6393    path: String,
6394    base: V2ConflictCoordinate,
6395    local: V2ConflictCoordinate,
6396    remote: V2ConflictCoordinate,
6397}
6398
6399#[derive(Debug, Clone, Deserialize, Serialize)]
6400struct V2ConflictPlan {
6401    v: u8,
6402    class: String,
6403    bundle: String,
6404    brain: String,
6405    origin: String,
6406    created_unix: u64,
6407    expires_unix: u64,
6408    base_seq: Option<u64>,
6409    base_commit: Option<String>,
6410    remote_seq: u64,
6411    remote_commit: Option<String>,
6412    remote_content_root: Option<String>,
6413    view_kind: String,
6414    view_revision: String,
6415    files: Vec<V2ConflictFile>,
6416}
6417
6418fn v2_take_remote_selection(
6419    files: &[V2ConflictFile],
6420    current: &std::collections::BTreeMap<String, V2BaselineFile>,
6421) -> LinkResult<(
6422    std::collections::BTreeMap<String, V2BaselineFile>,
6423    Vec<String>,
6424)> {
6425    let mut selected = std::collections::BTreeMap::new();
6426    let mut deleted = Vec::new();
6427    for file in files {
6428        match (&file.remote.sha256, file.remote.bytes) {
6429            (Some(sha256), Some(bytes)) => {
6430                let proven = current.get(&file.path).ok_or_else(|| {
6431                    invalid_feed("conflict remote coordinate disappeared from the exact head")
6432                })?;
6433                if proven.sha256 != *sha256 || proven.bytes != bytes {
6434                    return Err(invalid_feed(
6435                        "conflict remote coordinate differs from the exact head",
6436                    ));
6437                }
6438                if selected.insert(file.path.clone(), proven.clone()).is_some() {
6439                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6440                }
6441            }
6442            (None, None) => {
6443                if current.contains_key(&file.path) {
6444                    return Err(invalid_feed(
6445                        "conflict remote deletion differs from the exact head",
6446                    ));
6447                }
6448                deleted.push(file.path.clone());
6449            }
6450            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6451        }
6452    }
6453    Ok((selected, deleted))
6454}
6455
6456fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6457    PathBuf::from(".dbmd")
6458        .join("conflicts")
6459        .join(bundle)
6460        .join(suffix)
6461}
6462
6463fn read_historical_conflict_blob(
6464    cfg: &HubConfig,
6465    brain: &str,
6466    baseline: &V2SyncBaseline,
6467    path: &str,
6468    file: &V2BaselineFile,
6469) -> LinkResult<Option<Vec<u8>>> {
6470    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6471        return Ok(None);
6472    };
6473    if seq == 0 {
6474        return Ok(None);
6475    }
6476    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6477    let endpoint = format!(
6478        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6479        file.sha256
6480    );
6481    let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6482    if raw.status == 404 || raw.status == 403 {
6483        return Ok(None);
6484    }
6485    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6486    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6487        return Err(invalid_feed(
6488            "v2 conflict base failed integrity verification",
6489        ));
6490    }
6491    Ok(Some(bytes))
6492}
6493
6494/// Persist a readable content conflict without modifying either side. The
6495/// plan is written last, so an interrupted bundle build is never actionable.
6496fn create_v2_conflict_bundle(
6497    cfg: &HubConfig,
6498    store: &Store,
6499    head: &V2VerifiedHead,
6500    baseline: Option<&V2SyncBaseline>,
6501    local: &std::collections::BTreeMap<String, (String, u64)>,
6502    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6503    paths: &[String],
6504) -> LinkResult<(String, Vec<String>)> {
6505    let conflicts_root = Path::new(".dbmd/conflicts");
6506    store.create_dir_all(conflicts_root)?;
6507    let completed = store
6508        .directory_names(conflicts_root)?
6509        .into_iter()
6510        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6511        .count();
6512    if completed >= V2_CONFLICT_BUNDLE_MAX {
6513        return Err(LinkError::InvalidPack {
6514            message: format!(
6515                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6516            ),
6517        });
6518    }
6519
6520    // A conflict bundle is diagnostic state, not a second whole-brain cache.
6521    // Materialize a deterministic prefix whose remote artifacts fit one
6522    // bounded window. Resolving it exposes the next prefix on the next sync.
6523    let mut selected_paths = Vec::new();
6524    let mut selected_remote_bytes = 0_u64;
6525    for path in paths {
6526        let bytes = remote.get(path).map_or(0, |file| file.bytes);
6527        if !selected_paths.is_empty()
6528            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6529        {
6530            break;
6531        }
6532        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6533        selected_paths.push(path.clone());
6534        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6535            break;
6536        }
6537    }
6538    if selected_paths.is_empty() {
6539        return Err(invalid_feed("content conflict set is empty"));
6540    }
6541    let bundle = crate::ulid::mint();
6542    let bundle_root = v2_conflict_relative(&bundle, "");
6543    store.create_dir_all(&bundle_root.join("files"))?;
6544    let pointer = head.pointer.as_ref();
6545    let remote_bytes = match pointer {
6546        Some(pointer) => download_v2_blobs(
6547            cfg,
6548            &head.brain_id,
6549            pointer,
6550            selected_paths
6551                .iter()
6552                .filter_map(|path| {
6553                    remote
6554                        .get(path)
6555                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6556                        .map(|file| (path, file))
6557                })
6558                .collect(),
6559        )?
6560        .into_iter()
6561        .collect::<std::collections::BTreeMap<_, _>>(),
6562        None => std::collections::BTreeMap::new(),
6563    };
6564
6565    let mut files = Vec::with_capacity(selected_paths.len());
6566    for (index, path) in selected_paths.iter().enumerate() {
6567        let base_file = baseline.and_then(|state| state.files.get(path));
6568        let base_bytes = match (baseline, base_file) {
6569            (Some(state), Some(file)) => {
6570                read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6571            }
6572            _ => None,
6573        };
6574        let local_file = local.get(path);
6575        let remote_file = remote.get(path);
6576        let remote_content = remote_bytes.get(path);
6577        let prefix = format!("files/{index:04}");
6578        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6579        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6580        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6581        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6582            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6583        }
6584        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6585            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6586            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6587                return Err(LinkError::InvalidPack {
6588                    message: format!("local conflict path `{path}` changed while bundling"),
6589                });
6590            }
6591            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6592        }
6593        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6594            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6595        }
6596        files.push(V2ConflictFile {
6597            path: path.clone(),
6598            base: V2ConflictCoordinate {
6599                sha256: base_file.map(|file| file.sha256.clone()),
6600                bytes: base_file.map(|file| file.bytes),
6601                file: base_name,
6602            },
6603            local: V2ConflictCoordinate {
6604                sha256: local_file.map(|(sha256, _)| sha256.clone()),
6605                bytes: local_file.map(|(_, bytes)| *bytes),
6606                file: local_name,
6607            },
6608            remote: V2ConflictCoordinate {
6609                sha256: remote_file.map(|file| file.sha256.clone()),
6610                bytes: remote_file.map(|file| file.bytes),
6611                file: remote_name,
6612            },
6613        });
6614    }
6615    let now = SystemTime::now()
6616        .duration_since(UNIX_EPOCH)
6617        .unwrap_or_default()
6618        .as_secs();
6619    let plan = V2ConflictPlan {
6620        v: 2,
6621        class: "content_resolution_required".to_string(),
6622        bundle: bundle.clone(),
6623        brain: head.brain_id.clone(),
6624        origin: normalized_origin(&cfg.hub)?,
6625        created_unix: now,
6626        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6627        base_seq: baseline.and_then(|state| state.head_seq),
6628        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6629        remote_seq: pointer.map_or(0, |value| value.seq),
6630        remote_commit: pointer.map(|value| value.commit_hash.clone()),
6631        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6632        view_kind: head.view_kind.clone(),
6633        view_revision: head.view_revision.clone(),
6634        files,
6635    };
6636    let mut bytes = serde_json::to_vec_pretty(&plan)
6637        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6638    bytes.push(b'\n');
6639    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6640    Ok((bundle, selected_paths))
6641}
6642
6643fn v2_sync_pull_with_resolution(
6644    cfg: &HubConfig,
6645    requested_brain: &str,
6646    expected_head: V2VerifiedHead,
6647    out: Option<&Path>,
6648    take_remote: Option<&std::collections::BTreeSet<String>>,
6649) -> LinkResult<V2PulledSnapshot> {
6650    let dest = out
6651        .map(Path::to_path_buf)
6652        .unwrap_or_else(|| PathBuf::from(requested_brain));
6653    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6654    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6655    let head = v2_verified_head(cfg, requested_brain)?
6656        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6657    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6658        return Err(LinkError::RemoteAdvancedDuringSync);
6659    }
6660    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6661    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6662    let (remote, remote_assets) = match baseline
6663        .as_ref()
6664        .filter(|state| v2_baseline_matches_head(&head, state))
6665    {
6666        Some(state) => (state.files.clone(), state.assets.clone()),
6667        None => (
6668            files_for_v2_view(
6669                &head,
6670                v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6671            ),
6672            v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6673        ),
6674    };
6675    let local_store = Store::open_strict(&dest).ok();
6676    // A baseline proves this path is an established checkout. Never reinterpret
6677    // a damaged/edited store marker as an empty clone destination: doing so
6678    // would let the pull half erase the local evidence before the push half can
6679    // reject it (most importantly for the generated scoped-view DB.md).
6680    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6681    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6682    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6683        return Err(LinkError::ScopedViewChanged);
6684    }
6685    if let Some(view) = local_view.as_mut() {
6686        remove_scoped_projection(&head, baseline.as_ref(), view)?;
6687    }
6688    let empty_local = std::collections::BTreeMap::new();
6689    let local = local_view
6690        .as_ref()
6691        .map_or(&empty_local, |view| &view.riding);
6692    let kept_home = |path: &str| {
6693        local_view
6694            .as_ref()
6695            .is_some_and(|view| view.policy.keeps_home(path))
6696    };
6697    let empty_base = std::collections::BTreeMap::new();
6698    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6699    let empty_base_assets = std::collections::BTreeMap::new();
6700    let base_assets = baseline
6701        .as_ref()
6702        .map_or(&empty_base_assets, |state| &state.assets);
6703    let mut local_assets = local_store
6704        .as_ref()
6705        .map(v2_local_asset_records)
6706        .transpose()?
6707        .unwrap_or_default();
6708    let mut content_merge = merge_v2_pulled_records(
6709        base,
6710        &remote,
6711        local,
6712        |file, _| (file.sha256.clone(), file.bytes),
6713        |file, _| (file.sha256.clone(), file.bytes),
6714        kept_home,
6715    );
6716    if let Some(selected) = take_remote {
6717        for path in selected {
6718            if let Some(position) = content_merge
6719                .conflicts
6720                .iter()
6721                .position(|conflict| conflict == path)
6722            {
6723                content_merge.conflicts.remove(position);
6724                content_merge.accept_remote.insert(path.clone());
6725                match remote.get(path) {
6726                    Some(file) => {
6727                        content_merge
6728                            .records
6729                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
6730                    }
6731                    None => {
6732                        content_merge.records.remove(path);
6733                    }
6734                }
6735            } else if !content_merge.accept_remote.contains(path) {
6736                return Err(LinkError::InvalidPack {
6737                    message: format!(
6738                        "take-remote path `{path}` is no longer at its conflict coordinate"
6739                    ),
6740                });
6741            }
6742        }
6743    }
6744    if !content_merge.conflicts.is_empty() {
6745        let mut conflicts = content_merge.conflicts.clone();
6746        conflicts.truncate(100);
6747        if let Some(store) = local_store.as_ref() {
6748            let (bundle, paths) = create_v2_conflict_bundle(
6749                cfg,
6750                store,
6751                &head,
6752                baseline.as_ref(),
6753                local,
6754                &remote,
6755                &conflicts,
6756            )?;
6757            return Err(LinkError::ConflictBundle { bundle, paths });
6758        }
6759        return Err(LinkError::Conflict { paths: conflicts });
6760    }
6761    let asset_merge = merge_v2_pulled_records(
6762        base_assets,
6763        &remote_assets,
6764        &local_assets,
6765        v2_asset_record,
6766        v2_asset_record,
6767        |_| false,
6768    );
6769    if !asset_merge.conflicts.is_empty() {
6770        let mut conflicts = asset_merge.conflicts.clone();
6771        conflicts.truncate(100);
6772        return Err(LinkError::Conflict { paths: conflicts });
6773    }
6774    let pointer = head.pointer.as_ref();
6775    let cache_transaction = pointer.map_or_else(
6776        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6777        |value| value.commit_hash.clone(),
6778    );
6779    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6780    let mut changed = match pointer {
6781        Some(pointer) => stage_v2_blobs(
6782            cfg,
6783            &head.brain_id,
6784            pointer,
6785            remote
6786                .iter()
6787                .filter(|(path, file)| {
6788                    content_merge.accept_remote.contains(*path)
6789                        && local.get(*path).map(|value| value.0.as_str())
6790                            != Some(file.sha256.as_str())
6791                })
6792                .collect(),
6793        )?,
6794        None => Vec::new(),
6795    };
6796    let mut deleted = content_merge
6797        .accept_remote
6798        .iter()
6799        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6800        .cloned()
6801        .collect::<Vec<_>>();
6802    if local_assets != asset_merge.records {
6803        if asset_merge.records.is_empty() {
6804            deleted.push("assets.jsonl".to_string());
6805        } else {
6806            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6807            let sha256 = content_sha256(&bytes);
6808            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6809            changed.push(V2StagedFile {
6810                path: "assets.jsonl".to_string(),
6811                source,
6812                sha256,
6813                bytes: bytes.len() as u64,
6814            });
6815        }
6816    }
6817    if let Some(pointer) = pointer {
6818        let mut pending_assets = Vec::new();
6819        for (path, asset) in &remote_assets {
6820            if asset.disposition != "hosted"
6821                || kept_home(path)
6822                || !asset_merge.accept_remote.contains(path)
6823            {
6824                continue;
6825            }
6826            let already_current = local_store.as_ref().is_some_and(|store| {
6827                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6828                    && store
6829                        .read_bounded(Path::new(path), asset.bytes)
6830                        .ok()
6831                        .is_some_and(|bytes| {
6832                            bytes.len() as u64 == asset.bytes
6833                                && content_sha256(&bytes) == asset.blob_sha256
6834                        })
6835            });
6836            if !already_current {
6837                pending_assets.push((path, asset));
6838            }
6839        }
6840        let mut window = Vec::new();
6841        let mut window_bytes = 0_u64;
6842        let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
6843                     window_bytes: &mut u64,
6844                     changed: &mut Vec<V2StagedFile>|
6845         -> LinkResult<()> {
6846            changed.extend(stage_v2_asset_download_window(
6847                cfg,
6848                &head.brain_id,
6849                pointer,
6850                &cache_dir,
6851                window,
6852            )?);
6853            window.clear();
6854            *window_bytes = 0;
6855            Ok(())
6856        };
6857        for item @ (_, asset) in pending_assets {
6858            if !window.is_empty()
6859                && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
6860                    || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
6861            {
6862                flush(&mut window, &mut window_bytes, &mut changed)?;
6863            }
6864            window.push(item);
6865            window_bytes = window_bytes.saturating_add(asset.bytes);
6866            if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
6867                flush(&mut window, &mut window_bytes, &mut changed)?;
6868            }
6869        }
6870        flush(&mut window, &mut window_bytes, &mut changed)?;
6871    }
6872    for (path, prior) in base_assets {
6873        if remote_assets.contains_key(path)
6874            || kept_home(path)
6875            || !asset_merge.accept_remote.contains(path)
6876        {
6877            continue;
6878        }
6879        let unchanged = local_store.as_ref().is_some_and(|store| {
6880            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6881                && store
6882                    .read_bounded(Path::new(path), prior.bytes)
6883                    .ok()
6884                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6885        });
6886        if unchanged {
6887            deleted.push(path.clone());
6888        }
6889    }
6890    let extra_local = content_merge
6891        .records
6892        .keys()
6893        .filter(|path| !remote.contains_key(*path))
6894        .cloned()
6895        .collect::<Vec<_>>();
6896    if head.view_kind == "scoped" {
6897        for (path, bytes) in [
6898            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6899            (
6900                ".dbmd/view.json".to_string(),
6901                scoped_view_metadata(&head, remote.len())?,
6902            ),
6903        ] {
6904            let sha256 = content_sha256(&bytes);
6905            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6906            changed.push(V2StagedFile {
6907                path,
6908                source,
6909                sha256,
6910                bytes: bytes.len() as u64,
6911            });
6912        }
6913    }
6914    let install_changed = !changed.is_empty() || !deleted.is_empty();
6915    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6916    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6917        let installed_store =
6918            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6919                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6920            })?;
6921        let installed_local = if install_changed {
6922            let mut scanned = v2_local_files(&installed_store)?;
6923            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6924            scanned
6925        } else {
6926            local_view
6927                .take()
6928                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6929        };
6930        if installed_local.riding != content_merge.records {
6931            return Err(LinkError::InvalidPack {
6932                message: "local content changed while installing the v2 pull".to_string(),
6933            });
6934        }
6935        let installed_assets = if install_changed {
6936            v2_local_asset_records(&installed_store)?
6937        } else {
6938            std::mem::take(&mut local_assets)
6939        };
6940        if installed_assets != asset_merge.records {
6941            return Err(LinkError::InvalidPack {
6942                message: "local assets changed while installing the v2 pull".to_string(),
6943            });
6944        }
6945        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6946            installed_local.policy.keeps_home(path)
6947        })
6948            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6949        let final_head = v2_verified_head(cfg, requested_brain)?
6950            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6951        if !same_v2_head(&head, &final_head) {
6952            return Err(LinkError::RemoteAdvancedDuringSync);
6953        }
6954        accept_v2_head(cfg, &final_head)?;
6955        save_v2_baseline(
6956            cfg,
6957            &head.brain_id,
6958            &dest,
6959            &v2_baseline_from_head(
6960                cfg,
6961                &head,
6962                remote.clone(),
6963                remote_assets.clone(),
6964                Some(&installed_local),
6965                baseline
6966                    .as_ref()
6967                    .and_then(|current| current.checkout_id.as_deref()),
6968            )?,
6969        )?;
6970        complete_v2_pull(&dest)?;
6971        Ok((local_dirty, installed_local, installed_assets))
6972    })();
6973    let (local_dirty, installed_local, installed_assets) = match finalized {
6974        Ok(value) => value,
6975        Err(error) => {
6976            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6977                return Err(LinkError::InvalidPack {
6978                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
6979                });
6980            }
6981            return Err(error);
6982        }
6983    };
6984    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6985    let report = PullReport {
6986        brain: head.brain_id.clone(),
6987        slug: requested_brain.to_string(),
6988        head_seq: pointer.map_or(0, |value| value.seq),
6989        files: remote.len() + remote_assets.len(),
6990        dest: dest.to_string_lossy().into_owned(),
6991        extra_local,
6992        sync_status: if local_dirty {
6993            "local_dirty_after_install".to_string()
6994        } else {
6995            "synced".to_string()
6996        },
6997    };
6998    Ok(V2PulledSnapshot {
6999        report,
7000        head,
7001        files: remote,
7002        assets: remote_assets,
7003        local: installed_local,
7004        local_assets: installed_assets,
7005    })
7006}
7007
7008fn v2_sync_pull(
7009    cfg: &HubConfig,
7010    requested_brain: &str,
7011    head: V2VerifiedHead,
7012    out: Option<&Path>,
7013) -> LinkResult<PullReport> {
7014    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
7015}
7016
7017fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
7018    match remote {
7019        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
7020        None => json!({ "kind": "absent" }),
7021    }
7022}
7023
7024fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7025    match remote {
7026        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7027        None => json!({ "kind": "absent" }),
7028    }
7029}
7030
7031fn v2_content_withdrawal_operation(
7032    store: &Store,
7033    local_view: &V2LocalView,
7034    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7035    path: &str,
7036    reason: &str,
7037) -> LinkResult<Value> {
7038    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7039        || path == "DB.md"
7040    {
7041        return Err(LinkError::InvalidPack {
7042            message: format!("content withdrawal path `{path}` is not a record or source"),
7043        });
7044    }
7045    if !local_view.policy.keeps_home(path)
7046        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7047    {
7048        return Err(LinkError::InvalidPack {
7049            message: format!(
7050                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7051            ),
7052        });
7053    }
7054    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7055        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7056    })?;
7057    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
7058    Ok(json!({
7059        "op": "withdraw_from_hosting",
7060        "path": path,
7061        "expected": { "kind": "blob", "hash": current.sha256 },
7062        "reason": reason,
7063    }))
7064}
7065
7066fn v2_asset_withdrawal_operation(
7067    store: &Store,
7068    local_view: &V2LocalView,
7069    path: &str,
7070    local: &crate::AssetRecord,
7071    current: &V2BaselineAsset,
7072    reason: &str,
7073) -> LinkResult<Value> {
7074    if !local_view.policy.keeps_home(path)
7075        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7076    {
7077        return Err(LinkError::InvalidPack {
7078            message: format!(
7079                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7080            ),
7081        });
7082    }
7083    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7084    if current.disposition != "hosted"
7085        || current.blob_sha256 != local.sha256
7086        || current.bytes != local.bytes
7087        || current.media_type != local.media_type
7088    {
7089        return Err(LinkError::InvalidPack {
7090            message: format!(
7091                "asset withdrawal path `{path}` must preserve the currently hosted blob identity, byte count, and media type"
7092            ),
7093        });
7094    }
7095    Ok(json!({
7096        "op": "asset_withdraw",
7097        "path": path,
7098        "expected": v2_asset_expected(Some(current)),
7099        "asset": v2_asset_value(local, "withheld"),
7100        "reason": reason,
7101    }))
7102}
7103
7104// A source is immutable, but its exact bytes may move from an intake namespace
7105// to an archive namespace under the distinct `promote_source` capability. A
7106// plain filesystem move appears to sync as one absent old path plus one new
7107// path. Preserve that intent as a protocol rename only when the hash has one
7108// unambiguous source delete and one unambiguous absent-destination source put.
7109// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
7110fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7111    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7112    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7113    for (index, operation) in operations.iter().enumerate() {
7114        match operation.get("op").and_then(Value::as_str) {
7115            Some("delete") => {
7116                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7117                    continue;
7118                };
7119                let Some(hash) = operation
7120                    .get("expected")
7121                    .and_then(|value| value.get("hash"))
7122                    .and_then(Value::as_str)
7123                else {
7124                    continue;
7125                };
7126                if path.starts_with("sources/") {
7127                    deletes
7128                        .entry(hash.to_string())
7129                        .or_default()
7130                        .push((index, path.to_string()));
7131                }
7132            }
7133            Some("put") => {
7134                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7135                    continue;
7136                };
7137                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7138                    continue;
7139                };
7140                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7141                    continue;
7142                };
7143                let destination_absent = operation
7144                    .get("expected")
7145                    .and_then(|value| value.get("kind"))
7146                    .and_then(Value::as_str)
7147                    == Some("absent");
7148                if path.starts_with("sources/") && destination_absent {
7149                    puts.entry(hash.to_string()).or_default().push((
7150                        index,
7151                        path.to_string(),
7152                        bytes,
7153                    ));
7154                }
7155            }
7156            _ => {}
7157        }
7158    }
7159    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7160    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7161    for (hash, source) in deletes {
7162        let Some(destination) = puts.get(&hash) else {
7163            continue;
7164        };
7165        if source.len() != 1 || destination.len() != 1 {
7166            continue;
7167        }
7168        let (delete_index, from) = &source[0];
7169        let (put_index, to, bytes) = &destination[0];
7170        if from == to {
7171            continue;
7172        }
7173        rename_at.insert(
7174            *delete_index,
7175            json!({
7176                "op": "rename",
7177                "from": from,
7178                "to": to,
7179                "expected_from": { "kind": "blob", "hash": hash },
7180                "expected_to": { "kind": "absent" },
7181                "blob": hash,
7182                "bytes": bytes,
7183            }),
7184        );
7185        consumed_puts.insert(*put_index);
7186    }
7187    operations
7188        .into_iter()
7189        .enumerate()
7190        .filter_map(|(index, operation)| {
7191            if let Some(rename) = rename_at.remove(&index) {
7192                Some(rename)
7193            } else if consumed_puts.contains(&index) {
7194                None
7195            } else {
7196                Some(operation)
7197            }
7198        })
7199        .collect()
7200}
7201
7202fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7203    json!({
7204        "blob_sha256": record.sha256,
7205        "bytes": record.bytes,
7206        "media_type": record.media_type,
7207        "wrappers": record.wrappers,
7208        "required": record.required,
7209        "disposition": disposition,
7210    })
7211}
7212
7213/// Apply the exact operation subset generated by this client to an already
7214/// verified manifest. This avoids downloading the same 50k-path listing again
7215/// after an unrebased commit; a rebased response still re-reads the manifest.
7216fn apply_generated_v2_operations(
7217    operations: &[Value],
7218    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7219    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7220    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7221) -> LinkResult<bool> {
7222    let mut asset_changed = false;
7223    for operation in operations {
7224        match operation.get("op").and_then(Value::as_str) {
7225            Some("put") => {
7226                let path = operation
7227                    .get("path")
7228                    .and_then(Value::as_str)
7229                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7230                let sha256 = operation
7231                    .get("blob")
7232                    .and_then(Value::as_str)
7233                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7234                let bytes = operation
7235                    .get("bytes")
7236                    .and_then(Value::as_u64)
7237                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7238                candidate.insert(
7239                    path.to_string(),
7240                    V2BaselineFile {
7241                        sha256: sha256.to_string(),
7242                        bytes,
7243                        proof: None,
7244                    },
7245                );
7246            }
7247            Some("rename") => {
7248                let from = operation
7249                    .get("from")
7250                    .and_then(Value::as_str)
7251                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7252                let to = operation
7253                    .get("to")
7254                    .and_then(Value::as_str)
7255                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7256                let sha256 = operation
7257                    .get("blob")
7258                    .and_then(Value::as_str)
7259                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7260                let bytes = operation
7261                    .get("bytes")
7262                    .and_then(Value::as_u64)
7263                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7264                let expected_from = operation
7265                    .get("expected_from")
7266                    .and_then(|expected| expected.get("hash"))
7267                    .and_then(Value::as_str);
7268                let expected_to_absent = operation
7269                    .get("expected_to")
7270                    .and_then(|expected| expected.get("kind"))
7271                    .and_then(Value::as_str)
7272                    == Some("absent");
7273                if from == to
7274                    || !from.starts_with("sources/")
7275                    || !to.starts_with("sources/")
7276                    || expected_from != Some(sha256)
7277                    || !expected_to_absent
7278                    || candidate.contains_key(to)
7279                {
7280                    return Err(invalid_feed("generated v2 source rename is malformed"));
7281                }
7282                let source = candidate
7283                    .remove(from)
7284                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7285                if source.sha256 != sha256 || source.bytes != bytes {
7286                    return Err(invalid_feed(
7287                        "v2 rename source differs from its exact-byte claim",
7288                    ));
7289                }
7290                candidate.insert(
7291                    to.to_string(),
7292                    V2BaselineFile {
7293                        sha256: sha256.to_string(),
7294                        bytes,
7295                        proof: None,
7296                    },
7297                );
7298            }
7299            Some("delete" | "withdraw_from_hosting") => {
7300                let path = operation
7301                    .get("path")
7302                    .and_then(Value::as_str)
7303                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7304                candidate.remove(path);
7305            }
7306            Some("asset_delete") => {
7307                let path = operation
7308                    .get("path")
7309                    .and_then(Value::as_str)
7310                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7311                candidate_assets.remove(path);
7312                asset_changed = true;
7313            }
7314            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7315                let path = operation
7316                    .get("path")
7317                    .and_then(Value::as_str)
7318                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7319                let record = local_assets
7320                    .get(path)
7321                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7322                let disposition =
7323                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7324                        "withheld"
7325                    } else {
7326                        operation
7327                            .get("asset")
7328                            .and_then(|asset| asset.get("disposition"))
7329                            .and_then(Value::as_str)
7330                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7331                    };
7332                candidate_assets.insert(
7333                    path.to_string(),
7334                    V2BaselineAsset {
7335                        blob_sha256: record.sha256.clone(),
7336                        bytes: record.bytes,
7337                        media_type: record.media_type.clone(),
7338                        wrappers: record.wrappers.clone(),
7339                        required: record.required,
7340                        disposition: disposition.to_string(),
7341                        // The server assigns the signed leaf address. Callers
7342                        // that need an exact asset manifest re-read it below.
7343                        leaf_hash: String::new(),
7344                    },
7345                );
7346                asset_changed = true;
7347            }
7348            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7349        }
7350    }
7351    Ok(asset_changed)
7352}
7353
7354fn v2_riding_matches_remote(
7355    local: &std::collections::BTreeMap<String, (String, u64)>,
7356    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7357    keeps_home: impl Fn(&str) -> bool,
7358) -> bool {
7359    remote.iter().all(|(path, file)| {
7360        keeps_home(path)
7361            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7362    }) && local.iter().all(|(path, (hash, _))| {
7363        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7364    })
7365}
7366
7367fn v2_initial_content_conflicts(
7368    local: &std::collections::BTreeMap<String, (String, u64)>,
7369    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7370    resolving: bool,
7371) -> Vec<String> {
7372    if resolving {
7373        // `sync resolve` already pins the exact remote head/view and rechecks
7374        // every bundled local coordinate before it enters the push planner.
7375        // Treating this as an ordinary baseline-less push would merely mint an
7376        // identical conflict bundle forever. The operation loop below permits
7377        // only those explicit remote replacements plus safe local-only
7378        // additions, and verifies each selected local/remote pair again before
7379        // authoring the commit.
7380        return Vec::new();
7381    }
7382    remote
7383        .iter()
7384        .filter(|(path, file)| {
7385            local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7386        })
7387        .map(|(path, _)| path.clone())
7388        .collect()
7389}
7390
7391fn v2_resolution_allows_path(
7392    resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7393    path: &str,
7394    remote_present: bool,
7395) -> bool {
7396    resolution.is_none_or(|allowed| allowed.contains_key(path) || !remote_present)
7397}
7398
7399#[derive(Debug, Clone)]
7400struct V2ResolutionOverride {
7401    expected_remote: Option<String>,
7402    selected_local: Option<String>,
7403}
7404
7405#[derive(Debug, Clone)]
7406struct V2UploadSource {
7407    path: String,
7408    bytes: u64,
7409}
7410
7411struct V2SyncPushOptions<'a> {
7412    resume_local_policy: bool,
7413    bulk_confirmation: Option<&'a V2BulkConfirmation>,
7414    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7415    pulled: Option<V2PulledSnapshot>,
7416    withdrawal_paths: &'a [String],
7417    withdrawal_reason: Option<&'a str>,
7418}
7419
7420fn verify_v2_upload_source(
7421    store: &Store,
7422    path: &str,
7423    sha256: &str,
7424    expected_bytes: u64,
7425) -> LinkResult<()> {
7426    let file = store.open_regular(Path::new(path))?;
7427    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7428        return Err(LinkError::InvalidPack {
7429            message: format!("local path `{path}` changed during sync planning"),
7430        });
7431    }
7432    Ok(())
7433}
7434
7435/// One reserved object waiting to be staged, held until its whole batch can be
7436/// uploaded together.
7437struct V2PendingUpload<'a> {
7438    url: String,
7439    headers: Value,
7440    sha256: String,
7441    source: &'a V2UploadSource,
7442}
7443
7444/// How many objects a migration stages at once. Bounded so a first push does
7445/// not behave like a burst against the object store, while still being far
7446/// faster than one-at-a-time.
7447/// Workers staging one batch. With a shared pooled connection the cost of an
7448/// object is the PUT itself rather than a handshake, so a wider pool converts
7449/// directly into throughput on a migration.
7450const V2_UPLOAD_CONCURRENCY: usize = 16;
7451
7452/// Stage a reserved batch, uploading up to `V2_UPLOAD_CONCURRENCY` objects at a
7453/// time and verifying each against the store after it lands. The first failure
7454/// is returned and the remaining work is abandoned.
7455fn upload_v2_batch_concurrently(
7456    cfg: &HubConfig,
7457    store: &Store,
7458    pending: &[V2PendingUpload<'_>],
7459) -> LinkResult<()> {
7460    if pending.is_empty() {
7461        return Ok(());
7462    }
7463    let urls = pending
7464        .iter()
7465        .map(|task| task.url.as_str())
7466        .collect::<Vec<_>>();
7467    let shared = shared_staging_agent(cfg, &urls);
7468    if pending.len() == 1 {
7469        let task = &pending[0];
7470        put_presigned_source(
7471            cfg,
7472            &task.url,
7473            &task.headers,
7474            store,
7475            task.source,
7476            shared.as_ref(),
7477        )?;
7478        return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7479    }
7480    let next = std::sync::atomic::AtomicUsize::new(0);
7481    let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7482    let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7483    std::thread::scope(|scope| {
7484        for _ in 0..workers {
7485            scope.spawn(|| loop {
7486                if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7487                    return;
7488                }
7489                let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7490                let Some(task) = pending.get(index) else {
7491                    return;
7492                };
7493                let outcome = put_presigned_source(
7494                    cfg,
7495                    &task.url,
7496                    &task.headers,
7497                    store,
7498                    task.source,
7499                    shared.as_ref(),
7500                )
7501                .and_then(|()| {
7502                    verify_v2_upload_source(
7503                        store,
7504                        &task.source.path,
7505                        &task.sha256,
7506                        task.source.bytes,
7507                    )
7508                });
7509                if let Err(error) = outcome {
7510                    if let Ok(mut guard) = failure.lock() {
7511                        guard.get_or_insert(error);
7512                    }
7513                    return;
7514                }
7515            });
7516        }
7517    });
7518    match failure.into_inner() {
7519        Ok(Some(error)) => Err(error),
7520        Ok(None) => Ok(()),
7521        Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7522    }
7523}
7524
7525fn put_presigned_source(
7526    cfg: &HubConfig,
7527    raw: &str,
7528    headers: &Value,
7529    store: &Store,
7530    source: &V2UploadSource,
7531    shared: Option<&ureq::Agent>,
7532) -> LinkResult<()> {
7533    put_presigned_source_with_budget(
7534        cfg,
7535        raw,
7536        headers,
7537        store,
7538        source,
7539        shared,
7540        std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7541    )
7542}
7543
7544fn put_presigned_source_with_budget(
7545    cfg: &HubConfig,
7546    raw: &str,
7547    headers: &Value,
7548    store: &Store,
7549    source: &V2UploadSource,
7550    shared: Option<&ureq::Agent>,
7551    total_budget: std::time::Duration,
7552) -> LinkResult<()> {
7553    // The URL's own safety checks run either way; only the connection pool is
7554    // shared, so a batch never trades a check for a warm socket.
7555    let owned = match shared {
7556        Some(_) => {
7557            checked_presigned_url(cfg, raw)?;
7558            None
7559        }
7560        None => Some(presigned_agent(cfg, raw)?),
7561    };
7562    let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7563    let deadline = std::time::Instant::now()
7564        .checked_add(total_budget)
7565        .ok_or_else(upload_deadline_error)?;
7566    let mut attempt = 0;
7567    let result = loop {
7568        let file = store.open_regular(Path::new(&source.path))?;
7569        if file.metadata()?.len() != source.bytes {
7570            return Err(LinkError::InvalidPack {
7571                message: format!("local path `{}` changed before upload", source.path),
7572            });
7573        }
7574        // The reservation already states content-length, lowercased. The HTTP
7575        // client de-duplicates headers by exact name, not case, so setting
7576        // `Content-Length` here as well leaves both on the wire and an edge
7577        // rejects the request as malformed. Supply it only when absent.
7578        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7579        let mut has_content_length = false;
7580        if let Some(map) = headers.as_object() {
7581            for (name, value) in map {
7582                if let Some(value) = value.as_str() {
7583                    has_content_length |= name.eq_ignore_ascii_case("content-length");
7584                    req = req.set(name, value);
7585                }
7586            }
7587        }
7588        if !has_content_length {
7589            req = req.set("Content-Length", &source.bytes.to_string());
7590        }
7591        match req.send(file) {
7592            // Every transport failure is retryable here, not only the
7593            // pre-request kinds: staging an object is idempotent (fixed content
7594            // address, exact length and checksum, write-once precondition), the
7595            // file is reopened on each attempt, and a migration holds
7596            // connections long enough that a mid-request drop is ordinary.
7597            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7598                attempt += 1;
7599            }
7600            // A migration stages tens of thousands of immutable objects, and an
7601            // edge or object store may refuse a single one under sustained
7602            // load. The upload is content-addressed and precondition-guarded,
7603            // so replaying it is safe; failing the whole push on one refusal is
7604            // not. 412 already means "someone else stored these exact bytes".
7605            Err(ureq::Error::Status(status, _))
7606                if status != 412
7607                    && is_retryable_upload_status(status)
7608                    && wait_for_upload_retry(deadline, attempt) =>
7609            {
7610                attempt += 1;
7611            }
7612            result => break result,
7613        }
7614    };
7615    match result {
7616        Ok(response) if (200..300).contains(&response.status()) => {
7617            drain_presigned_response(response);
7618            Ok(())
7619        }
7620        Ok(response) => {
7621            // The object store states its own reason (checksum mismatch,
7622            // precondition, expiry). Carrying a bounded excerpt turns an
7623            // unactionable refusal into one an operator can diagnose; the
7624            // coordinate is the store path, never file content.
7625            let status = response.status();
7626            let detail = response
7627                .into_string()
7628                .ok()
7629                .map(|body| body.chars().take(400).collect::<String>())
7630                .filter(|body| !body.trim().is_empty());
7631            Err(LinkError::Http {
7632                what: "v2 changed-byte upload",
7633                status,
7634                message: match detail {
7635                    Some(body) => format!(
7636                        "object store rejected the upload of `{}`: {}",
7637                        source.path,
7638                        body.replace('\n', " ")
7639                    ),
7640                    None => format!("object store rejected the upload of `{}`", source.path),
7641                },
7642                code: None,
7643                details: None,
7644            })
7645        }
7646        Err(error) => match error {
7647            ureq::Error::Status(412, _) => Ok(()),
7648            ureq::Error::Status(_, response) => {
7649                let status = response.status();
7650                let detail = response
7651                    .into_string()
7652                    .ok()
7653                    .map(|body| body.chars().take(400).collect::<String>())
7654                    .filter(|body| !body.trim().is_empty());
7655                Err(LinkError::Http {
7656                    what: "v2 changed-byte upload",
7657                    status,
7658                    message: match detail {
7659                        Some(body) => format!(
7660                            "object store rejected the upload of `{}`: {}",
7661                            source.path,
7662                            body.replace('\n', " ")
7663                        ),
7664                        None => {
7665                            format!("object store rejected the upload of `{}`", source.path)
7666                        }
7667                    },
7668                    code: None,
7669                    details: None,
7670                })
7671            }
7672            ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7673        },
7674    }
7675}
7676
7677/// The request as it is hashed and signed. A staged push carries its operations
7678/// by address rather than inline, so the signed view restores them: the request
7679/// hash covers the change itself, never the transport that carried it.
7680fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7681    if body.get("operations").is_some() {
7682        return body.clone();
7683    }
7684    let mut value = body.clone();
7685    if let Some(map) = value.as_object_mut() {
7686        map.remove("staged_change");
7687        map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7688    }
7689    value
7690}
7691
7692/// Open one staging window, waiting out a busy hub rather than abandoning the
7693/// push. The call reserves capacity and mints URLs; it commits nothing, so
7694/// asking again after a refusal costs only the wait.
7695fn reserve_upload_window(
7696    cfg: &HubConfig,
7697    path: &str,
7698    body: &Value,
7699    what: &'static str,
7700) -> LinkResult<Value> {
7701    let mut attempt = 0;
7702    loop {
7703        let pause = |attempt: usize| {
7704            std::thread::sleep(std::time::Duration::from_millis(
7705                RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7706            ));
7707        };
7708        match request(cfg, "POST", path, Some(body), Auth::Required) {
7709            // A migration opens dozens of windows over tens of minutes, so one
7710            // response that never arrives is ordinary rather than final. The
7711            // call reserves capacity and mints URLs; it commits nothing, so a
7712            // dropped or timed-out attempt is worth asking again.
7713            Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7714                pause(attempt);
7715                attempt += 1;
7716            }
7717            Err(error) => return Err(error),
7718            Ok(response) => {
7719                if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7720                    pause(attempt);
7721                    attempt += 1;
7722                    continue;
7723                }
7724                return ensure_ok(response, what);
7725            }
7726        }
7727    }
7728}
7729
7730/// The exact bytes of a staged change. The hub refuses a manifest carrying any
7731/// other key, so this is the whole contract: operations, and the blob
7732/// references they need, and nothing else.
7733fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7734    let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7735        .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7736    if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7737        return Err(LinkError::PushTooLarge {
7738            detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7739        });
7740    }
7741    Ok(bytes)
7742}
7743
7744/// State one change's operations and blob references through the same
7745/// content-addressed lane its bytes already ride.
7746///
7747/// A first push of a real store names one operation and one blob reference per
7748/// file, which alone outgrows the bounded commit request. Staging that metadata
7749/// and addressing it by hash keeps the push a single atomic commit: the hub
7750/// verifies the address, splices the manifest in, and validates exactly what it
7751/// would have validated inline. The alternative — many partial commits — would
7752/// make a failed migration leave a half-populated brain readable.
7753fn stage_v2_change(
7754    cfg: &HubConfig,
7755    requested_brain: &str,
7756    operations: &[Value],
7757    blobs: Value,
7758) -> LinkResult<Value> {
7759    let bytes = v2_change_manifest(operations, blobs)?;
7760    let sha256 = content_sha256(&bytes);
7761    let reserved = reserve_upload_window(
7762        cfg,
7763        &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7764        &json!({
7765            "blobs": [{
7766                "sha256": sha256,
7767                "bytes": bytes.len(),
7768                "kind": "staged_change",
7769            }],
7770        }),
7771        "stage the v2 change",
7772    )?;
7773    let items = reserved
7774        .get("uploads")
7775        .and_then(Value::as_array)
7776        .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7777    let [item] = items.as_slice() else {
7778        return Err(invalid_feed(
7779            "v2 change staging response changed the requested set",
7780        ));
7781    };
7782    let reservation_id = item
7783        .get("reservation_id")
7784        .and_then(Value::as_str)
7785        .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7786    if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7787        || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7788        || !crate::ulid::is_ulid(reservation_id)
7789    {
7790        return Err(invalid_feed("v2 change staging item is inconsistent"));
7791    }
7792    match item.get("status").and_then(Value::as_str) {
7793        Some("upload") => put_presigned(
7794            cfg,
7795            item.get("url")
7796                .and_then(Value::as_str)
7797                .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7798            item.get("headers").unwrap_or(&Value::Null),
7799            &bytes,
7800        )?,
7801        Some("already_present") => {}
7802        _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7803    }
7804    Ok(json!({
7805        "sha256": sha256,
7806        "bytes": bytes.len(),
7807        "reservation_id": reservation_id,
7808    }))
7809}
7810
7811/// Move a commit request's operations and blob references off the wire body
7812/// when they no longer fit in it. Idempotent by construction: the mutation ID
7813/// is derived from the change, never from how the change was transported.
7814fn stage_oversized_v2_change(
7815    cfg: &HubConfig,
7816    requested_brain: &str,
7817    operations: &[Value],
7818    body: &mut Value,
7819) -> LinkResult<()> {
7820    if body.to_string().len() <= MAX_PUSH_BYTES {
7821        return Ok(());
7822    }
7823    let staged = stage_v2_change(
7824        cfg,
7825        requested_brain,
7826        operations,
7827        body.get("blobs")
7828            .cloned()
7829            .unwrap_or(Value::Array(Vec::new())),
7830    )?;
7831    let map = body
7832        .as_object_mut()
7833        .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7834    map.remove("operations");
7835    map.remove("blobs");
7836    map.insert("staged_change".to_string(), staged);
7837    Ok(())
7838}
7839
7840fn v2_sync_push(
7841    cfg: &HubConfig,
7842    requested_brain: &str,
7843    store: &Store,
7844    head: V2VerifiedHead,
7845    options: V2SyncPushOptions<'_>,
7846) -> LinkResult<Value> {
7847    let V2SyncPushOptions {
7848        resume_local_policy,
7849        bulk_confirmation,
7850        resolution,
7851        pulled,
7852        withdrawal_paths,
7853        withdrawal_reason,
7854    } = options;
7855    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7856    let head = v2_verified_head(cfg, requested_brain)?
7857        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7858    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7859    ensure_v2_view_compatible(&head, baseline.as_ref())?;
7860    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7861        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7862            Some(snapshot) => (
7863                snapshot.files,
7864                snapshot.assets,
7865                Some(snapshot.local),
7866                Some(snapshot.local_assets),
7867            ),
7868            None => match baseline
7869                .as_ref()
7870                .filter(|state| v2_baseline_matches_head(&head, state))
7871            {
7872                Some(state) => (state.files.clone(), state.assets.clone(), None, None),
7873                None => (
7874                    files_for_v2_view(
7875                        &head,
7876                        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7877                    ),
7878                    v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7879                    None,
7880                    None,
7881                ),
7882            },
7883        };
7884    if head.view_kind == "scoped" && baseline.is_none() {
7885        return Err(LinkError::ScopedViewChanged);
7886    }
7887    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7888    let local = &local_view.riding;
7889    let local_assets = match carried_local_assets {
7890        Some(assets) => assets,
7891        None => v2_local_asset_records(store)?,
7892    };
7893    if withdrawal_paths.len() > MAX_PUSH_FILES {
7894        return Err(LinkError::PushTooLarge {
7895            detail: "too many explicit withdrawal paths".to_string(),
7896        });
7897    }
7898    let withdrawal_reason = if withdrawal_paths.is_empty() {
7899        None
7900    } else {
7901        let reason = withdrawal_reason
7902            .map(str::trim)
7903            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7904            .ok_or_else(|| LinkError::InvalidPack {
7905                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7906            })?;
7907        Some(reason)
7908    };
7909    let mut withdrawals = withdrawal_paths
7910        .iter()
7911        .map(|path| {
7912            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7913                path: error.to_string(),
7914            })
7915        })
7916        .collect::<LinkResult<Vec<_>>>()?;
7917    withdrawals.sort();
7918    withdrawals.dedup();
7919    if withdrawals.len() != withdrawal_paths.len() {
7920        return Err(LinkError::InvalidPack {
7921            message: "explicit withdrawal paths must be unique".to_string(),
7922        });
7923    }
7924    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7925    let mut consumed_withdrawals = BTreeSet::new();
7926    if let Some(previous) = baseline.as_ref() {
7927        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7928            && !resume_local_policy
7929        {
7930            let mut newly_eligible = previous
7931                .local_eligibility
7932                .iter()
7933                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7934                .map(|(path, _)| path.clone())
7935                .collect::<Vec<_>>();
7936            if !newly_eligible.is_empty() {
7937                newly_eligible.truncate(100);
7938                return Err(LinkError::LocalPolicyTransition {
7939                    paths: newly_eligible,
7940                });
7941            }
7942        }
7943    }
7944    let base = match baseline.as_ref() {
7945        Some(state) => &state.files,
7946        None if remote.is_empty() => &remote,
7947        None => {
7948            let mut conflicts = v2_initial_content_conflicts(local, &remote, resolution.is_some());
7949            if !conflicts.is_empty() {
7950                conflicts.truncate(100);
7951                let (bundle, paths) =
7952                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7953                return Err(LinkError::ConflictBundle { bundle, paths });
7954            }
7955            &remote
7956        }
7957    };
7958    let all_paths = base
7959        .keys()
7960        .chain(remote.keys())
7961        .chain(local.keys())
7962        .cloned()
7963        .collect::<std::collections::BTreeSet<_>>();
7964    let mut conflicts = Vec::new();
7965    let mut operations = Vec::new();
7966    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7967    for path in all_paths {
7968        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7969        let remote_file = remote.get(&path);
7970        let remote_hash = remote_file.map(|file| file.sha256.as_str());
7971        let local_file = local.get(&path);
7972        let local_hash = local_file.map(|file| file.0.as_str());
7973        if local_hash == base_hash {
7974            continue;
7975        }
7976        if !v2_resolution_allows_path(resolution, &path, remote_file.is_some()) {
7977            continue;
7978        }
7979        if local_view.policy.keeps_home(&path) {
7980            // Kept-home is a local transfer exclusion, never an implicit
7981            // delete of the company's already-hosted coordinate.
7982            continue;
7983        }
7984        if remote_hash != base_hash && local_hash != remote_hash {
7985            let explicitly_resolved = resolution
7986                .and_then(|allowed| allowed.get(&path))
7987                .is_some_and(|selected| {
7988                    selected.expected_remote.as_deref() == remote_hash
7989                        && selected.selected_local.as_deref() == local_hash
7990                });
7991            if !explicitly_resolved {
7992                conflicts.push(path);
7993                continue;
7994            }
7995        }
7996        match local_file {
7997            Some((sha256, byte_count)) => {
7998                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7999                operations.push(json!({
8000                    "op": "put",
8001                    "path": path,
8002                    "expected": v2_expected(remote_file),
8003                    "blob": sha256,
8004                    "bytes": byte_count,
8005                }));
8006                upload_sources
8007                    .entry(sha256.clone())
8008                    .or_insert_with(|| V2UploadSource {
8009                        path: path.clone(),
8010                        bytes: *byte_count,
8011                    });
8012            }
8013            None => {
8014                let Some(current) = remote_file else {
8015                    continue;
8016                };
8017                operations.push(json!({
8018                    "op": "delete",
8019                    "path": path,
8020                    "expected": { "kind": "blob", "hash": current.sha256 },
8021                }));
8022            }
8023        }
8024    }
8025    operations = infer_exact_source_promotions(operations);
8026    for path in &withdrawals {
8027        if local_assets.contains_key(path) {
8028            continue;
8029        }
8030        operations.push(v2_content_withdrawal_operation(
8031            store,
8032            &local_view,
8033            &remote,
8034            path,
8035            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8036        )?);
8037        consumed_withdrawals.insert(path.clone());
8038    }
8039    if !conflicts.is_empty() {
8040        conflicts.truncate(100);
8041        let (bundle, paths) = create_v2_conflict_bundle(
8042            cfg,
8043            store,
8044            &head,
8045            baseline.as_ref(),
8046            local,
8047            &remote,
8048            &conflicts,
8049        )?;
8050        return Err(LinkError::ConflictBundle { bundle, paths });
8051    }
8052    let base_assets = match baseline.as_ref() {
8053        Some(state) => &state.assets,
8054        None if remote_assets.is_empty() => &remote_assets,
8055        None => {
8056            let mismatched = remote_assets.iter().any(|(path, remote)| {
8057                local_assets.get(path) != Some(&v2_asset_record(remote, path))
8058            }) || local_assets.len() != remote_assets.len();
8059            if mismatched {
8060                return Err(LinkError::Conflict {
8061                    paths: vec!["assets.jsonl".to_string()],
8062                });
8063            }
8064            &remote_assets
8065        }
8066    };
8067    let asset_paths = base_assets
8068        .keys()
8069        .chain(remote_assets.keys())
8070        .chain(local_assets.keys())
8071        .cloned()
8072        .collect::<std::collections::BTreeSet<_>>();
8073    let mut asset_policy_transitions = Vec::new();
8074    let mut asset_withdrawal_transitions = Vec::new();
8075    for path in asset_paths {
8076        let base_record = base_assets
8077            .get(&path)
8078            .map(|asset| v2_asset_record(asset, &path));
8079        let remote = remote_assets.get(&path);
8080        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8081        let local_record = local_assets.get(&path);
8082        if withdrawal_set.contains(&path) {
8083            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8084                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8085            })?;
8086            let current = remote.ok_or_else(|| LinkError::InvalidPack {
8087                message: format!(
8088                    "asset withdrawal path `{path}` has no readable hosted coordinate"
8089                ),
8090            })?;
8091            operations.push(v2_asset_withdrawal_operation(
8092                store,
8093                &local_view,
8094                &path,
8095                record,
8096                current,
8097                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8098            )?);
8099            consumed_withdrawals.insert(path.clone());
8100            continue;
8101        }
8102        let mut raw_present = false;
8103        let mut disposition = "withheld";
8104        let mut resumes_hosting = false;
8105        if let Some(record) = local_record {
8106            crate::linkmd_v2::normalize_path(&record.path)
8107                .map_err(|error| invalid_feed(error.to_string()))?;
8108            let kept_home = local_view.policy.keeps_home(&path);
8109            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8110            disposition = if kept_home || !raw_present {
8111                "withheld"
8112            } else {
8113                "hosted"
8114            };
8115            let inherits_withheld_absence = v2_asset_inherits_withheld_absence(
8116                base_assets.get(&path),
8117                base_record.as_ref(),
8118                local_record,
8119                raw_present,
8120            );
8121            if !raw_present && record.required && !kept_home && !inherits_withheld_absence {
8122                return Err(LinkError::InvalidPack {
8123                    message: format!("required asset {path} is missing"),
8124                });
8125            }
8126            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8127            if remote.is_some_and(|asset| asset.disposition == "hosted")
8128                && disposition == "withheld"
8129            {
8130                asset_withdrawal_transitions.push(path.clone());
8131                continue;
8132            }
8133        }
8134        if local_record == base_record.as_ref() && !resumes_hosting {
8135            continue;
8136        }
8137        if remote_record != base_record && local_record != remote_record.as_ref() {
8138            conflicts.push(path);
8139            continue;
8140        }
8141        let Some(record) = local_record else {
8142            if let Some(remote) = remote {
8143                operations.push(json!({
8144                    "op": "asset_delete",
8145                    "path": path,
8146                    "expected": v2_asset_expected(Some(remote)),
8147                }));
8148            }
8149            continue;
8150        };
8151        let raw = if raw_present {
8152            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8153            Some(())
8154        } else {
8155            None
8156        };
8157        let op = if resumes_hosting {
8158            if !resume_local_policy {
8159                asset_policy_transitions.push(path);
8160                continue;
8161            }
8162            "asset_resume"
8163        } else {
8164            "asset_put"
8165        };
8166        operations.push(json!({
8167            "op": op,
8168            "path": path,
8169            "expected": v2_asset_expected(remote),
8170            "asset": v2_asset_value(record, disposition),
8171        }));
8172        if disposition == "hosted" {
8173            raw.expect("hosted asset was checked present");
8174            upload_sources
8175                .entry(record.sha256.clone())
8176                .or_insert_with(|| V2UploadSource {
8177                    path: path.clone(),
8178                    bytes: record.bytes,
8179                });
8180        }
8181    }
8182    if consumed_withdrawals != withdrawal_set {
8183        let missing = withdrawal_set
8184            .difference(&consumed_withdrawals)
8185            .next()
8186            .expect("different withdrawal sets have one member");
8187        return Err(LinkError::InvalidPack {
8188            message: format!(
8189                "withdrawal path `{missing}` is not a readable content or asset coordinate"
8190            ),
8191        });
8192    }
8193    if !conflicts.is_empty() {
8194        conflicts.truncate(100);
8195        return Err(LinkError::Conflict { paths: conflicts });
8196    }
8197    if !asset_policy_transitions.is_empty() {
8198        asset_policy_transitions.truncate(100);
8199        return Err(LinkError::LocalPolicyTransition {
8200            paths: asset_policy_transitions,
8201        });
8202    }
8203    if !asset_withdrawal_transitions.is_empty() {
8204        asset_withdrawal_transitions.truncate(100);
8205        return Err(LinkError::AssetWithdrawalRequired {
8206            paths: asset_withdrawal_transitions,
8207        });
8208    }
8209    let touched_sources = operations
8210        .iter()
8211        .filter_map(
8212            |operation| match operation.get("op").and_then(Value::as_str) {
8213                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
8214                Some("rename") => operation.get("to").and_then(Value::as_str),
8215                _ => None,
8216            },
8217        )
8218        .collect::<std::collections::BTreeSet<_>>();
8219    let withheld_links = local_view
8220        .withheld_links
8221        .iter()
8222        .filter(|link| touched_sources.contains(link.source.as_str()))
8223        .collect::<Vec<_>>();
8224    let checkout_pseudonym = v2_checkout_id(
8225        baseline
8226            .as_ref()
8227            .and_then(|current| current.checkout_id.as_deref()),
8228    )?;
8229    let checkout_id = if withheld_links.is_empty() {
8230        None
8231    } else {
8232        Some(checkout_pseudonym.clone())
8233    };
8234    if operations.is_empty() {
8235        let final_head = v2_verified_head(cfg, requested_brain)?
8236            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8237        if !same_v2_head(&head, &final_head) {
8238            return Err(LinkError::RemoteAdvancedDuringSync);
8239        }
8240        let mut final_local = v2_local_files(store)?;
8241        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8242        let final_assets = v2_local_asset_records(store)?;
8243        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8244        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8245            final_local.policy.keeps_home(path)
8246        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8247        let next = v2_baseline_from_head(
8248            cfg,
8249            &head,
8250            remote,
8251            remote_assets,
8252            Some(&final_local),
8253            Some(&checkout_pseudonym),
8254        )?;
8255        let split_count = next.remote_copy_remains.len();
8256        accept_v2_head(cfg, &final_head)?;
8257        if !local_changed && !remote_ahead {
8258            refresh_scoped_view_marker(store, &head, next.files.len())?;
8259            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8260        }
8261        return Ok(json!({
8262            "v": 2,
8263            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8264            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8265            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8266            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8267            "local_policy": {
8268                "remote_copy_remains": split_count,
8269            },
8270        }));
8271    }
8272    let includes_contract = operations
8273        .iter()
8274        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8275    let rebase = if head.pointer.is_none() || includes_contract {
8276        "strict"
8277    } else {
8278        "disjoint"
8279    };
8280    let base_value = head.pointer.as_ref().map(|pointer| {
8281        json!({
8282            "seq": pointer.seq,
8283            "commit_hash": pointer.commit_hash,
8284            "content_root": pointer.content_root,
8285            "asset_root": pointer.asset_root,
8286        })
8287    });
8288    // The mutation coordinate is stable for the exact observed base and local
8289    // operation set. A lost response can therefore be retried without a
8290    // duplicate commit/proposal even across process restarts.
8291    let entropy = format!(
8292        "{}\0{}\0{}\0{}\0{}\0{}",
8293        normalized_origin(&cfg.hub)?,
8294        head.brain_id,
8295        serde_json::to_string(&base_value).unwrap_or_default(),
8296        serde_json::to_string(&operations).unwrap_or_default(),
8297        serde_json::to_string(&withheld_links).unwrap_or_default(),
8298        checkout_id.as_deref().unwrap_or("")
8299    );
8300    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8301    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8302        total
8303            .checked_add(source.bytes)
8304            .ok_or_else(|| LinkError::PushTooLarge {
8305                detail: "v2 changed-byte total overflow".to_string(),
8306            })
8307    })?;
8308    let inline = changed_bytes <= 3 * 1024 * 1024;
8309    let inline_blobs = if inline {
8310        upload_sources
8311            .iter()
8312            .map(|(sha256, source)| {
8313                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8314                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8315                    return Err(LinkError::InvalidPack {
8316                        message: format!("local path `{}` changed before upload", source.path),
8317                    });
8318                }
8319                Ok(json!({
8320                    "sha256": sha256,
8321                    "bytes": source.bytes,
8322                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8323                }))
8324            })
8325            .collect::<LinkResult<Vec<_>>>()?
8326    } else {
8327        Vec::new()
8328    };
8329    let mut body = json!({
8330        "mutation_id": mutation_id,
8331        "base": base_value,
8332        "rebase": rebase,
8333        "reason": "dbmd sync",
8334        "operations": operations,
8335        "blobs": inline_blobs,
8336    });
8337    if !withheld_links.is_empty() {
8338        body["withheld_links"] = serde_json::to_value(&withheld_links)
8339            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8340        body["checkout_id"] =
8341            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8342    }
8343    if let Some(confirmation) = bulk_confirmation {
8344        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8345            return Err(LinkError::InvalidPack {
8346                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8347                    .to_string(),
8348            });
8349        }
8350        // A preview is bound to the exact observed head. Confirmation can
8351        // therefore never use disjoint rebase semantics, even when the normal
8352        // small-mutation path could.
8353        body["rebase"] = Value::String("strict".to_string());
8354        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8355        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8356    }
8357    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8358        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8359        for operation in &operations {
8360            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8361                return Err(invalid_feed("v2 upload operation has no kind"));
8362            };
8363            let hash = match kind {
8364                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8365                "asset_put" | "asset_resume" => operation
8366                    .get("asset")
8367                    .and_then(|asset| asset.get("blob_sha256"))
8368                    .and_then(Value::as_str),
8369                _ => None,
8370            };
8371            let Some(hash) = hash else { continue };
8372            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8373            if kind == "rename" {
8374                for field in ["from", "to"] {
8375                    coordinates.insert(
8376                        operation
8377                            .get(field)
8378                            .and_then(Value::as_str)
8379                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8380                            .to_string(),
8381                    );
8382                }
8383            } else {
8384                let path = operation
8385                    .get("path")
8386                    .and_then(Value::as_str)
8387                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8388                coordinates.insert(if kind.starts_with("asset_") {
8389                    format!("assets/{path}")
8390                } else {
8391                    path.to_string()
8392                });
8393            }
8394        }
8395        let declarations = upload_sources
8396            .iter()
8397            .map(|(sha256, source)| {
8398                json!({
8399                    "sha256": sha256,
8400                    "bytes": source.bytes,
8401                    "coordinates": coordinates_by_hash
8402                        .get(sha256)
8403                        .into_iter()
8404                        .flatten()
8405                        .collect::<Vec<_>>(),
8406                })
8407            })
8408            .collect::<Vec<_>>();
8409        let mut references = Vec::with_capacity(upload_sources.len());
8410        let mut seen = std::collections::BTreeSet::new();
8411        let mut reserved_count = 0usize;
8412        let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8413        // A reservation's presigned URL is short-lived, so each batch's bytes
8414        // ride before the next batch is reserved. Reserving every blob first
8415        // and uploading afterwards expires the earliest URLs on a real store.
8416        for batch in batch_upload_declarations(declarations) {
8417            let batch_len = batch.len();
8418            let reserved = reserve_upload_window(
8419                cfg,
8420                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8421                &json!({ "blobs": batch }),
8422                "prepare v2 changed-byte uploads",
8423            )?;
8424            let items = reserved
8425                .get("uploads")
8426                .and_then(Value::as_array)
8427                .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8428            if items.len() != batch_len {
8429                return Err(invalid_feed(
8430                    "v2 upload reservation response changed the requested set",
8431                ));
8432            }
8433            reserved_count += items.len();
8434            for item in items {
8435                let sha256 = item
8436                    .get("sha256")
8437                    .and_then(Value::as_str)
8438                    .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8439                let source = upload_sources
8440                    .get(sha256)
8441                    .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8442                let declared_bytes = item
8443                    .get("bytes")
8444                    .and_then(Value::as_u64)
8445                    .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8446                let reservation_id = item
8447                    .get("reservation_id")
8448                    .and_then(Value::as_str)
8449                    .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8450                let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8451                    invalid_feed("v2 upload reservation has no coordinate binding")
8452                })?;
8453                let returned_coordinates = item
8454                    .get("coordinates")
8455                    .and_then(Value::as_array)
8456                    .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8457                if declared_bytes != source.bytes
8458                    || !crate::ulid::is_ulid(reservation_id)
8459                    || !seen.insert(sha256.to_string())
8460                    || returned_coordinates.len() != expected_coordinates.len()
8461                    || returned_coordinates
8462                        .iter()
8463                        .zip(expected_coordinates)
8464                        .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8465                {
8466                    return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8467                }
8468                match item.get("status").and_then(Value::as_str) {
8469                    Some("upload") => {
8470                        let url = item
8471                            .get("url")
8472                            .and_then(Value::as_str)
8473                            .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8474                        pending_uploads.push(V2PendingUpload {
8475                            url: url.to_string(),
8476                            headers: item.get("headers").cloned().unwrap_or(Value::Null),
8477                            sha256: sha256.to_string(),
8478                            source,
8479                        });
8480                    }
8481                    Some("already_present") => {}
8482                    _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8483                }
8484                references.push(json!({
8485                    "sha256": sha256,
8486                    "bytes": source.bytes,
8487                    "reservation_id": reservation_id,
8488                }));
8489            }
8490            // Stage this batch's bytes concurrently. Sequential PUTs move a real
8491            // store at a few objects a second, which both takes hours and pushes
8492            // the batch past its reservation's upload window; the object store
8493            // is content-addressed and precondition-guarded, so parallel writes
8494            // stay safe.
8495            upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8496            pending_uploads.clear();
8497        }
8498        if reserved_count != upload_sources.len() {
8499            return Err(invalid_feed(
8500                "v2 upload reservation response changed the requested set",
8501            ));
8502        }
8503        body["blobs"] = Value::Array(references);
8504    }
8505    stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8506    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8507    let mut candidate_hub_signer: Option<String> = None;
8508    let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8509    let bulk_preview_required = !(200..300).contains(&response.status)
8510        && response.body.as_ref().is_some_and(|value| {
8511            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8512                || value
8513                    .get("details")
8514                    .and_then(|details| details.get("code"))
8515                    .and_then(Value::as_str)
8516                    == Some("bulk_preview_required")
8517        });
8518    if bulk_preview_required && bulk_confirmation.is_none() {
8519        body["rebase"] = Value::String("strict".to_string());
8520        body["preview_only"] = Value::Bool(true);
8521        let preview = ensure_ok(
8522            request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8523            "v2 bulk preview",
8524        )?;
8525        let preview_code = preview.get("code").and_then(Value::as_str);
8526        let required = preview.get("required").and_then(Value::as_bool);
8527        if preview.get("v").and_then(Value::as_u64) != Some(2)
8528            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8529            || !matches!(
8530                preview_code,
8531                Some("bulk_preview_created" | "bulk_preview_not_required")
8532            )
8533            || required.is_none()
8534        {
8535            return Err(invalid_feed(
8536                "bulk preview response is not bound to the requested mutation",
8537            ));
8538        }
8539        if required == Some(true) {
8540            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8541            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8542            if preview_code != Some("bulk_preview_created")
8543                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8544                || preview_digest.is_none_or(|value| !is_sha256(value))
8545                || preview.get("expires_at").and_then(Value::as_str).is_none()
8546                || !preview.get("impact").is_some_and(Value::is_object)
8547            {
8548                return Err(invalid_feed("bulk preview receipt is malformed"));
8549            }
8550            return Err(LinkError::BulkPreviewRequired { preview });
8551        }
8552        if preview_code != Some("bulk_preview_not_required") {
8553            return Err(invalid_feed("bulk preview requirement is inconsistent"));
8554        }
8555        // The rolling activity window can expire between the refusal and
8556        // preview. Retry once, still pinned strictly to the observed head.
8557        body.as_object_mut()
8558            .expect("v2 commit request is an object")
8559            .remove("preview_only");
8560        response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8561    }
8562    let mut result = ensure_ok(response, "v2 sync push")?;
8563    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8564        if let Some(object) = result.as_object_mut() {
8565            object.insert(
8566                "sync_status".to_string(),
8567                Value::String("proposal_pending".to_string()),
8568            );
8569        }
8570        return Ok(result);
8571    }
8572    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8573        let request_id = result
8574            .get("request_id")
8575            .and_then(Value::as_str)
8576            .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8577            .to_string();
8578        let challenge = result
8579            .get("signing_challenge")
8580            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8581        let mut expected_candidate = remote.clone();
8582        let mut expected_candidate_assets = remote_assets.clone();
8583        apply_generated_v2_operations(
8584            &operations,
8585            &local_assets,
8586            &mut expected_candidate,
8587            &mut expected_candidate_assets,
8588        )?;
8589        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8590            cfg,
8591            &head,
8592            &expected_candidate,
8593            &expected_candidate_assets,
8594            &mutation_id,
8595            &v2_signed_request_view(&body, &operations),
8596            challenge,
8597        )?;
8598        body["signing_challenge_id"] = Value::String(challenge_id);
8599        body["signature_base64url"] = Value::String(signature);
8600        candidate_hub_signer = Some(actor_signer);
8601        result = ensure_ok(
8602            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8603            "v2 self-custody commit",
8604        )?;
8605    }
8606    let refreshed = v2_verified_head(cfg, requested_brain)?
8607        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8608    if candidate_hub_signer
8609        .as_ref()
8610        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8611    {
8612        return Err(invalid_feed(
8613            "self-custody actor signer differs from the committed hub pointer signer",
8614        ));
8615    }
8616    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8617    if refreshed
8618        .pointer
8619        .as_ref()
8620        .map(|pointer| pointer.commit_hash.as_str())
8621        != accepted_hash
8622    {
8623        return Err(LinkError::RemoteAdvancedDuringSync);
8624    }
8625    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8626    let rebased = result
8627        .get("rebased")
8628        .and_then(Value::as_bool)
8629        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8630    let (refreshed_files, refreshed_assets) = if rebased {
8631        (
8632            files_for_v2_view(
8633                &refreshed,
8634                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8635            ),
8636            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8637        )
8638    } else {
8639        let asset_changed = apply_generated_v2_operations(
8640            &operations,
8641            &local_assets,
8642            &mut remote,
8643            &mut remote_assets,
8644        )?;
8645        let assets = if asset_changed {
8646            // Asset leaf hashes are server-generated signed coordinates; only
8647            // asset-changing commits need this usually tiny post-commit read.
8648            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8649        } else {
8650            remote_assets
8651        };
8652        (remote, assets)
8653    };
8654    let mut final_local = v2_local_files(store)?;
8655    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8656    let final_assets = v2_local_asset_records(store)?;
8657    let local_dirty = final_local.riding != local_view.riding
8658        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8659            final_local.policy.keeps_home(path)
8660        })
8661        || final_assets != local_assets
8662        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8663    let next = v2_baseline_from_head(
8664        cfg,
8665        &refreshed,
8666        refreshed_files,
8667        refreshed_assets,
8668        Some(&final_local),
8669        Some(&checkout_pseudonym),
8670    )?;
8671    let split_count = next.remote_copy_remains.len();
8672    accept_v2_head(cfg, &refreshed)?;
8673    if !local_dirty {
8674        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8675        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8676    }
8677    if let Some(object) = result.as_object_mut() {
8678        object.insert(
8679            "local_policy".to_string(),
8680            json!({ "remote_copy_remains": split_count }),
8681        );
8682        object.insert(
8683            "sync_status".to_string(),
8684            Value::String(if local_dirty {
8685                "remote_committed_local_dirty".to_string()
8686            } else {
8687                "synced".to_string()
8688            }),
8689        );
8690    }
8691    Ok(result)
8692}
8693
8694/// Negotiate v2 and send only local changes. A v1 hub retains the existing
8695/// whole-snapshot behavior until its brain advertises the new profile.
8696pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8697    sync_push_incremental_with_policy(cfg, brain, store, false)
8698}
8699
8700/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
8701/// path that became eligible because local policy was weakened or removed.
8702pub fn sync_push_incremental_with_policy(
8703    cfg: &HubConfig,
8704    brain: &str,
8705    store: &Store,
8706    resume_local_policy: bool,
8707) -> LinkResult<Value> {
8708    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8709}
8710
8711/// Incremental push with both local-policy adoption and an optional exact
8712/// permissioned bulk-preview confirmation.
8713pub fn sync_push_incremental_with_options(
8714    cfg: &HubConfig,
8715    brain: &str,
8716    store: &Store,
8717    resume_local_policy: bool,
8718    bulk_confirmation: Option<&V2BulkConfirmation>,
8719) -> LinkResult<Value> {
8720    sync_push_incremental_with_controls(
8721        cfg,
8722        brain,
8723        store,
8724        resume_local_policy,
8725        bulk_confirmation,
8726        &[],
8727        None,
8728    )
8729}
8730
8731/// Incremental push with explicit permissioned current-host withdrawal.
8732pub fn sync_push_incremental_with_controls(
8733    cfg: &HubConfig,
8734    brain: &str,
8735    store: &Store,
8736    resume_local_policy: bool,
8737    bulk_confirmation: Option<&V2BulkConfirmation>,
8738    withdrawal_paths: &[String],
8739    withdrawal_reason: Option<&str>,
8740) -> LinkResult<Value> {
8741    require_safe_ref(brain)?;
8742    if let Some(head) = v2_verified_head(cfg, brain)? {
8743        return v2_sync_push(
8744            cfg,
8745            brain,
8746            store,
8747            head,
8748            V2SyncPushOptions {
8749                resume_local_policy,
8750                bulk_confirmation,
8751                resolution: None,
8752                pulled: None,
8753                withdrawal_paths,
8754                withdrawal_reason,
8755            },
8756        );
8757    }
8758    if !withdrawal_paths.is_empty() {
8759        return Err(LinkError::InvalidPack {
8760            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8761        });
8762    }
8763    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8764}
8765
8766/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
8767/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
8768/// a damaged `DB.md` makes strict store-open fail.
8769pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8770    require_safe_ref(brain)?;
8771    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8772}
8773
8774#[cfg(windows)]
8775fn legacy_sync_push_incremental(
8776    _cfg: &HubConfig,
8777    _brain: &str,
8778    _store: &Store,
8779    _resume_local_policy: bool,
8780    _bulk_confirmation: Option<&V2BulkConfirmation>,
8781) -> LinkResult<Value> {
8782    Err(LinkError::UnsupportedPlatform {
8783        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8784    })
8785}
8786
8787#[cfg(not(windows))]
8788fn legacy_sync_push_incremental(
8789    cfg: &HubConfig,
8790    brain: &str,
8791    store: &Store,
8792    resume_local_policy: bool,
8793    bulk_confirmation: Option<&V2BulkConfirmation>,
8794) -> LinkResult<Value> {
8795    if resume_local_policy || bulk_confirmation.is_some() {
8796        return Err(LinkError::InvalidPack {
8797            message: "v2 sync options require a link.md v2 brain".to_string(),
8798        });
8799    }
8800    let files = collect_push_files(store)?;
8801    sync_push(cfg, brain, &files)
8802}
8803
8804/// One explicit resolution for a previously preserved private conflict bundle.
8805#[derive(Debug, Clone)]
8806pub enum V2ConflictChoice {
8807    KeepLocal,
8808    TakeRemote,
8809    From(PathBuf),
8810}
8811
8812fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8813    if !crate::ulid::is_ulid(bundle) {
8814        return Err(LinkError::InvalidPack {
8815            message: "conflict bundle must be a lowercase ULID".to_string(),
8816        });
8817    }
8818    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8819    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8820        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8821    if plan.v != 2
8822        || plan.class != "content_resolution_required"
8823        || plan.bundle != bundle
8824        || !crate::ulid::is_ulid(&plan.brain)
8825        || plan.files.is_empty()
8826        || plan.files.len() > 100
8827        || plan.files.iter().any(|file| {
8828            crate::linkmd_v2::normalize_path(&file.path).is_err()
8829                || [&file.base, &file.local, &file.remote]
8830                    .into_iter()
8831                    .any(|coordinate| {
8832                        coordinate
8833                            .sha256
8834                            .as_deref()
8835                            .is_some_and(|hash| !is_sha256(hash))
8836                            || coordinate.file.as_deref().is_some_and(|name| {
8837                                name.starts_with('/')
8838                                    || name
8839                                        .split('/')
8840                                        .any(|part| part.is_empty() || part == "." || part == "..")
8841                            })
8842                    })
8843        })
8844    {
8845        return Err(invalid_feed("private conflict plan failed validation"));
8846    }
8847    Ok(plan)
8848}
8849
8850/// Inspect or prune private conflict-control state. Ordinary pruning removes
8851/// only expired completed bundles and interrupted bundles with no `plan.json`;
8852/// corrupt completed plans fail closed. `all` is an explicit local discard of
8853/// every well-addressed bundle and never changes hosted brain data.
8854pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8855    require_hardened_filesystem("private conflict maintenance")?;
8856    if all && !prune {
8857        return Err(LinkError::InvalidPack {
8858            message: "discarding all conflict bundles requires prune=true".to_string(),
8859        });
8860    }
8861    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8862        message: format!("conflict checkout is not a valid db.md store: {error}"),
8863    })?;
8864    let _transaction = store.transaction()?;
8865    let root = Path::new(".dbmd/conflicts");
8866    let names = match store.directory_names(root) {
8867        Ok(names) => names,
8868        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8869        Err(error) => return Err(error.into()),
8870    };
8871    let now = SystemTime::now()
8872        .duration_since(UNIX_EPOCH)
8873        .unwrap_or_default()
8874        .as_secs();
8875    let mut bundles = Vec::new();
8876    let mut pruned = 0_u64;
8877    for name in names {
8878        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8879            continue;
8880        };
8881        let plan_path = v2_conflict_relative(bundle, "plan.json");
8882        let plan_exists = store.regular_file_exists(&plan_path)?;
8883        let expired = if plan_exists {
8884            match load_v2_conflict_plan(&store, bundle) {
8885                Ok(plan) => plan.expires_unix < now,
8886                Err(error) if all => {
8887                    let _ = error;
8888                    true
8889                }
8890                Err(error) => return Err(error),
8891            }
8892        } else {
8893            true
8894        };
8895        if prune && (all || expired) {
8896            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8897            pruned += 1;
8898            continue;
8899        }
8900        bundles.push(json!({
8901            "bundle": bundle,
8902            "complete": plan_exists,
8903            "expired": expired,
8904        }));
8905    }
8906    Ok(json!({
8907        "v": 2,
8908        "class": "private_conflict_state",
8909        "bundles": bundles.len(),
8910        "pruned": pruned,
8911        "items": bundles,
8912    }))
8913}
8914
8915/// Resolve one exact private conflict plan. Drift never lowers a precondition:
8916/// the remote head/view and every original local coordinate are rechecked
8917/// before either an explicit local install or a fresh normal commit.
8918pub fn sync_resolve_conflict(
8919    cfg: &HubConfig,
8920    checkout: &Path,
8921    bundle: &str,
8922    choice: V2ConflictChoice,
8923    bulk_confirmation: Option<&V2BulkConfirmation>,
8924) -> LinkResult<Value> {
8925    require_hardened_filesystem("conflict resolution")?;
8926    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8927        message: format!("conflict checkout is not a valid db.md store: {error}"),
8928    })?;
8929    let plan = load_v2_conflict_plan(&store, bundle)?;
8930    if plan.origin != normalized_origin(&cfg.hub)? {
8931        return Err(invalid_feed(
8932            "conflict bundle belongs to another hub origin",
8933        ));
8934    }
8935    let now = SystemTime::now()
8936        .duration_since(UNIX_EPOCH)
8937        .unwrap_or_default()
8938        .as_secs();
8939    if now > plan.expires_unix {
8940        return Err(LinkError::InvalidPack {
8941            message: "conflict bundle expired; rerun sync to obtain current coordinates"
8942                .to_string(),
8943        });
8944    }
8945    let head = v2_verified_head(cfg, &plan.brain)?
8946        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8947    let pointer = head.pointer.as_ref();
8948    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8949        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8950        || pointer.and_then(|value| value.content_root.as_deref())
8951            != plan.remote_content_root.as_deref()
8952        || head.view_kind != plan.view_kind
8953        || head.view_revision != plan.view_revision
8954    {
8955        return Err(LinkError::RemoteAdvancedDuringSync);
8956    }
8957
8958    // A resolution never overwrites a post-bundle local edit.
8959    for file in &plan.files {
8960        let actual = match store.regular_file_exists(Path::new(&file.path))? {
8961            true => Some(content_sha256(&store.read_bounded(
8962                Path::new(&file.path),
8963                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8964            )?)),
8965            false => None,
8966        };
8967        if actual.as_deref() != file.local.sha256.as_deref() {
8968            return Err(LinkError::InvalidPack {
8969                message: format!(
8970                    "local conflict path `{}` changed after the bundle was created",
8971                    file.path
8972                ),
8973            });
8974        }
8975    }
8976
8977    let from_source = match &choice {
8978        V2ConflictChoice::From(source) => Some(source.clone()),
8979        _ => None,
8980    };
8981    let result = match choice {
8982        V2ConflictChoice::TakeRemote => {
8983            if bulk_confirmation.is_some() {
8984                return Err(LinkError::InvalidPack {
8985                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8986                });
8987            }
8988            // Re-read the exact current manifest so every selected remote byte
8989            // retains its signed inclusion proof. The private bundle is useful
8990            // for inspection, but is never promoted into live state as trust.
8991            let current_remote =
8992                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8993            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
8994            let selected = plan
8995                .files
8996                .iter()
8997                .map(|file| file.path.clone())
8998                .collect::<std::collections::BTreeSet<_>>();
8999            serde_json::to_value(
9000                v2_sync_pull_with_resolution(
9001                    cfg,
9002                    &plan.brain,
9003                    head,
9004                    Some(checkout),
9005                    Some(&selected),
9006                )?
9007                .report,
9008            )
9009            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
9010        }
9011        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
9012            if let Some(source) = from_source.as_ref() {
9013                if plan.files.len() != 1 {
9014                    return Err(LinkError::InvalidPack {
9015                        message: "--from requires a bundle with exactly one conflict".to_string(),
9016                    });
9017                }
9018                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
9019                if std::str::from_utf8(&candidate).is_err() {
9020                    return Err(LinkError::NotUtf8 {
9021                        path: source.display().to_string(),
9022                    });
9023                }
9024                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
9025            }
9026            let refreshed_store =
9027                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9028                    message: format!("resolved checkout is not a valid db.md store: {error}"),
9029                })?;
9030            let mut overrides = std::collections::BTreeMap::new();
9031            for file in &plan.files {
9032                let selected_local = match refreshed_store
9033                    .regular_file_exists(Path::new(&file.path))?
9034                {
9035                    true => Some(content_sha256(
9036                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
9037                    )),
9038                    false => None,
9039                };
9040                overrides.insert(
9041                    file.path.clone(),
9042                    V2ResolutionOverride {
9043                        expected_remote: file.remote.sha256.clone(),
9044                        selected_local,
9045                    },
9046                );
9047            }
9048            v2_sync_push(
9049                cfg,
9050                &plan.brain,
9051                &refreshed_store,
9052                head,
9053                V2SyncPushOptions {
9054                    resume_local_policy: true,
9055                    bulk_confirmation,
9056                    resolution: Some(&overrides),
9057                    pulled: None,
9058                    withdrawal_paths: &[],
9059                    withdrawal_reason: None,
9060                },
9061            )?
9062        }
9063    };
9064
9065    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9066        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9067            message: format!("resolved checkout is not a valid db.md store: {error}"),
9068        })?;
9069        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9070    }
9071    Ok(json!({
9072        "v": 2,
9073        "class": "auto_converged",
9074        "bundle": bundle,
9075        "receipt": result,
9076    }))
9077}
9078
9079/// Converge one established permissioned-v2 checkout in both directions.
9080///
9081/// The pull half first performs the ordinary three-way conflict check and
9082/// crash-safe install against the accepted baseline. The push half then opens
9083/// that exact installed checkout, takes its local transaction guard, computes
9084/// a fresh three-way delta, and submits it with mandatory preconditions. A
9085/// remote movement between halves is therefore a normal rebase/conflict input,
9086/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
9087/// its whole-pack replacement semantics are never presented as granular
9088/// convergence.
9089pub fn sync_converge(
9090    cfg: &HubConfig,
9091    brain: &str,
9092    checkout: &Path,
9093    resume_local_policy: bool,
9094) -> LinkResult<Value> {
9095    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9096}
9097
9098/// Bidirectional convergence with optional exact bulk-preview confirmation.
9099pub fn sync_converge_with_options(
9100    cfg: &HubConfig,
9101    brain: &str,
9102    checkout: &Path,
9103    resume_local_policy: bool,
9104    bulk_confirmation: Option<&V2BulkConfirmation>,
9105) -> LinkResult<Value> {
9106    sync_converge_with_controls(
9107        cfg,
9108        brain,
9109        checkout,
9110        resume_local_policy,
9111        bulk_confirmation,
9112        &[],
9113        None,
9114    )
9115}
9116
9117/// Bidirectional convergence with optional exact withdrawal intents.
9118pub fn sync_converge_with_controls(
9119    cfg: &HubConfig,
9120    brain: &str,
9121    checkout: &Path,
9122    resume_local_policy: bool,
9123    bulk_confirmation: Option<&V2BulkConfirmation>,
9124    withdrawal_paths: &[String],
9125    withdrawal_reason: Option<&str>,
9126) -> LinkResult<Value> {
9127    require_hardened_filesystem("bidirectional sync")?;
9128    require_safe_ref(brain)?;
9129    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9130        message:
9131            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9132                .to_string(),
9133    })?;
9134    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9135    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9136        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9137    })?;
9138    let _transaction = store.transaction()?;
9139    let pulled_report = pulled.report.clone();
9140    let pulled_head = pulled.head.clone();
9141    let mut result = v2_sync_push(
9142        cfg,
9143        brain,
9144        &store,
9145        pulled_head,
9146        V2SyncPushOptions {
9147            resume_local_policy,
9148            bulk_confirmation,
9149            resolution: None,
9150            pulled: Some(pulled),
9151            withdrawal_paths,
9152            withdrawal_reason,
9153        },
9154    )?;
9155    if let Some(object) = result.as_object_mut() {
9156        object.insert("pulled_files".to_string(), json!(pulled_report.files));
9157        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9158        object.insert(
9159            "mode".to_string(),
9160            Value::String("bidirectional".to_string()),
9161        );
9162    }
9163    Ok(result)
9164}
9165
9166/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
9167/// exported path is safety-gated before it touches disk; files are written
9168/// atomically; nothing local is ever deleted (locals the export lacks are
9169/// *reported* in `extra_local` instead). Returns the report; rebuilding the
9170/// local index catalog afterwards is the caller's (cheap, optional) step.
9171pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9172    require_hardened_filesystem("sync pull")?;
9173    require_safe_ref(brain)?;
9174    if let Some(head) = v2_verified_head(cfg, brain)? {
9175        return v2_sync_pull(cfg, brain, head, out);
9176    }
9177    legacy_sync_pull(cfg, brain, out)
9178}
9179
9180#[cfg(windows)]
9181fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9182    Err(LinkError::UnsupportedPlatform {
9183        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9184    })
9185}
9186
9187#[cfg(not(windows))]
9188fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9189    let remote = verified_remote_head(cfg, brain, false)?;
9190    if !remote.head.verified {
9191        return Err(invalid_feed(
9192            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9193        ));
9194    }
9195    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9196    let path = format!(
9197        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9198        remote.head.seq
9199    );
9200    let body = ensure_ok(
9201        request(cfg, "GET", &path, None, Auth::Required)?,
9202        "sync pull",
9203    )?;
9204    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9205        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9206    {
9207        return Err(invalid_feed(
9208            "export response is not bound to the verified snapshot token",
9209        ));
9210    }
9211
9212    let remote_slug = body
9213        .get("slug")
9214        .and_then(Value::as_str)
9215        .filter(|slug| is_safe_slug(slug));
9216    let slug = remote_slug
9217        .or_else(|| is_safe_slug(brain).then_some(brain))
9218        .unwrap_or("brain")
9219        .to_string();
9220    let brain_id = body
9221        .get("brain")
9222        .and_then(Value::as_str)
9223        .unwrap_or(&remote.head.brain)
9224        .to_string();
9225    if brain_id != remote.head.brain {
9226        return Err(invalid_feed(
9227            "export response names a different brain than the verified head",
9228        ));
9229    }
9230    let head_seq = remote.head.seq;
9231    let dest: PathBuf = match out {
9232        Some(p) => p.to_path_buf(),
9233        None => PathBuf::from(&slug),
9234    };
9235    let entries = if head_seq == 0 {
9236        let files = body
9237            .get("files")
9238            .and_then(Value::as_array)
9239            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
9240        if !files.is_empty() || body.get("url").is_some() {
9241            return Err(invalid_feed(
9242                "empty signed feed cannot authorize non-empty exported content",
9243            ));
9244        }
9245        Vec::new()
9246    } else {
9247        let signed_head = remote
9248            .head_entry
9249            .as_ref()
9250            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
9251        let expected = &signed_head.entry.pack_sha256;
9252        if !is_sha256(expected) {
9253            return Err(invalid_feed(
9254                "signed head carries an invalid snapshot pack digest",
9255            ));
9256        }
9257        if let Some(url) = body.get("url").and_then(Value::as_str) {
9258            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
9259                return Err(invalid_feed(
9260                    "export pack digest does not match the signed head entry",
9261                ));
9262            }
9263            let bytes = get_presigned(cfg, url)?;
9264            let actual = format!("{:x}", Sha256::digest(&bytes));
9265            if actual != *expected {
9266                return Err(LinkError::InvalidPack {
9267                    message: "downloaded pack does not match the signed snapshot digest"
9268                        .to_string(),
9269                });
9270            }
9271            let entries = parse_store_pack(bytes)?;
9272            if signed_head.entry.kind == "push" {
9273                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9274            }
9275            entries
9276        } else {
9277            if signed_head.entry.kind != "push" {
9278                return Err(invalid_feed(
9279                    "delta snapshots must export the exact signed pack",
9280                ));
9281            }
9282            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
9283                invalid_feed("verified snapshot export carried neither a pack nor files")
9284            })?;
9285            let mut entries = Vec::with_capacity(files.len());
9286            for file in files {
9287                let path = file
9288                    .get("path")
9289                    .and_then(Value::as_str)
9290                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
9291                let content = file
9292                    .get("content")
9293                    .and_then(Value::as_str)
9294                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
9295                entries.push((path.to_string(), content.as_bytes().to_vec()));
9296            }
9297            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
9298            entries
9299        }
9300    };
9301
9302    // Gate the complete manifest before the first filesystem mutation.
9303    let mut seen = std::collections::HashSet::new();
9304    for (path, _) in &entries {
9305        if !safe_store_rel_path(path) {
9306            return Err(LinkError::UnsafePath { path: path.clone() });
9307        }
9308        if !seen.insert(path) {
9309            return Err(LinkError::InvalidPack {
9310                message: format!("duplicate path `{path}`"),
9311            });
9312        }
9313    }
9314    // Compute divergence against the still-live tree. The staged clone keeps
9315    // these extra locals byte-for-byte while overlaying the signed snapshot.
9316    let pulled: std::collections::BTreeSet<&str> =
9317        entries.iter().map(|(p, _)| p.as_str()).collect();
9318    let mut extra_local = Vec::new();
9319    if let Ok(store) = Store::open(&dest) {
9320        if let Ok(walked) = store.walk() {
9321            for rel in walked {
9322                let rel_str = rel.to_string_lossy().replace('\\', "/");
9323                if !pulled.contains(rel_str.as_str()) {
9324                    extra_local.push(rel_str);
9325                }
9326            }
9327        }
9328    }
9329    #[cfg(unix)]
9330    install_pulled_snapshot(&dest, &entries)?;
9331
9332    Ok(PullReport {
9333        brain: brain_id,
9334        slug,
9335        head_seq,
9336        files: entries.len(),
9337        dest: dest.to_string_lossy().into_owned(),
9338        extra_local,
9339        sync_status: "synced".to_string(),
9340    })
9341}
9342
9343#[cfg(unix)]
9344fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
9345    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
9346        path: display.to_string(),
9347    })
9348}
9349
9350#[cfg(unix)]
9351fn open_dir_at(
9352    parent: std::os::fd::RawFd,
9353    name: &std::ffi::CStr,
9354    display: &str,
9355) -> LinkResult<std::fs::File> {
9356    use std::os::fd::FromRawFd as _;
9357    let fd = unsafe {
9358        libc::openat(
9359            parent,
9360            name.as_ptr(),
9361            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9362        )
9363    };
9364    if fd < 0 {
9365        return Err(LinkError::UnsafePath {
9366            path: display.to_string(),
9367        });
9368    }
9369    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9370}
9371
9372/// Open (and, where absent, create) a directory path without following a
9373/// symlink in any component. The returned directory capability remains bound
9374/// to the opened inode even if an attacker renames or replaces an ancestor.
9375#[cfg(unix)]
9376fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9377    use std::os::fd::AsRawFd as _;
9378
9379    // macOS exposes a few system-owned compatibility symlinks at the root.
9380    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
9381    // caller-controlled ancestor.
9382    #[cfg(target_os = "macos")]
9383    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9384        .into_iter()
9385        .find_map(|(alias, real)| {
9386            path.strip_prefix(alias)
9387                .ok()
9388                .map(|rest| Path::new(real).join(rest))
9389        })
9390        .unwrap_or_else(|| path.to_path_buf());
9391    #[cfg(not(target_os = "macos"))]
9392    let normalized = path.to_path_buf();
9393
9394    let start = if normalized.is_absolute() {
9395        std::fs::File::open("/")?
9396    } else {
9397        std::fs::File::open(".")?
9398    };
9399    let mut directory = start;
9400    for component in normalized.components() {
9401        use std::path::Component;
9402        let name = match component {
9403            Component::RootDir | Component::CurDir => continue,
9404            Component::Normal(name) => name,
9405            Component::ParentDir | Component::Prefix(_) => {
9406                return Err(LinkError::UnsafePath {
9407                    path: path.display().to_string(),
9408                });
9409            }
9410        };
9411        use std::os::unix::ffi::OsStrExt as _;
9412        let name = c_name(name.as_bytes(), &path.display().to_string())?;
9413        if create {
9414            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9415            if made != 0 {
9416                let error = std::io::Error::last_os_error();
9417                if error.raw_os_error() != Some(libc::EEXIST) {
9418                    return Err(error.into());
9419                }
9420            }
9421        }
9422        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9423    }
9424    Ok(directory)
9425}
9426
9427#[cfg(unix)]
9428fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9429    open_dir_path_nofollow(path, true)
9430}
9431
9432#[cfg(unix)]
9433fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9434    open_dir_path_nofollow(path, false)
9435}
9436
9437#[cfg(unix)]
9438fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9439    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9440    let result =
9441        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9442    if result == 0 {
9443        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9444    }
9445    let error = std::io::Error::last_os_error();
9446    if error.kind() == std::io::ErrorKind::NotFound {
9447        Ok(None)
9448    } else {
9449        Err(error.into())
9450    }
9451}
9452
9453#[cfg(unix)]
9454fn create_dir_exclusive_at(
9455    parent: std::os::fd::RawFd,
9456    name: &std::ffi::CStr,
9457    display: &str,
9458) -> LinkResult<std::fs::File> {
9459    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9460    if made != 0 {
9461        return Err(LinkError::UnsafePath {
9462            path: display.to_string(),
9463        });
9464    }
9465    open_dir_at(parent, name, display)
9466}
9467
9468#[cfg(unix)]
9469fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9470    use std::os::fd::AsRawFd as _;
9471
9472    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9473    if duplicate < 0 {
9474        return Err(std::io::Error::last_os_error().into());
9475    }
9476    let stream = unsafe { libc::fdopendir(duplicate) };
9477    if stream.is_null() {
9478        let error = std::io::Error::last_os_error();
9479        unsafe {
9480            libc::close(duplicate);
9481        }
9482        return Err(error.into());
9483    }
9484    let mut names = Vec::new();
9485    loop {
9486        let entry = unsafe { libc::readdir(stream) };
9487        if entry.is_null() {
9488            break;
9489        }
9490        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9491        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9492            names.push(raw.to_owned());
9493        }
9494    }
9495    if unsafe { libc::closedir(stream) } != 0 {
9496        return Err(std::io::Error::last_os_error().into());
9497    }
9498    Ok(names)
9499}
9500
9501/// Remove an entry tree relative to a held directory capability. Symlinks are
9502/// unlinked, never traversed, even when an old mirror contains hostile names.
9503#[cfg(unix)]
9504fn remove_tree_at(
9505    parent: std::os::fd::RawFd,
9506    name: &std::ffi::CStr,
9507    display: &str,
9508) -> LinkResult<()> {
9509    use std::os::fd::AsRawFd as _;
9510
9511    match entry_is_dir_at(parent, name)? {
9512        None => return Ok(()),
9513        Some(false) => {
9514            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9515                return Err(std::io::Error::last_os_error().into());
9516            }
9517        }
9518        Some(true) => {
9519            let directory = open_dir_at(parent, name, display)?;
9520            for child in directory_entry_names(&directory)? {
9521                let child_display =
9522                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9523                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9524            }
9525            drop(directory);
9526            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9527                return Err(std::io::Error::last_os_error().into());
9528            }
9529        }
9530    }
9531    Ok(())
9532}
9533
9534/// Clone a live destination into a private sibling stage without following
9535/// any symlink. Regular files are streamed between held descriptors; symlinks
9536/// are reproduced as links, never opened. Special files are refused.
9537#[cfg(unix)]
9538fn clone_tree_contents(
9539    source: &std::fs::File,
9540    destination: &std::fs::File,
9541    display: &str,
9542) -> LinkResult<()> {
9543    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9544
9545    for name in directory_entry_names(source)? {
9546        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9547        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9548        if unsafe {
9549            libc::fstatat(
9550                source.as_raw_fd(),
9551                name.as_ptr(),
9552                &mut stat,
9553                libc::AT_SYMLINK_NOFOLLOW,
9554            )
9555        } != 0
9556        {
9557            return Err(std::io::Error::last_os_error().into());
9558        }
9559        match stat.st_mode & libc::S_IFMT {
9560            libc::S_IFDIR => {
9561                if unsafe {
9562                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9563                } != 0
9564                {
9565                    return Err(std::io::Error::last_os_error().into());
9566                }
9567                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9568                let destination_child =
9569                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9570                clone_tree_contents(&source_child, &destination_child, &child_display)?;
9571                destination_child.sync_all()?;
9572            }
9573            libc::S_IFREG => {
9574                let source_fd = unsafe {
9575                    libc::openat(
9576                        source.as_raw_fd(),
9577                        name.as_ptr(),
9578                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9579                    )
9580                };
9581                if source_fd < 0 {
9582                    return Err(std::io::Error::last_os_error().into());
9583                }
9584                let destination_fd = unsafe {
9585                    libc::openat(
9586                        destination.as_raw_fd(),
9587                        name.as_ptr(),
9588                        libc::O_WRONLY
9589                            | libc::O_CREAT
9590                            | libc::O_EXCL
9591                            | libc::O_CLOEXEC
9592                            | libc::O_NOFOLLOW,
9593                        (stat.st_mode & 0o777) as libc::c_uint,
9594                    )
9595                };
9596                if destination_fd < 0 {
9597                    unsafe {
9598                        libc::close(source_fd);
9599                    }
9600                    return Err(std::io::Error::last_os_error().into());
9601                }
9602                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9603                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9604                std::io::copy(&mut input, &mut output)?;
9605                output.sync_all()?;
9606            }
9607            libc::S_IFLNK => {
9608                let mut target = vec![0_u8; 4097];
9609                let length = unsafe {
9610                    libc::readlinkat(
9611                        source.as_raw_fd(),
9612                        name.as_ptr(),
9613                        target.as_mut_ptr().cast(),
9614                        target.len(),
9615                    )
9616                };
9617                if length < 0 || length as usize >= target.len() {
9618                    return Err(LinkError::UnsafePath {
9619                        path: child_display,
9620                    });
9621                }
9622                target.truncate(length as usize);
9623                let target = c_name(&target, &child_display)?;
9624                if unsafe {
9625                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9626                } != 0
9627                {
9628                    return Err(std::io::Error::last_os_error().into());
9629                }
9630            }
9631            _ => {
9632                return Err(LinkError::UnsafePath {
9633                    path: child_display,
9634                });
9635            }
9636        }
9637    }
9638    destination.sync_all()?;
9639    Ok(())
9640}
9641
9642#[cfg(target_os = "linux")]
9643fn install_stage_at(
9644    parent: std::os::fd::RawFd,
9645    stage: &std::ffi::CStr,
9646    dest: &std::ffi::CStr,
9647    dest_exists: bool,
9648) -> LinkResult<()> {
9649    let flags = if dest_exists {
9650        libc::RENAME_EXCHANGE
9651    } else {
9652        libc::RENAME_NOREPLACE
9653    };
9654    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
9655    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
9656    // atomic exchange/no-replace boundary compiles for glibc and musl.
9657    let result = unsafe {
9658        libc::syscall(
9659            libc::SYS_renameat2,
9660            parent,
9661            stage.as_ptr(),
9662            parent,
9663            dest.as_ptr(),
9664            flags,
9665        )
9666    };
9667    if result == 0 {
9668        Ok(())
9669    } else {
9670        Err(std::io::Error::last_os_error().into())
9671    }
9672}
9673
9674#[cfg(target_os = "macos")]
9675fn install_stage_at(
9676    parent: std::os::fd::RawFd,
9677    stage: &std::ffi::CStr,
9678    dest: &std::ffi::CStr,
9679    dest_exists: bool,
9680) -> LinkResult<()> {
9681    let flags = if dest_exists {
9682        libc::RENAME_SWAP
9683    } else {
9684        libc::RENAME_EXCL
9685    };
9686    let result =
9687        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9688    if result == 0 {
9689        Ok(())
9690    } else {
9691        Err(std::io::Error::last_os_error().into())
9692    }
9693}
9694
9695#[cfg(unix)]
9696fn write_pull_entries_beneath_dir(
9697    root: &std::fs::File,
9698    entries: &[(String, Vec<u8>)],
9699) -> LinkResult<()> {
9700    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9701
9702    for (path, content) in entries {
9703        let components: Vec<&str> = path.split('/').collect();
9704        let (leaf, parents) = components
9705            .split_last()
9706            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9707        let mut directory = root.try_clone()?;
9708        for component in parents {
9709            let name = c_name(component.as_bytes(), path)?;
9710            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9711            if made != 0 {
9712                let error = std::io::Error::last_os_error();
9713                if error.raw_os_error() != Some(libc::EEXIST) {
9714                    return Err(error.into());
9715                }
9716            }
9717            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9718        }
9719
9720        let leaf_name = c_name(leaf.as_bytes(), path)?;
9721        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9722        let inspected = unsafe {
9723            libc::fstatat(
9724                directory.as_raw_fd(),
9725                leaf_name.as_ptr(),
9726                &mut existing,
9727                libc::AT_SYMLINK_NOFOLLOW,
9728            )
9729        };
9730        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9731            return Err(LinkError::UnsafePath { path: path.clone() });
9732        }
9733
9734        let nonce = std::time::SystemTime::now()
9735            .duration_since(std::time::UNIX_EPOCH)
9736            .unwrap_or_default()
9737            .as_nanos();
9738        let temp_name = format!(
9739            ".dbmd-pull-{}-{nonce}-{}",
9740            std::process::id(),
9741            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9742        );
9743        let temp = c_name(temp_name.as_bytes(), path)?;
9744        let fd = unsafe {
9745            libc::openat(
9746                directory.as_raw_fd(),
9747                temp.as_ptr(),
9748                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9749                0o600,
9750            )
9751        };
9752        if fd < 0 {
9753            return Err(std::io::Error::last_os_error().into());
9754        }
9755        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9756        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9757            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9758            return Err(error.into());
9759        }
9760        drop(file);
9761        let renamed = unsafe {
9762            libc::renameat(
9763                directory.as_raw_fd(),
9764                temp.as_ptr(),
9765                directory.as_raw_fd(),
9766                leaf_name.as_ptr(),
9767            )
9768        };
9769        if renamed != 0 {
9770            let error = std::io::Error::last_os_error();
9771            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9772            return Err(error.into());
9773        }
9774        directory.sync_all()?;
9775    }
9776    root.sync_all()?;
9777    Ok(())
9778}
9779
9780#[cfg(unix)]
9781fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9782    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9783
9784    let path = &entry.path;
9785    let components: Vec<&str> = path.split('/').collect();
9786    let (leaf, parents) = components
9787        .split_last()
9788        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9789    let mut directory = root.try_clone()?;
9790    for component in parents {
9791        let name = c_name(component.as_bytes(), path)?;
9792        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9793        if made != 0 {
9794            let error = std::io::Error::last_os_error();
9795            if error.raw_os_error() != Some(libc::EEXIST) {
9796                return Err(error.into());
9797            }
9798        }
9799        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9800    }
9801    let leaf_name = c_name(leaf.as_bytes(), path)?;
9802    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9803    if unsafe {
9804        libc::fstatat(
9805            directory.as_raw_fd(),
9806            leaf_name.as_ptr(),
9807            &mut existing,
9808            libc::AT_SYMLINK_NOFOLLOW,
9809        )
9810    } == 0
9811        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9812    {
9813        return Err(LinkError::UnsafePath { path: path.clone() });
9814    }
9815    let nonce = SystemTime::now()
9816        .duration_since(UNIX_EPOCH)
9817        .unwrap_or_default()
9818        .as_nanos();
9819    let temp_name = format!(
9820        ".dbmd-pull-{}-{nonce}-{}",
9821        std::process::id(),
9822        content_sha256(path.as_bytes())
9823    );
9824    let temp = c_name(temp_name.as_bytes(), path)?;
9825    let fd = unsafe {
9826        libc::openat(
9827            directory.as_raw_fd(),
9828            temp.as_ptr(),
9829            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9830            0o600,
9831        )
9832    };
9833    if fd < 0 {
9834        return Err(std::io::Error::last_os_error().into());
9835    }
9836    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9837    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9838    let mut digest = Sha256::new();
9839    let mut total = 0_u64;
9840    let mut buffer = [0_u8; 64 * 1024];
9841    let copied = (|| -> std::io::Result<()> {
9842        loop {
9843            let read = input.read(&mut buffer)?;
9844            if read == 0 {
9845                break;
9846            }
9847            total = total.saturating_add(read as u64);
9848            if total > entry.bytes {
9849                return Err(std::io::Error::new(
9850                    std::io::ErrorKind::InvalidData,
9851                    "staged sync source grew beyond its verified length",
9852                ));
9853            }
9854            digest.update(&buffer[..read]);
9855            output.write_all(&buffer[..read])?;
9856        }
9857        Ok(())
9858    })();
9859    if let Err(error) = copied {
9860        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9861        return Err(error.into());
9862    }
9863    drop(output);
9864    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9865        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9866        return Err(invalid_feed(
9867            "private staged sync source failed final integrity verification",
9868        ));
9869    }
9870    if unsafe {
9871        libc::renameat(
9872            directory.as_raw_fd(),
9873            temp.as_ptr(),
9874            directory.as_raw_fd(),
9875            leaf_name.as_ptr(),
9876        )
9877    } != 0
9878    {
9879        let error = std::io::Error::last_os_error();
9880        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9881        return Err(error.into());
9882    }
9883    Ok(())
9884}
9885
9886#[cfg(unix)]
9887fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9888    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9889
9890    let path = &entry.path;
9891    let components: Vec<&str> = path.split('/').collect();
9892    let (leaf, parents) = components
9893        .split_last()
9894        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9895    let mut directory = root.try_clone()?;
9896    for component in parents {
9897        directory = open_dir_at(
9898            directory.as_raw_fd(),
9899            &c_name(component.as_bytes(), path)?,
9900            path,
9901        )?;
9902    }
9903    let leaf = c_name(leaf.as_bytes(), path)?;
9904    let fd = unsafe {
9905        libc::openat(
9906            directory.as_raw_fd(),
9907            leaf.as_ptr(),
9908            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9909        )
9910    };
9911    if fd < 0 {
9912        return Err(std::io::Error::last_os_error().into());
9913    }
9914    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9915    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9916        return Err(invalid_feed(
9917            "private pull stage changed before its durability barrier",
9918        ));
9919    }
9920    file.sync_all()?;
9921    Ok(())
9922}
9923
9924#[cfg(unix)]
9925fn run_pull_source_workers(
9926    root: &std::fs::File,
9927    entries: &[V2StagedFile],
9928    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9929) -> LinkResult<()> {
9930    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9931
9932    let next = AtomicUsize::new(0);
9933    let failed = AtomicBool::new(false);
9934    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9935    let mut first_error = None;
9936    std::thread::scope(|scope| {
9937        let (sender, receiver) = std::sync::mpsc::channel();
9938        for _ in 0..worker_count {
9939            let sender = sender.clone();
9940            let next = &next;
9941            let failed = &failed;
9942            scope.spawn(move || {
9943                while !failed.load(Ordering::Acquire) {
9944                    let index = next.fetch_add(1, Ordering::Relaxed);
9945                    let Some(entry) = entries.get(index) else {
9946                        break;
9947                    };
9948                    let result = operation(root, entry);
9949                    if result.is_err() {
9950                        failed.store(true, Ordering::Release);
9951                    }
9952                    if sender.send(result).is_err() {
9953                        break;
9954                    }
9955                }
9956            });
9957        }
9958        drop(sender);
9959        for result in receiver {
9960            if let Err(error) = result {
9961                if first_error.is_none() {
9962                    first_error = Some(error);
9963                }
9964            }
9965        }
9966    });
9967    if let Some(error) = first_error {
9968        return Err(error);
9969    }
9970    if next.load(Ordering::Relaxed) < entries.len() {
9971        return Err(invalid_feed(
9972            "a bounded pull worker stopped before reporting every file",
9973        ));
9974    }
9975    Ok(())
9976}
9977
9978#[cfg(unix)]
9979fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9980    use std::os::fd::AsRawFd as _;
9981
9982    for name in directory_entry_names(root)? {
9983        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9984            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9985            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9986            sync_pull_directory_tree(&child, &child_display)?;
9987        }
9988    }
9989    root.sync_all()?;
9990    Ok(())
9991}
9992
9993#[cfg(unix)]
9994fn write_pull_sources_beneath_dir(
9995    root: &std::fs::File,
9996    entries: &[V2StagedFile],
9997) -> LinkResult<()> {
9998    // The stage is still private and unreachable as the checkout here. Write
9999    // and verify every leaf first so the filesystem can coalesce dirty data,
10000    // then re-open/re-hash/fsync leaves through the held root capability. Only
10001    // after every file barrier succeeds do we flush each directory once. This
10002    // preserves the same crash durability as file-fsync + directory-fsync per
10003    // path without serializing 50,000 redundant directory barriers.
10004    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
10005    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
10006    sync_pull_directory_tree(root, "v2 pull stage")
10007}
10008
10009#[cfg(unix)]
10010fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
10011    use std::os::fd::AsRawFd as _;
10012    for path in paths {
10013        if !safe_store_rel_path(path) {
10014            return Err(LinkError::UnsafePath { path: path.clone() });
10015        }
10016        let components = path.split('/').collect::<Vec<_>>();
10017        let Some((leaf, parents)) = components.split_last() else {
10018            return Err(LinkError::UnsafePath { path: path.clone() });
10019        };
10020        let mut directory = root.try_clone()?;
10021        let mut missing = false;
10022        for component in parents {
10023            let name = c_name(component.as_bytes(), path)?;
10024            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
10025                None => {
10026                    missing = true;
10027                    break;
10028                }
10029                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
10030                Some(true) => {
10031                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10032                }
10033            }
10034        }
10035        if missing {
10036            continue;
10037        }
10038        let leaf = c_name(leaf.as_bytes(), path)?;
10039        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
10040            None => {}
10041            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
10042            Some(false) => {
10043                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
10044                    return Err(std::io::Error::last_os_error().into());
10045                }
10046                directory.sync_all()?;
10047            }
10048        }
10049    }
10050    Ok(())
10051}
10052
10053#[cfg(unix)]
10054fn install_pulled_delta(
10055    dest: &Path,
10056    entries: &[(String, Vec<u8>)],
10057    deleted: &[String],
10058    rebuild_indexes: bool,
10059) -> LinkResult<()> {
10060    use ring::rand::SecureRandom as _;
10061    use std::os::fd::AsRawFd as _;
10062    use std::os::unix::ffi::OsStrExt as _;
10063
10064    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10065    let name = dest
10066        .file_name()
10067        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10068        .ok_or_else(|| LinkError::UnsafePath {
10069            path: dest.display().to_string(),
10070        })?;
10071    let parent_dir = open_or_create_dir_nofollow(parent)?;
10072    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10073    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10074        None => false,
10075        Some(true) => true,
10076        Some(false) => {
10077            return Err(LinkError::UnsafePath {
10078                path: dest.display().to_string(),
10079            });
10080        }
10081    };
10082
10083    let mut nonce = [0_u8; 16];
10084    ring::rand::SystemRandom::new()
10085        .fill(&mut nonce)
10086        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10087    let stage_label = format!(
10088        ".{}.dbmd-pull-stage-{}",
10089        name.to_string_lossy(),
10090        URL_SAFE_NO_PAD.encode(nonce)
10091    );
10092    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10093    let stage_dir = create_dir_exclusive_at(
10094        parent_dir.as_raw_fd(),
10095        &stage_name,
10096        &dest.display().to_string(),
10097    )?;
10098
10099    let prepared = (|| -> LinkResult<()> {
10100        if dest_exists {
10101            let live = open_dir_at(
10102                parent_dir.as_raw_fd(),
10103                &dest_name,
10104                &dest.display().to_string(),
10105            )?;
10106            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10107        }
10108        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10109        write_pull_entries_beneath_dir(&stage_dir, entries)?;
10110        if rebuild_indexes {
10111            let stage_store =
10112                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10113                    .map_err(|error| LinkError::InvalidPack {
10114                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10115                    })?;
10116            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10117                LinkError::InvalidPack {
10118                    message: format!("could not materialize v2 local catalogs: {error}"),
10119                }
10120            })?;
10121        }
10122        stage_dir.sync_all()?;
10123        Ok(())
10124    })();
10125    if let Err(error) = prepared {
10126        let _ = remove_tree_at(
10127            parent_dir.as_raw_fd(),
10128            &stage_name,
10129            &dest.display().to_string(),
10130        );
10131        return Err(error);
10132    }
10133
10134    if let Err(error) =
10135        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10136    {
10137        let _ = remove_tree_at(
10138            parent_dir.as_raw_fd(),
10139            &stage_name,
10140            &dest.display().to_string(),
10141        );
10142        return Err(error);
10143    }
10144    parent_dir.sync_all()?;
10145    if dest_exists {
10146        // Commit already happened atomically. Cleanup is best-effort so a
10147        // failure cannot be reported as a failed pull after the live tree
10148        // changed; a crash may leave only this private old-tree sibling.
10149        let _ = remove_tree_at(
10150            parent_dir.as_raw_fd(),
10151            &stage_name,
10152            &dest.display().to_string(),
10153        );
10154        let _ = parent_dir.sync_all();
10155    }
10156    Ok(())
10157}
10158
10159#[cfg(unix)]
10160fn install_pulled_delta_sources(
10161    dest: &Path,
10162    entries: &[V2StagedFile],
10163    deleted: &[String],
10164    rebuild_indexes: bool,
10165    _previous: Option<&V2SyncBaseline>,
10166    _next: &V2VerifiedHead,
10167) -> LinkResult<()> {
10168    use ring::rand::SecureRandom as _;
10169    use std::os::fd::AsRawFd as _;
10170    use std::os::unix::ffi::OsStrExt as _;
10171
10172    // An established checkout is updated only at the changed coordinates
10173    // through the durable pull journal shared with Windows. Initial clones
10174    // still use a private complete stage and one atomic directory install.
10175    if let Ok(store) = Store::open_strict(dest) {
10176        return install_established_v2_delta(
10177            store,
10178            entries,
10179            deleted,
10180            rebuild_indexes,
10181            _previous,
10182            _next,
10183        );
10184    }
10185
10186    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10187    let name = dest
10188        .file_name()
10189        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10190        .ok_or_else(|| LinkError::UnsafePath {
10191            path: dest.display().to_string(),
10192        })?;
10193    let parent_dir = open_or_create_dir_nofollow(parent)?;
10194    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10195    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10196        None => false,
10197        Some(true) => true,
10198        Some(false) => {
10199            return Err(LinkError::UnsafePath {
10200                path: dest.display().to_string(),
10201            })
10202        }
10203    };
10204    let mut nonce = [0_u8; 16];
10205    ring::rand::SystemRandom::new()
10206        .fill(&mut nonce)
10207        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10208    let stage_label = format!(
10209        ".{}.dbmd-pull-stage-{}",
10210        name.to_string_lossy(),
10211        URL_SAFE_NO_PAD.encode(nonce)
10212    );
10213    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10214    let stage_dir = create_dir_exclusive_at(
10215        parent_dir.as_raw_fd(),
10216        &stage_name,
10217        &dest.display().to_string(),
10218    )?;
10219    let prepared = (|| -> LinkResult<()> {
10220        if dest_exists {
10221            let live = open_dir_at(
10222                parent_dir.as_raw_fd(),
10223                &dest_name,
10224                &dest.display().to_string(),
10225            )?;
10226            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10227        }
10228        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10229        write_pull_sources_beneath_dir(&stage_dir, entries)?;
10230        if rebuild_indexes {
10231            let stage_store =
10232                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10233                    .map_err(|error| LinkError::InvalidPack {
10234                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10235                    })?;
10236            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10237                LinkError::InvalidPack {
10238                    message: format!("could not materialize v2 local catalogs: {error}"),
10239                }
10240            })?;
10241        }
10242        stage_dir.sync_all()?;
10243        Ok(())
10244    })();
10245    if let Err(error) = prepared {
10246        let _ = remove_tree_at(
10247            parent_dir.as_raw_fd(),
10248            &stage_name,
10249            &dest.display().to_string(),
10250        );
10251        return Err(error);
10252    }
10253    if let Err(error) =
10254        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10255    {
10256        let _ = remove_tree_at(
10257            parent_dir.as_raw_fd(),
10258            &stage_name,
10259            &dest.display().to_string(),
10260        );
10261        return Err(error);
10262    }
10263    parent_dir.sync_all()?;
10264    if dest_exists {
10265        let _ = remove_tree_at(
10266            parent_dir.as_raw_fd(),
10267            &stage_name,
10268            &dest.display().to_string(),
10269        );
10270        let _ = parent_dir.sync_all();
10271    }
10272    Ok(())
10273}
10274
10275#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10276struct V2PullCoordinate {
10277    head_seq: Option<u64>,
10278    commit_hash: Option<String>,
10279    view_kind: Option<String>,
10280    view_revision: Option<String>,
10281}
10282
10283#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10284struct V2PullFileCoordinate {
10285    sha256: String,
10286    bytes: u64,
10287}
10288
10289#[derive(Debug, Clone, Deserialize, Serialize)]
10290struct V2PullJournalEntry {
10291    path: String,
10292    old: Option<V2PullFileCoordinate>,
10293    new: Option<V2PullFileCoordinate>,
10294    backup: Option<String>,
10295}
10296
10297#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
10298#[serde(rename_all = "snake_case")]
10299enum V2PullPhase {
10300    Preparing,
10301    Ready,
10302}
10303
10304#[derive(Debug, Clone, Deserialize, Serialize)]
10305struct V2PullJournal {
10306    v: u8,
10307    phase: V2PullPhase,
10308    brain: String,
10309    previous: V2PullCoordinate,
10310    next: V2PullCoordinate,
10311    backup_dir: String,
10312    entries: Vec<V2PullJournalEntry>,
10313}
10314
10315const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
10316
10317fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
10318    V2PullCoordinate {
10319        head_seq: baseline.and_then(|value| value.head_seq),
10320        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
10321        view_kind: baseline.and_then(|value| value.view_kind.clone()),
10322        view_revision: baseline.and_then(|value| value.view_revision.clone()),
10323    }
10324}
10325
10326fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
10327    V2PullCoordinate {
10328        head_seq: head.pointer.as_ref().map(|value| value.seq),
10329        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
10330        view_kind: Some(head.view_kind.clone()),
10331        view_revision: Some(head.view_revision.clone()),
10332    }
10333}
10334
10335fn v2_pull_file_coordinate(
10336    store: &Store,
10337    path: &str,
10338    limit: u64,
10339) -> LinkResult<Option<V2PullFileCoordinate>> {
10340    let file = match store.open_regular(Path::new(path)) {
10341        Ok(file) => file,
10342        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10343        Err(error) => return Err(error.into()),
10344    };
10345    let bytes = file.metadata()?.len();
10346    if bytes > limit || bytes > MAX_STORE_BYTES {
10347        return Err(invalid_feed(
10348            "pull transaction file exceeds its declared bound",
10349        ));
10350    }
10351    Ok(Some(V2PullFileCoordinate {
10352        sha256: content_sha256_reader(file)?,
10353        bytes,
10354    }))
10355}
10356
10357fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
10358    let mut bytes = serde_json::to_vec_pretty(journal)
10359        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
10360    bytes.push(b'\n');
10361    Ok(bytes)
10362}
10363
10364fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10365    let backup_prefix = ".dbmd/pull-backup-";
10366    let suffix = journal
10367        .backup_dir
10368        .strip_prefix(backup_prefix)
10369        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10370    let mut paths = std::collections::BTreeSet::new();
10371    if journal.v != 1
10372        || !crate::ulid::is_ulid(&journal.brain)
10373        || !crate::ulid::is_ulid(suffix)
10374        || journal.entries.is_empty()
10375        || journal.entries.len() > MAX_PUSH_FILES + 4
10376        || journal.previous == journal.next
10377    {
10378        return Err(invalid_feed("v2 pull journal failed validation"));
10379    }
10380    for (index, entry) in journal.entries.iter().enumerate() {
10381        if !safe_store_rel_path(&entry.path)
10382            || entry.path == V2_PULL_JOURNAL
10383            || entry.path.starts_with(backup_prefix)
10384            || !paths.insert(entry.path.clone())
10385            || (entry.old.is_none() && entry.new.is_none())
10386            || entry
10387                .old
10388                .iter()
10389                .chain(entry.new.iter())
10390                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10391            || entry.backup.as_deref()
10392                != entry
10393                    .old
10394                    .as_ref()
10395                    .map(|_| format!("{index:08x}"))
10396                    .as_deref()
10397        {
10398            return Err(invalid_feed("v2 pull journal entry failed validation"));
10399        }
10400    }
10401    Ok(())
10402}
10403
10404fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10405    #[cfg(unix)]
10406    {
10407        use std::os::unix::fs::PermissionsExt as _;
10408        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10409            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10410                return Err(invalid_feed(
10411                    "v2 pull journal is accessible to group/other; set mode 0600",
10412                ));
10413            }
10414            Ok(_) => {}
10415            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10416            Err(error) => return Err(error.into()),
10417        }
10418    }
10419    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10420        Ok(bytes) => bytes,
10421        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10422        Err(error) => return Err(error.into()),
10423    };
10424    let journal: V2PullJournal =
10425        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10426    validate_v2_pull_journal(&journal)?;
10427    Ok(Some(journal))
10428}
10429
10430fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10431    // The journal is the only recovery authority. Remove it first only after
10432    // rollback or baseline commit is durable; an orphan private backup is safe
10433    // and can be pruned, while a journal without its backups is not recoverable.
10434    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10435        Ok(()) => {}
10436        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10437        Err(error) => return Err(error.into()),
10438    }
10439    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10440        Ok(()) => Ok(()),
10441        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10442        Err(error) => Err(error.into()),
10443    }
10444}
10445
10446fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10447    let names = match store.directory_names(Path::new(".dbmd")) {
10448        Ok(names) => names,
10449        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10450        Err(error) => return Err(error.into()),
10451    };
10452    for name in names {
10453        let Some(name) = name.to_str() else {
10454            continue;
10455        };
10456        let Some(suffix) = name.strip_prefix("pull-backup-") else {
10457            continue;
10458        };
10459        if crate::ulid::is_ulid(suffix) {
10460            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10461        }
10462    }
10463    Ok(())
10464}
10465
10466fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10467    // Validate every live coordinate and every backup before the first restore.
10468    for entry in &journal.entries {
10469        let limit = entry
10470            .old
10471            .as_ref()
10472            .into_iter()
10473            .chain(entry.new.iter())
10474            .map(|value| value.bytes)
10475            .max()
10476            .unwrap_or(0);
10477        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10478        if current != entry.old && current != entry.new {
10479            return Err(LinkError::InvalidPack {
10480                message: format!(
10481                    "cannot recover interrupted pull because `{}` changed afterward",
10482                    entry.path
10483                ),
10484            });
10485        }
10486        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10487            let path = Path::new(&journal.backup_dir).join(backup);
10488            let file = store.open_regular(&path)?;
10489            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10490                return Err(invalid_feed("v2 pull recovery backup failed verification"));
10491            }
10492        }
10493    }
10494    for entry in journal.entries.iter().rev() {
10495        match (&entry.old, &entry.backup) {
10496            (Some(old), Some(backup)) => {
10497                let bytes =
10498                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10499                store.write_atomic(Path::new(&entry.path), &bytes)?;
10500            }
10501            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10502                store.remove_file(Path::new(&entry.path))?;
10503            }
10504            (None, None) => {}
10505            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10506        }
10507    }
10508    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10509        message: format!("could not rebuild catalogs after pull recovery: {error}"),
10510    })?;
10511    cleanup_v2_pull_journal(store, journal)
10512}
10513
10514fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10515    let Ok(store) = Store::open_strict(dest) else {
10516        return Ok(());
10517    };
10518    if let Some(journal) = load_v2_pull_journal(&store)? {
10519        if journal.brain != brain {
10520            return Err(invalid_feed("v2 pull journal belongs to another brain"));
10521        }
10522        if journal.phase == V2PullPhase::Preparing {
10523            cleanup_v2_pull_journal(&store, &journal)?;
10524        } else {
10525            let baseline = load_v2_baseline(cfg, brain, dest)?;
10526            let current = v2_pull_baseline_coordinate(baseline.as_ref());
10527            if current == journal.next {
10528                cleanup_v2_pull_journal(&store, &journal)?;
10529            } else {
10530                if current != journal.previous {
10531                    return Err(invalid_feed(
10532                        "cannot recover interrupted pull because its baseline changed afterward",
10533                    ));
10534                }
10535                rollback_v2_pull(&store, &journal)?;
10536            }
10537        }
10538    }
10539    // Cleanup removes the journal first after the state is durably old or new.
10540    // A hard kill in the tiny interval before deleting its backup directory can
10541    // therefore leave only an inert, private orphan. The per-brain operation
10542    // lock is held by the caller, so no live pull can own one here.
10543    prune_orphan_v2_pull_backups(&store)
10544}
10545
10546fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10547    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10548        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10549    })?;
10550    if let Some(journal) = load_v2_pull_journal(&store)? {
10551        cleanup_v2_pull_journal(&store, &journal)?;
10552    }
10553    Ok(())
10554}
10555
10556#[cfg(windows)]
10557fn install_windows_initial_sources(
10558    dest: &Path,
10559    entries: &[V2StagedFile],
10560    rebuild_indexes: bool,
10561) -> LinkResult<()> {
10562    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10563    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10564        path: dest.display().to_string(),
10565    })?;
10566    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10567    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10568        return Err(LinkError::UnsafePath {
10569            path: dest.display().to_string(),
10570        });
10571    }
10572    let stage_name = format!(
10573        ".{}.dbmd-pull-stage-{}",
10574        name.to_string_lossy(),
10575        crate::ulid::mint()
10576    );
10577    let stage_path = parent.join(&stage_name);
10578    let stage_capability =
10579        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10580    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10581    let prepared = (|| -> LinkResult<()> {
10582        for entry in entries {
10583            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10584            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10585                return Err(invalid_feed(
10586                    "private staged sync source failed final integrity verification",
10587                ));
10588            }
10589            stage.write_atomic(Path::new(&entry.path), &bytes)?;
10590        }
10591        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10592            .map_err(|error| LinkError::InvalidPack {
10593                message: format!("v2 staging tree is not a valid db.md store: {error}"),
10594            })?;
10595        if rebuild_indexes {
10596            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10597                message: format!("could not materialize v2 local catalogs: {error}"),
10598            })?;
10599        }
10600        Ok(())
10601    })();
10602    if let Err(error) = prepared {
10603        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10604        return Err(error);
10605    }
10606    crate::fsx::rename_directory_beneath(
10607        &parent_capability,
10608        Path::new(&stage_name),
10609        Path::new(name),
10610    )?;
10611    Ok(())
10612}
10613
10614fn install_established_v2_delta(
10615    store: Store,
10616    entries: &[V2StagedFile],
10617    deleted: &[String],
10618    rebuild_indexes: bool,
10619    previous: Option<&V2SyncBaseline>,
10620    next: &V2VerifiedHead,
10621) -> LinkResult<()> {
10622    if load_v2_pull_journal(&store)?.is_some() {
10623        return Err(invalid_feed(
10624            "an interrupted pull must be recovered before installing",
10625        ));
10626    }
10627    let mut sources = std::collections::BTreeMap::new();
10628    for entry in entries {
10629        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10630            return Err(invalid_feed("pull mutation repeats a path"));
10631        }
10632    }
10633    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10634    paths.extend(deleted.iter().cloned());
10635    paths.sort();
10636    paths.dedup();
10637    if paths.is_empty() {
10638        return Ok(());
10639    }
10640    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10641    let mut journal = V2PullJournal {
10642        v: 1,
10643        phase: V2PullPhase::Preparing,
10644        brain: next.brain_id.clone(),
10645        previous: v2_pull_baseline_coordinate(previous),
10646        next: v2_pull_head_coordinate(next),
10647        backup_dir: backup_dir.clone(),
10648        entries: Vec::with_capacity(paths.len()),
10649    };
10650    for path in &paths {
10651        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10652        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10653            sha256: entry.sha256.clone(),
10654            bytes: entry.bytes,
10655        });
10656        if old == new {
10657            continue;
10658        }
10659        let index = journal.entries.len();
10660        journal.entries.push(V2PullJournalEntry {
10661            path: path.clone(),
10662            backup: old.as_ref().map(|_| format!("{index:08x}")),
10663            old,
10664            new,
10665        });
10666    }
10667    if journal.entries.is_empty() {
10668        return Ok(());
10669    }
10670    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10671        entry
10672            .old
10673            .as_ref()
10674            .map_or(Some(total), |old| total.checked_add(old.bytes))
10675    });
10676    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10677        return Err(LinkError::InvalidPack {
10678            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10679        });
10680    }
10681    validate_v2_pull_journal(&journal)?;
10682    store.write_private_atomic_new(
10683        Path::new(V2_PULL_JOURNAL),
10684        &v2_pull_journal_bytes(&journal)?,
10685    )?;
10686    let prepared = (|| -> LinkResult<()> {
10687        store.create_private_dir_all(Path::new(&backup_dir))?;
10688        for entry in &journal.entries {
10689            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10690                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10691                if content_sha256(&bytes) != old.sha256 {
10692                    return Err(invalid_feed("live pull source changed during backup"));
10693                }
10694                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10695            }
10696        }
10697        journal.phase = V2PullPhase::Ready;
10698        store.write_private_atomic(
10699            Path::new(V2_PULL_JOURNAL),
10700            &v2_pull_journal_bytes(&journal)?,
10701        )?;
10702        Ok(())
10703    })();
10704    if let Err(error) = prepared {
10705        let cleanup = cleanup_v2_pull_journal(&store, &journal);
10706        return match cleanup {
10707            Ok(()) => Err(error),
10708            Err(cleanup) => Err(LinkError::InvalidPack {
10709                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10710            }),
10711        };
10712    }
10713    let installed = (|| -> LinkResult<()> {
10714        for entry in &journal.entries {
10715            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10716                return Err(LinkError::InvalidPack {
10717                    message: format!("local path `{}` changed during pull", entry.path),
10718                });
10719            }
10720            if let Some(source) = sources.get(&entry.path) {
10721                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10722                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10723                    return Err(invalid_feed(
10724                        "private staged sync source failed final integrity verification",
10725                    ));
10726                }
10727                store.write_atomic(Path::new(&entry.path), &bytes)?;
10728            } else if entry.old.is_some() {
10729                store.remove_file(Path::new(&entry.path))?;
10730            }
10731        }
10732        if rebuild_indexes {
10733            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10734                message: format!("could not materialize v2 local catalogs: {error}"),
10735            })?;
10736        }
10737        Ok(())
10738    })();
10739    if let Err(error) = installed {
10740        return match rollback_v2_pull(&store, &journal) {
10741            Ok(()) => Err(error),
10742            Err(rollback) => Err(LinkError::InvalidPack {
10743                message: format!("{error}; durable pull rollback also failed: {rollback}"),
10744            }),
10745        };
10746    }
10747    Ok(())
10748}
10749
10750#[cfg(windows)]
10751fn install_pulled_delta_sources(
10752    dest: &Path,
10753    entries: &[V2StagedFile],
10754    deleted: &[String],
10755    rebuild_indexes: bool,
10756    previous: Option<&V2SyncBaseline>,
10757    next: &V2VerifiedHead,
10758) -> LinkResult<()> {
10759    match Store::open_strict(dest) {
10760        Ok(store) => {
10761            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10762        }
10763        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10764    }
10765}
10766
10767#[cfg(not(any(unix, windows)))]
10768fn install_pulled_delta_sources(
10769    _dest: &Path,
10770    _entries: &[V2StagedFile],
10771    _deleted: &[String],
10772    _rebuild_indexes: bool,
10773    _previous: Option<&V2SyncBaseline>,
10774    _next: &V2VerifiedHead,
10775) -> LinkResult<()> {
10776    Err(LinkError::UnsupportedPlatform {
10777        operation: "atomic v2 pull install",
10778    })
10779}
10780
10781#[cfg(unix)]
10782fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10783    install_pulled_delta(dest, entries, &[], false)
10784}
10785
10786#[cfg(not(windows))]
10787fn is_safe_slug(slug: &str) -> bool {
10788    !slug.is_empty()
10789        && slug.len() <= 63
10790        && !slug.starts_with('-')
10791        && !slug.ends_with('-')
10792        && slug
10793            .bytes()
10794            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10795}
10796
10797fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10798    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10799}
10800
10801fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10802    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10803}
10804
10805fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10806    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10807}
10808
10809fn preflight_zip_central_directory(
10810    bytes: &[u8],
10811    offset: usize,
10812    size: usize,
10813    count: u64,
10814) -> LinkResult<()> {
10815    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10816    let end = offset
10817        .checked_add(size)
10818        .filter(|end| *end <= bytes.len())
10819        .ok_or_else(|| LinkError::InvalidPack {
10820            message: "ZIP central directory is out of bounds".to_string(),
10821        })?;
10822    let mut cursor = offset;
10823    for _ in 0..count {
10824        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10825            return Err(LinkError::InvalidPack {
10826                message: "ZIP central directory entry count is inconsistent".to_string(),
10827            });
10828        }
10829        if le_u16(bytes, cursor + 34) != Some(0) {
10830            return Err(LinkError::InvalidPack {
10831                message: "multi-disk ZIP archives are not supported".to_string(),
10832            });
10833        }
10834        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10835            total.checked_add(le_u16(bytes, cursor + at)? as usize)
10836        });
10837        cursor = cursor
10838            .checked_add(46)
10839            .and_then(|fixed| fixed.checked_add(variable?))
10840            .filter(|cursor| *cursor <= end)
10841            .ok_or_else(|| LinkError::InvalidPack {
10842                message: "ZIP central directory entry is truncated".to_string(),
10843            })?;
10844    }
10845    if cursor != end {
10846        return Err(LinkError::InvalidPack {
10847            message: "ZIP central directory size is inconsistent".to_string(),
10848        });
10849    }
10850    Ok(())
10851}
10852
10853/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
10854/// metadata object per central-directory entry. Supports ordinary EOCD and the
10855/// Zip64 locator/record emitted for >65,535-entry archives.
10856fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10857    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10858    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10859    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10860    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10861    let eocd = bytes[search_start..]
10862        .windows(4)
10863        .rposition(|window| window == EOCD_SIG)
10864        .map(|offset| search_start + offset)
10865        .ok_or_else(|| LinkError::InvalidPack {
10866            message: "ZIP has no end-of-central-directory record".to_string(),
10867        })?;
10868    let invalid_end = || LinkError::InvalidPack {
10869        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10870    };
10871    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10872    if eocd
10873        .checked_add(22)
10874        .and_then(|end| end.checked_add(comment_len))
10875        != Some(bytes.len())
10876    {
10877        // Do not fall back to an earlier signature. ZipArchive does, and a
10878        // fake low-count EOCD appended after a real Zip64 entry-count bomb
10879        // would otherwise bypass this allocation preflight.
10880        return Err(invalid_end());
10881    }
10882    let disk = le_u16(bytes, eocd + 4);
10883    let central_disk = le_u16(bytes, eocd + 6);
10884    if disk != Some(0) || central_disk != Some(0) {
10885        return Err(LinkError::InvalidPack {
10886            message: "multi-disk ZIP archives are not supported".to_string(),
10887        });
10888    }
10889    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10890    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10891    if entries_on_disk != ordinary {
10892        return Err(LinkError::InvalidPack {
10893            message: "multi-disk ZIP archives are not supported".to_string(),
10894        });
10895    }
10896    let zip64_locator = eocd
10897        .checked_sub(20)
10898        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10899    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10900        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10901        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10902        if central_offset
10903            .checked_add(central_size)
10904            .filter(|end| *end == eocd)
10905            .is_none()
10906        {
10907            return Err(invalid_end());
10908        }
10909        (ordinary as u64, central_offset, central_size)
10910    } else {
10911        let Some(locator) = zip64_locator else {
10912            return Err(invalid_end());
10913        };
10914        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10915            return Err(LinkError::InvalidPack {
10916                message: "multi-disk ZIP64 archives are not supported".to_string(),
10917            });
10918        }
10919        let record = le_u64(bytes, locator + 8)
10920            .and_then(|offset| usize::try_from(offset).ok())
10921            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10922            .ok_or_else(|| LinkError::InvalidPack {
10923                message: "ZIP64 archive has an invalid end record".to_string(),
10924            })?;
10925        let record_size = le_u64(bytes, record + 4)
10926            .and_then(|size| usize::try_from(size).ok())
10927            .filter(|size| *size >= 44)
10928            .ok_or_else(invalid_end)?;
10929        if record
10930            .checked_add(12)
10931            .and_then(|end| end.checked_add(record_size))
10932            != Some(locator)
10933            || le_u32(bytes, record + 16) != Some(0)
10934            || le_u32(bytes, record + 20) != Some(0)
10935        {
10936            return Err(invalid_end());
10937        }
10938        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10939        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10940        let central_size = le_u64(bytes, record + 40)
10941            .and_then(|size| usize::try_from(size).ok())
10942            .ok_or_else(invalid_end)?;
10943        let central_offset = le_u64(bytes, record + 48)
10944            .and_then(|offset| usize::try_from(offset).ok())
10945            .ok_or_else(invalid_end)?;
10946        if zip64_on_disk != zip64_total
10947            || central_offset
10948                .checked_add(central_size)
10949                .filter(|end| *end == record)
10950                .is_none()
10951        {
10952            return Err(invalid_end());
10953        }
10954        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10955        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10956        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10957            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10958        {
10959            return Err(invalid_end());
10960        }
10961        (zip64_total, central_offset, central_size)
10962    };
10963    if count == 0 || count > max_entries as u64 {
10964        return Err(LinkError::InvalidPack {
10965            message: format!("invalid file count {count}"),
10966        });
10967    }
10968    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10969    Ok(())
10970}
10971
10972fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10973    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10974    let mut archive =
10975        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10976            message: format!("ZIP parse failed: {err}"),
10977        })?;
10978    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10979        return Err(LinkError::InvalidPack {
10980            message: format!("invalid file count {}", archive.len()),
10981        });
10982    }
10983    let mut total = 0u64;
10984    let mut seen = std::collections::HashSet::new();
10985    let mut entries = Vec::with_capacity(archive.len());
10986    for index in 0..archive.len() {
10987        let mut file = archive
10988            .by_index(index)
10989            .map_err(|err| LinkError::InvalidPack {
10990                message: format!("ZIP entry failed: {err}"),
10991            })?;
10992        if file.is_dir() {
10993            continue;
10994        }
10995        let path = file.name().to_string();
10996        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10997            return Err(LinkError::UnsafePath { path });
10998        }
10999        if file
11000            .unix_mode()
11001            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
11002        {
11003            return Err(LinkError::InvalidPack {
11004                message: format!("non-file entry `{path}`"),
11005            });
11006        }
11007        if !seen.insert(path.clone()) {
11008            return Err(LinkError::InvalidPack {
11009                message: format!("duplicate path `{path}`"),
11010            });
11011        }
11012        let remaining = MAX_STORE_BYTES.saturating_sub(total);
11013        if file.size() > remaining {
11014            return Err(LinkError::InvalidPack {
11015                message: "expanded content exceeds the 512 MB limit".to_string(),
11016            });
11017        }
11018        let mut content = Vec::new();
11019        (&mut file)
11020            .take(remaining + 1)
11021            .read_to_end(&mut content)
11022            .map_err(|err| LinkError::InvalidPack {
11023                message: format!("could not decompress `{path}`: {err}"),
11024            })?;
11025        if content.len() as u64 > remaining {
11026            return Err(LinkError::InvalidPack {
11027                message: "expanded content exceeds the 512 MB limit".to_string(),
11028            });
11029        }
11030        if content.len() as u64 != file.size() {
11031            return Err(LinkError::InvalidPack {
11032                message: format!("length mismatch for `{path}`"),
11033            });
11034        }
11035        total += content.len() as u64;
11036        entries.push((path, content));
11037    }
11038    if entries.is_empty() {
11039        return Err(LinkError::InvalidPack {
11040            message: "pack contains no files".to_string(),
11041        });
11042    }
11043    Ok(entries)
11044}
11045
11046fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
11047    let mut expected = std::collections::BTreeMap::new();
11048    for file in signed {
11049        if !safe_store_rel_path(&file.path) {
11050            return Err(LinkError::UnsafePath {
11051                path: file.path.clone(),
11052            });
11053        }
11054        if !is_sha256(&file.sha256)
11055            || expected
11056                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11057                .is_some()
11058        {
11059            return Err(invalid_feed(
11060                "signed snapshot manifest contains an invalid or duplicate file",
11061            ));
11062        }
11063    }
11064    if expected.len() != entries.len() {
11065        return Err(invalid_feed(
11066            "downloaded pack file set differs from the signed snapshot manifest",
11067        ));
11068    }
11069    for (path, bytes) in entries {
11070        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
11071            return Err(invalid_feed(format!(
11072                "downloaded pack contains unsigned path `{path}`"
11073            )));
11074        };
11075        if *declared_bytes != bytes.len() as u64
11076            || *sha256 != format!("{:x}", Sha256::digest(bytes))
11077        {
11078            return Err(invalid_feed(format!(
11079                "downloaded file `{path}` differs from its signed manifest"
11080            )));
11081        }
11082    }
11083    Ok(())
11084}
11085
11086/// Collect the files a push sends: the store's owned text — `DB.md`,
11087/// `assets.jsonl` when present, and every content `.md` under `records/` and
11088/// `sources/` (the store walk, which already excludes hidden dirs like
11089/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
11090/// derives its own index, and local history stays local). Returns
11091/// `(store-relative path, content)` pairs, path-sorted.
11092pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11093    require_hardened_filesystem("sync push")?;
11094    preflight_push_ownership(store)?;
11095    let mut out: Vec<(String, String)> = Vec::new();
11096    let mut total = 0u64;
11097
11098    let mut read_text = |rel: &str| -> LinkResult<String> {
11099        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11100        total = total
11101            .checked_add(bytes.len() as u64)
11102            .ok_or_else(|| LinkError::PushTooLarge {
11103                detail: "uncompressed byte count overflow".to_string(),
11104            })?;
11105        if total > MAX_STORE_BYTES {
11106            return Err(LinkError::PushTooLarge {
11107                detail: format!("{total} uncompressed bytes"),
11108            });
11109        }
11110        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11111            path: rel.to_string(),
11112        })
11113    };
11114
11115    out.push(("DB.md".to_string(), read_text("DB.md")?));
11116    if store
11117        .regular_file_exists(Path::new("assets.jsonl"))
11118        .unwrap_or(false)
11119    {
11120        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11121    }
11122
11123    for rel in store.walk()? {
11124        let rel_str = rel.to_string_lossy().replace('\\', "/");
11125        if !safe_store_rel_path(&rel_str) {
11126            // A locally-legal name outside the hub's portable charset cannot
11127            // travel this wire; refusing beats silently dropping it.
11128            return Err(LinkError::UnsafePath { path: rel_str });
11129        }
11130        let content = read_text(&rel_str)?;
11131        out.push((rel_str, content));
11132    }
11133
11134    out.sort_by(|a, b| a.0.cmp(&b.0));
11135    Ok(out)
11136}
11137
11138/// Refuse to build a destructive whole-store snapshot from an ambiguous local
11139/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
11140/// silently omitted them could delete the hosted copies of those paths.
11141fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11142    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11143        return Err(LinkError::from(std::io::Error::new(
11144            std::io::ErrorKind::PermissionDenied,
11145            format!("cannot push: nested db.md store at {}", nested.display()),
11146        )));
11147    }
11148
11149    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11150        return Err(LinkError::from(std::io::Error::new(
11151            std::io::ErrorKind::PermissionDenied,
11152            format!(
11153                "cannot push: {} is a symlink outside the store ownership model",
11154                symlink.display()
11155            ),
11156        )));
11157    }
11158    Ok(())
11159}
11160
11161/// Push `files` to `brain` as a whole-store snapshot — the hub's push
11162/// semantics: the hosted copy becomes exactly this set (pull first if the
11163/// hosted side may have records the local copy lacks). Client-side caps
11164/// mirror the hub's JSON-path limits so an oversized push fails before the
11165/// upload.
11166pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11167    require_safe_ref(brain)?;
11168    let remote = verified_remote_head(cfg, brain, false)?;
11169    if files.len() > MAX_PUSH_FILES {
11170        return Err(LinkError::PushTooLarge {
11171            detail: format!("{} files", files.len()),
11172        });
11173    }
11174    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11175    if raw_total > MAX_STORE_BYTES {
11176        return Err(LinkError::PushTooLarge {
11177            detail: format!("{raw_total} uncompressed bytes"),
11178        });
11179    }
11180
11181    // Self-custody (a brain key is configured): the JSON fast path is
11182    // hub-signed by construction, so every push goes through the pack flow
11183    // with a locally signed entry — the hub verifies and can never sign.
11184    if cfg.brain_key.is_none() {
11185        let body = json!({
11186            "files": files
11187                .iter()
11188                .map(|(p, c)| json!({ "path": p, "content": c }))
11189                .collect::<Vec<_>>(),
11190        });
11191        if body.to_string().len() <= MAX_PUSH_BYTES {
11192            let path = format!("/api/hub/brains/{brain}/push");
11193            let pushed = ensure_ok(
11194                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11195                "sync push",
11196            )?;
11197            return Ok(pushed);
11198        }
11199    }
11200
11201    let pack = build_store_pack(files)?;
11202    if pack.len() as u64 > MAX_PACK_BYTES {
11203        return Err(LinkError::PushTooLarge {
11204            detail: format!("{} pack bytes", pack.len()),
11205        });
11206    }
11207    let sha256 = format!("{:x}", Sha256::digest(&pack));
11208    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11209    if let Some(key) = &cfg.brain_key {
11210        if !remote.head.verified {
11211            return Err(invalid_feed(
11212                "self-custody push requires a fully verified, unscoped feed head",
11213            ));
11214        }
11215        let identity = remote
11216            .identity
11217            .as_ref()
11218            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
11219        let current_multikey = format!("ed25519:{}", identity.fingerprint);
11220        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
11221            return Err(invalid_feed(
11222                "configured brain key is not the verified current brain identity",
11223            ));
11224        }
11225        // The verified state pins seq + prev; a concurrent writer surfaces as
11226        // the hub's 422 on commit (re-run to retry against the new head).
11227        let next_seq = remote
11228            .head
11229            .seq
11230            .checked_add(1)
11231            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
11232        let mut manifest: Vec<WireFeedFile> = files
11233            .iter()
11234            .map(|(path, content)| WireFeedFile {
11235                path: path.clone(),
11236                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
11237                bytes: content.len() as u64,
11238            })
11239            .collect();
11240        manifest.sort_by(|a, b| a.path.cmp(&b.path));
11241        let ts = crate::now()
11242            .with_timezone(&chrono::Utc)
11243            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
11244            .to_string();
11245        let entry = self_custody_entry(
11246            key,
11247            next_seq,
11248            ts,
11249            &sha256,
11250            &manifest,
11251            remote.head.feed_hash.as_deref(),
11252        )?;
11253        meta["entry"] = Value::String(entry);
11254    }
11255    let presigned = ensure_ok(
11256        request(
11257            cfg,
11258            "POST",
11259            &format!("/api/hub/brains/{brain}/packs/presign"),
11260            Some(&meta),
11261            Auth::Required,
11262        )?,
11263        "prepare pack upload",
11264    )?;
11265    let url = presigned
11266        .get("url")
11267        .and_then(Value::as_str)
11268        .ok_or_else(|| LinkError::InvalidPack {
11269            message: "the hub returned no upload URL".to_string(),
11270        })?;
11271    put_presigned(
11272        cfg,
11273        url,
11274        presigned.get("headers").unwrap_or(&Value::Null),
11275        &pack,
11276    )?;
11277    let committed = ensure_ok(
11278        request(
11279            cfg,
11280            "POST",
11281            &format!("/api/hub/brains/{brain}/packs/commit"),
11282            Some(&meta),
11283            Auth::Required,
11284        )?,
11285        "commit pack",
11286    )?;
11287    Ok(committed)
11288}
11289
11290fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
11291    const LOCAL_HEADER: u32 = 0x0403_4b50;
11292    const CENTRAL_HEADER: u32 = 0x0201_4b50;
11293    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
11294    const VERSION_20: u16 = 20;
11295    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
11296    const UTF8_FLAG: u16 = 1 << 11;
11297    const STORED: u16 = 0;
11298    const DOS_TIME_MIDNIGHT: u16 = 0;
11299    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
11300    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
11301
11302    struct CentralEntry<'a> {
11303        name: &'a [u8],
11304        crc32: u32,
11305        size: u32,
11306        local_offset: u32,
11307    }
11308
11309    fn push_u16(out: &mut Vec<u8>, value: u16) {
11310        out.extend_from_slice(&value.to_le_bytes());
11311    }
11312
11313    fn push_u32(out: &mut Vec<u8>, value: u32) {
11314        out.extend_from_slice(&value.to_le_bytes());
11315    }
11316
11317    if files.is_empty() {
11318        return Err(LinkError::InvalidPack {
11319            message: "cannot create an empty snapshot pack".to_string(),
11320        });
11321    }
11322    if files.len() > u16::MAX as usize {
11323        return Err(LinkError::PushTooLarge {
11324            detail: format!(
11325                "{} files (canonical ZIP32 packs cap at {})",
11326                files.len(),
11327                u16::MAX
11328            ),
11329        });
11330    }
11331
11332    let mut sorted: Vec<_> = files.iter().collect();
11333    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
11334    let mut previous: Option<&str> = None;
11335    for (path, content) in &sorted {
11336        if !safe_store_rel_path(path) {
11337            return Err(LinkError::UnsafePath {
11338                path: (*path).clone(),
11339            });
11340        }
11341        if previous == Some(path.as_str()) {
11342            return Err(LinkError::InvalidPack {
11343                message: format!("duplicate path `{path}`"),
11344            });
11345        }
11346        previous = Some(path.as_str());
11347        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
11348            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11349        })?;
11350    }
11351
11352    let mut out = Vec::new();
11353    let mut central = Vec::with_capacity(sorted.len());
11354    for (path, content) in sorted {
11355        let name = path.as_bytes();
11356        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
11357            message: format!("ZIP entry name is too long: `{path}`"),
11358        })?;
11359        let bytes = content.as_bytes();
11360        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
11361            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11362        })?;
11363        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11364            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11365        })?;
11366        let crc32 = crc32fast::hash(bytes);
11367
11368        // Canonical local header: sizes and CRC are known up front. Bit 3 is
11369        // deliberately clear, so there is no trailing data descriptor.
11370        push_u32(&mut out, LOCAL_HEADER);
11371        push_u16(&mut out, VERSION_20);
11372        push_u16(&mut out, UTF8_FLAG);
11373        push_u16(&mut out, STORED);
11374        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11375        push_u16(&mut out, DOS_DATE_1980_01_01);
11376        push_u32(&mut out, crc32);
11377        push_u32(&mut out, size);
11378        push_u32(&mut out, size);
11379        push_u16(&mut out, name_len);
11380        push_u16(&mut out, 0); // no local extra data
11381        out.extend_from_slice(name);
11382        out.extend_from_slice(bytes);
11383
11384        central.push(CentralEntry {
11385            name,
11386            crc32,
11387            size,
11388            local_offset,
11389        });
11390    }
11391
11392    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11393        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11394    })?;
11395    for entry in &central {
11396        push_u32(&mut out, CENTRAL_HEADER);
11397        push_u16(&mut out, MADE_BY_UNIX_20);
11398        push_u16(&mut out, VERSION_20);
11399        push_u16(&mut out, UTF8_FLAG);
11400        push_u16(&mut out, STORED);
11401        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11402        push_u16(&mut out, DOS_DATE_1980_01_01);
11403        push_u32(&mut out, entry.crc32);
11404        push_u32(&mut out, entry.size);
11405        push_u32(&mut out, entry.size);
11406        push_u16(&mut out, entry.name.len() as u16);
11407        push_u16(&mut out, 0); // no central extra data
11408        push_u16(&mut out, 0); // no file comment
11409        push_u16(&mut out, 0); // disk number
11410        push_u16(&mut out, 0); // internal attributes
11411        push_u32(&mut out, UNIX_REGULAR_0600);
11412        push_u32(&mut out, entry.local_offset);
11413        out.extend_from_slice(entry.name);
11414    }
11415    let central_size = u32::try_from(out.len())
11416        .ok()
11417        .and_then(|end| end.checked_sub(central_offset))
11418        .ok_or_else(|| LinkError::PushTooLarge {
11419            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11420        })?;
11421    let entry_count = central.len() as u16;
11422
11423    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11424    push_u16(&mut out, 0); // this disk
11425    push_u16(&mut out, 0); // central directory disk
11426    push_u16(&mut out, entry_count);
11427    push_u16(&mut out, entry_count);
11428    push_u32(&mut out, central_size);
11429    push_u32(&mut out, central_offset);
11430    push_u16(&mut out, 0); // no archive comment
11431
11432    if out.len() > u32::MAX as usize {
11433        return Err(LinkError::PushTooLarge {
11434            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11435        });
11436    }
11437    Ok(out)
11438}
11439
11440// ─────────────────────────────────────────────────────────────────────────────
11441// grant — issue / list / revoke capabilities (owner-side)
11442// ─────────────────────────────────────────────────────────────────────────────
11443
11444/// The two capabilities a v0 hub enforces.
11445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11446pub enum Capability {
11447    /// Read the granted slice.
11448    Read,
11449    /// Read and push (whole-store; a path-scoped grant is read-only).
11450    Write,
11451}
11452
11453impl Capability {
11454    /// The wire form.
11455    pub fn as_str(self) -> &'static str {
11456        match self {
11457            Capability::Read => "read",
11458            Capability::Write => "write",
11459        }
11460    }
11461}
11462
11463/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
11464/// by email in v0 (the protocol's near-term simplification; key-named
11465/// grantees arrive with the signing layer). `scope` is a store-path prefix
11466/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
11467/// revoked.
11468pub fn grant_issue(
11469    cfg: &HubConfig,
11470    brain: &str,
11471    grantee: &str,
11472    can: Capability,
11473    scope: Option<&str>,
11474    until: Option<&str>,
11475) -> LinkResult<Value> {
11476    require_safe_ref(brain)?;
11477    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
11478    // multikey holder (link.md §6 cross-party keys — no hub account; the
11479    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
11480    // hub principal named by email.
11481    let is_key_grantee = URL_SAFE_NO_PAD
11482        .decode(grantee)
11483        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11484        .unwrap_or(false);
11485    if let Some(head) = v2_verified_head(cfg, brain)? {
11486        if is_key_grantee {
11487            let scope = scope.unwrap_or("");
11488            let preset = match can {
11489                Capability::Read => "viewer",
11490                Capability::Write => "editor",
11491            };
11492            let entropy = format!(
11493                "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11494                normalized_origin(&cfg.hub)?,
11495                head.brain_id,
11496                head.control_revision,
11497                grantee,
11498                preset,
11499                scope,
11500                until.unwrap_or("")
11501            );
11502            let mut body = json!({
11503                "context": "external",
11504                "expected_control_revision": head.control_revision,
11505                "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11506                "preset": preset,
11507                "principal_kind": "key",
11508                "public_key": grantee,
11509                "scope": scope,
11510                "scope_kind": "prefix",
11511            });
11512            if let Some(value) = until {
11513                body["expires_at"] = json!(value);
11514            }
11515            let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11516            let response = ensure_ok(
11517                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11518                "v2 grant issue",
11519            )?;
11520            let expected_fingerprint = identity_fingerprint(grantee)?;
11521            if response.get("v").and_then(Value::as_u64) != Some(2)
11522                || response
11523                    .get("id")
11524                    .and_then(Value::as_str)
11525                    .is_none_or(|id| !crate::ulid::is_ulid(id))
11526                || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11527                || response.get("principal_id").and_then(Value::as_str)
11528                    != Some(expected_fingerprint.as_str())
11529                || response
11530                    .get("control_revision")
11531                    .and_then(Value::as_str)
11532                    .is_none_or(|value| !is_sha256(value))
11533            {
11534                return Err(invalid_feed(
11535                    "v2 grant issue response is not authority-bound",
11536                ));
11537            }
11538            return Ok(response);
11539        }
11540        // Account invitations remain a product workflow. The compatibility
11541        // endpoint resolves email/account identity, then commits a native v2
11542        // grant with the same control-plane recovery boundary. The v2 head
11543        // above is already the authenticated authority checkpoint; never
11544        // fall through to the retired v1 feed verifier for this request.
11545        let mut body = json!({ "email": grantee, "capability": can.as_str() });
11546        if let Some(value) = scope {
11547            body["scopePrefix"] = json!(value);
11548        }
11549        if let Some(value) = until {
11550            body["expiresAt"] = json!(value);
11551        }
11552        let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11553        return ensure_ok(
11554            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11555            "account grant issue",
11556        );
11557    }
11558    let _ = verified_remote_head(cfg, brain, false)?;
11559    let mut body = if is_key_grantee {
11560        json!({ "keySpki": grantee, "capability": can.as_str() })
11561    } else {
11562        json!({ "email": grantee, "capability": can.as_str() })
11563    };
11564    if let Some(s) = scope {
11565        body["scopePrefix"] = json!(s);
11566    }
11567    if let Some(u) = until {
11568        body["expiresAt"] = json!(u);
11569    }
11570    let path = format!("/api/hub/brains/{brain}/grants");
11571    ensure_ok(
11572        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11573        "grant issue",
11574    )
11575}
11576
11577/// List the active grants (and pending invites) on `brain`. Owner-side.
11578pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11579    require_safe_ref(brain)?;
11580    if let Some(head) = v2_verified_head(cfg, brain)? {
11581        let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11582        let response = ensure_ok(
11583            request(cfg, "GET", &path, None, Auth::Required)?,
11584            "v2 grant list",
11585        )?;
11586        if response.get("v").and_then(Value::as_u64) != Some(2)
11587            || response.get("control_revision").and_then(Value::as_str)
11588                != Some(head.control_revision.as_str())
11589            || !response.get("grants").is_some_and(Value::is_array)
11590        {
11591            return Err(invalid_feed(
11592                "v2 grant list is not bound to the verified authority",
11593            ));
11594        }
11595        return Ok(response);
11596    }
11597    let _ = verified_remote_head(cfg, brain, false)?;
11598    let path = format!("/api/hub/brains/{brain}/grants");
11599    ensure_ok(
11600        request(cfg, "GET", &path, None, Auth::Required)?,
11601        "grant list",
11602    )
11603}
11604
11605/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
11606/// is soft on the hub (the audit trail survives).
11607pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11608    require_safe_ref(brain)?;
11609    require_safe_grant_id(grant_id)?;
11610    if let Some(head) = v2_verified_head(cfg, brain)? {
11611        let entropy = format!(
11612            "{}\0{}\0{}\0{}",
11613            normalized_origin(&cfg.hub)?,
11614            head.brain_id,
11615            head.control_revision,
11616            grant_id
11617        );
11618        let body = json!({
11619            "expected_control_revision": head.control_revision,
11620            "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11621        });
11622        let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11623        let response = ensure_ok(
11624            request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11625            "v2 grant revoke",
11626        )?;
11627        if response.get("v").and_then(Value::as_u64) != Some(2)
11628            || response.get("id").and_then(Value::as_str) != Some(grant_id)
11629            || response.get("revoked").and_then(Value::as_bool) != Some(true)
11630            || response
11631                .get("control_revision")
11632                .and_then(Value::as_str)
11633                .is_none_or(|value| !is_sha256(value))
11634        {
11635            return Err(invalid_feed(
11636                "v2 grant revocation response is not authority-bound",
11637            ));
11638        }
11639        return Ok(response);
11640    }
11641    let _ = verified_remote_head(cfg, brain, false)?;
11642    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11643    ensure_ok(
11644        request(cfg, "DELETE", &path, None, Auth::Required)?,
11645        "grant revoke",
11646    )
11647}
11648
11649// ─────────────────────────────────────────────────────────────────────────────
11650// permissioned v2 proposals — inspect, accept, reject
11651// ─────────────────────────────────────────────────────────────────────────────
11652
11653#[derive(Debug)]
11654struct VerifiedV2Proposal {
11655    value: Value,
11656    changes: Value,
11657    blobs: Vec<(String, u64, String)>,
11658}
11659
11660fn require_proposal_id(id: &str) -> LinkResult<()> {
11661    if crate::ulid::is_ulid(id) {
11662        Ok(())
11663    } else {
11664        Err(invalid_feed("proposal id is not a lowercase ULID"))
11665    }
11666}
11667
11668fn verified_v2_proposal(
11669    cfg: &HubConfig,
11670    head: &V2VerifiedHead,
11671    proposal_id: &str,
11672) -> LinkResult<VerifiedV2Proposal> {
11673    require_proposal_id(proposal_id)?;
11674    if head.view_kind != "full" {
11675        return Err(invalid_feed(
11676            "proposal review requires a full readable view",
11677        ));
11678    }
11679    let path = format!(
11680        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11681        head.brain_id
11682    );
11683    let value = ensure_ok(
11684        request_capped(
11685            cfg,
11686            "GET",
11687            &path,
11688            None,
11689            Auth::Required,
11690            MAX_FEED_RESPONSE_BYTES,
11691        )?,
11692        "v2 proposal",
11693    )?;
11694    verify_v2_proposal_value(head, proposal_id, value)
11695}
11696
11697fn verify_v2_proposal_value(
11698    head: &V2VerifiedHead,
11699    proposal_id: &str,
11700    value: Value,
11701) -> LinkResult<VerifiedV2Proposal> {
11702    if value.get("v").and_then(Value::as_u64) != Some(2) {
11703        return Err(invalid_feed("proposal response has an invalid version"));
11704    }
11705    let proposal = value
11706        .get("proposal")
11707        .and_then(Value::as_object)
11708        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11709    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11710        return Err(invalid_feed("proposal response changed its id"));
11711    }
11712    let payload_hash = proposal
11713        .get("payload_sha256")
11714        .and_then(Value::as_str)
11715        .filter(|hash| is_sha256(hash))
11716        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11717    let clear_hash = proposal
11718        .get("clear_sha256")
11719        .and_then(Value::as_str)
11720        .filter(|hash| is_sha256(hash))
11721        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11722    let submission_hash = proposal
11723        .get("submission_claim_sha256")
11724        .and_then(Value::as_str)
11725        .filter(|hash| is_sha256(hash))
11726        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11727    let submission = STANDARD
11728        .decode(
11729            proposal
11730                .get("submission_claim_base64")
11731                .and_then(Value::as_str)
11732                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11733        )
11734        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11735    let submission_value: Value = serde_json::from_slice(&submission)
11736        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11737    if crate::linkmd_v2::canonical_bytes(&submission_value)
11738        .map_err(|error| invalid_feed(error.to_string()))?
11739        != submission
11740        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11741            .map_err(|error| invalid_feed(error.to_string()))?
11742            != submission_hash
11743    {
11744        return Err(invalid_feed(
11745            "proposal submission claim is not canonical or addressed",
11746        ));
11747    }
11748    let envelope = submission_value
11749        .as_object()
11750        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11751    let claim = envelope
11752        .get("claim")
11753        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11754    let claim_object = claim
11755        .as_object()
11756        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11757    let actor_root = claim_object
11758        .get("actor_root")
11759        .and_then(Value::as_object)
11760        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11761    let public_key = envelope
11762        .get("public_key")
11763        .and_then(Value::as_str)
11764        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11765    let fingerprint = envelope
11766        .get("fingerprint")
11767        .and_then(Value::as_str)
11768        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11769    let signature = envelope
11770        .get("sig")
11771        .and_then(Value::as_str)
11772        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11773    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11774        .map_err(|error| invalid_feed(error.to_string()))?;
11775    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11776    let signer = format!("{fingerprint}:{public_key}");
11777    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11778    let grants = actor_root.get("grants").and_then(Value::as_array);
11779    let grants_are_canonical = grants.is_some_and(|items| {
11780        let mut prior: Option<&str> = None;
11781        items.iter().all(|item| {
11782            let Some(grant) = item.as_str() else {
11783                return false;
11784            };
11785            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11786                return false;
11787            }
11788            prior = Some(grant);
11789            true
11790        })
11791    });
11792    let optional_actor_field = |name: &str| {
11793        actor_root.get(name).is_some_and(|value| {
11794            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11795        })
11796    };
11797    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11798    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11799        || format!("{:x}", Sha256::digest(&der)) != fingerprint
11800        || head
11801            .trust
11802            .hub_signer
11803            .as_ref()
11804            .is_some_and(|known| known != &signer)
11805        || !matches!(
11806            actor_class,
11807            Some(
11808                "user"
11809                    | "owned_agent"
11810                    | "foreign_key"
11811                    | "curation"
11812                    | "inbox"
11813                    | "restore"
11814                    | "migration"
11815                    | "operator_recovery"
11816            )
11817        )
11818        || actor_root
11819            .get("principal")
11820            .and_then(Value::as_str)
11821            .is_none_or(|value| value.is_empty())
11822        || actor_root
11823            .get("credential")
11824            .and_then(Value::as_str)
11825            .is_none_or(|value| value.is_empty())
11826        || !optional_actor_field("organization")
11827        || !optional_actor_field("role")
11828        || !grants_are_canonical
11829        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11830        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11831        || !claim_object
11832            .get("mutation_id")
11833            .and_then(Value::as_str)
11834            .is_some_and(|value| {
11835                !value.is_empty()
11836                    && value.len() <= 128
11837                    && value.chars().enumerate().all(|(index, char)| {
11838                        char.is_ascii_alphanumeric()
11839                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11840                    })
11841            })
11842        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11843        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11844        || !claim_object
11845            .get("control_revision")
11846            .and_then(Value::as_str)
11847            .is_some_and(is_sha256)
11848        || submitted_at.is_none_or(|value| {
11849            chrono::DateTime::parse_from_rfc3339(value).is_err()
11850                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11851        })
11852        || !proposal
11853            .get("state")
11854            .and_then(Value::as_str)
11855            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11856        || proposal
11857            .get("expires_at")
11858            .and_then(Value::as_str)
11859            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11860        || proposal
11861            .get("proposer")
11862            .and_then(Value::as_object)
11863            .and_then(|value| value.get("class"))
11864            .and_then(Value::as_str)
11865            != actor_class
11866    {
11867        return Err(invalid_feed(
11868            "proposal submission claim does not bind the verified proposal",
11869        ));
11870    }
11871    let changes_b64 = proposal
11872        .get("changes_base64")
11873        .and_then(Value::as_str)
11874        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11875    let changes_bytes = STANDARD
11876        .decode(changes_b64)
11877        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11878    let changes: Value = serde_json::from_slice(&changes_bytes)
11879        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11880    if crate::linkmd_v2::canonical_bytes(&changes)
11881        .map_err(|error| invalid_feed(error.to_string()))?
11882        != changes_bytes
11883        || changes.get("v").and_then(Value::as_u64) != Some(2)
11884        || !changes.get("operations").is_some_and(Value::is_array)
11885    {
11886        return Err(invalid_feed("proposal changeset is not canonical v2"));
11887    }
11888    let blob_values = proposal
11889        .get("blobs")
11890        .and_then(Value::as_array)
11891        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11892    let mut blobs = Vec::with_capacity(blob_values.len());
11893    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11894    let mut prior_hash: Option<String> = None;
11895    for item in blob_values {
11896        let hash = item
11897            .get("sha256")
11898            .and_then(Value::as_str)
11899            .filter(|hash| is_sha256(hash))
11900            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11901        let bytes = item
11902            .get("bytes")
11903            .and_then(Value::as_u64)
11904            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11905            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11906        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11907            return Err(invalid_feed(
11908                "proposal blob declarations are not unique and sorted",
11909            ));
11910        }
11911        prior_hash = Some(hash.to_string());
11912        let endpoint = item
11913            .get("endpoint")
11914            .and_then(Value::as_str)
11915            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11916        let expected_endpoint = format!(
11917            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11918            head.brain_id
11919        );
11920        if endpoint != expected_endpoint {
11921            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11922        }
11923        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11924        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11925    }
11926    let descriptor = json!({
11927        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11928        "blobs": descriptor_blobs,
11929        "changes_base64": changes_b64,
11930        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11931        "v": 2,
11932    });
11933    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11934        .map_err(|error| invalid_feed(error.to_string()))?;
11935    if content_sha256(&descriptor_bytes) != clear_hash {
11936        return Err(invalid_feed(
11937            "proposal clear payload differs from its signed submission claim",
11938        ));
11939    }
11940    Ok(VerifiedV2Proposal {
11941        value,
11942        changes,
11943        blobs,
11944    })
11945}
11946
11947pub fn proposal_list(
11948    cfg: &HubConfig,
11949    brain: &str,
11950    state: &str,
11951    after: Option<&str>,
11952    limit: usize,
11953) -> LinkResult<Value> {
11954    require_safe_ref(brain)?;
11955    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11956        return Err(invalid_feed("proposal state is invalid"));
11957    }
11958    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11959        return Err(invalid_feed("proposal cursor is invalid"));
11960    }
11961    let head = v2_verified_head(cfg, brain)?
11962        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11963    let path = format!(
11964        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11965        head.brain_id,
11966        limit.clamp(1, 100),
11967        after.map_or_else(String::new, |value| format!("&after={value}"))
11968    );
11969    ensure_ok(
11970        request_capped(
11971            cfg,
11972            "GET",
11973            &path,
11974            None,
11975            Auth::Required,
11976            MAX_FEED_RESPONSE_BYTES,
11977        )?,
11978        "v2 proposal list",
11979    )
11980}
11981
11982pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11983    require_safe_ref(brain)?;
11984    let head = v2_verified_head(cfg, brain)?
11985        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11986    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11987}
11988
11989pub fn proposal_reject(
11990    cfg: &HubConfig,
11991    brain: &str,
11992    proposal_id: &str,
11993    mutation_id: &str,
11994    reason: &str,
11995) -> LinkResult<Value> {
11996    require_safe_ref(brain)?;
11997    require_proposal_id(proposal_id)?;
11998    let head = v2_verified_head(cfg, brain)?
11999        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12000    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
12001    let body = json!({
12002        "mutation_id": mutation_id,
12003        "control_revision": head.control_revision,
12004        "reason": reason,
12005    });
12006    let path = format!(
12007        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12008        head.brain_id
12009    );
12010    ensure_ok(
12011        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12012        "v2 proposal rejection",
12013    )
12014}
12015
12016pub fn proposal_accept_exact(
12017    cfg: &HubConfig,
12018    brain: &str,
12019    proposal_id: &str,
12020    mutation_id: &str,
12021    reason: &str,
12022) -> LinkResult<Value> {
12023    require_safe_ref(brain)?;
12024    require_proposal_id(proposal_id)?;
12025    let head = v2_verified_head(cfg, brain)?
12026        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12027    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
12028    let operations = proposal
12029        .changes
12030        .get("operations")
12031        .and_then(Value::as_array)
12032        .cloned()
12033        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
12034    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
12035        return Err(invalid_feed("proposal operation count is invalid"));
12036    }
12037    let mut downloaded = std::collections::BTreeMap::new();
12038    for (hash, bytes, endpoint) in &proposal.blobs {
12039        let body = ensure_raw_ok(
12040            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
12041            "v2 proposal blob",
12042        )?;
12043        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
12044            return Err(invalid_feed("proposal blob does not match its declaration"));
12045        }
12046        downloaded.insert(hash.clone(), body);
12047    }
12048    let remote = files_for_v2_view(
12049        &head,
12050        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
12051    );
12052    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
12053    let mut expected_candidate = remote.clone();
12054    let mut expected_candidate_assets = remote_assets;
12055    for operation in &operations {
12056        let op = operation
12057            .get("op")
12058            .and_then(Value::as_str)
12059            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
12060        match op {
12061            "put" | "restore" => {
12062                let path = operation
12063                    .get("path")
12064                    .and_then(Value::as_str)
12065                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
12066                crate::linkmd_v2::normalize_path(path)
12067                    .map_err(|error| invalid_feed(error.to_string()))?;
12068                let hash = operation
12069                    .get("blob")
12070                    .and_then(Value::as_str)
12071                    .filter(|hash| is_sha256(hash))
12072                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12073                let bytes = operation
12074                    .get("bytes")
12075                    .and_then(Value::as_u64)
12076                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12077                expected_candidate.insert(
12078                    path.to_string(),
12079                    V2BaselineFile {
12080                        sha256: hash.to_string(),
12081                        bytes,
12082                        proof: None,
12083                    },
12084                );
12085            }
12086            "delete" | "withdraw_from_hosting" => {
12087                let path = operation
12088                    .get("path")
12089                    .and_then(Value::as_str)
12090                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12091                crate::linkmd_v2::normalize_path(path)
12092                    .map_err(|error| invalid_feed(error.to_string()))?;
12093                expected_candidate.remove(path);
12094            }
12095            "rename" => {
12096                let from = operation
12097                    .get("from")
12098                    .and_then(Value::as_str)
12099                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12100                let to = operation
12101                    .get("to")
12102                    .and_then(Value::as_str)
12103                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12104                crate::linkmd_v2::normalize_path(from)
12105                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
12106                    .map_err(|error| invalid_feed(error.to_string()))?;
12107                let hash = operation
12108                    .get("blob")
12109                    .and_then(Value::as_str)
12110                    .filter(|hash| is_sha256(hash))
12111                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12112                let bytes = operation
12113                    .get("bytes")
12114                    .and_then(Value::as_u64)
12115                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12116                expected_candidate.remove(from);
12117                expected_candidate.insert(
12118                    to.to_string(),
12119                    V2BaselineFile {
12120                        sha256: hash.to_string(),
12121                        bytes,
12122                        proof: None,
12123                    },
12124                );
12125            }
12126            "asset_delete" => {
12127                let path = operation
12128                    .get("path")
12129                    .and_then(Value::as_str)
12130                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12131                expected_candidate_assets.remove(path);
12132            }
12133            "asset_withdraw" => {
12134                let path = operation
12135                    .get("path")
12136                    .and_then(Value::as_str)
12137                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12138                if !expected_candidate_assets.contains_key(path) {
12139                    return Err(invalid_feed("proposal withdraws an unknown asset"));
12140                }
12141                let Some(asset) = operation.get("asset").and_then(Value::as_object) else {
12142                    // Pre-supersession proposals carried no replacement leaf.
12143                    // Preserve their exact historical meaning while all new
12144                    // pushes include the explicit withheld value.
12145                    let prior = expected_candidate_assets
12146                        .get_mut(path)
12147                        .expect("presence checked above");
12148                    prior.disposition = "withheld".to_string();
12149                    prior.leaf_hash.clear();
12150                    continue;
12151                };
12152                let blob_sha256 = asset
12153                    .get("blob_sha256")
12154                    .and_then(Value::as_str)
12155                    .filter(|hash| is_sha256(hash))
12156                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12157                let bytes = asset
12158                    .get("bytes")
12159                    .and_then(Value::as_u64)
12160                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12161                let media_type = asset
12162                    .get("media_type")
12163                    .and_then(Value::as_str)
12164                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12165                let wrappers = asset
12166                    .get("wrappers")
12167                    .and_then(Value::as_array)
12168                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12169                    .iter()
12170                    .map(|wrapper| {
12171                        wrapper
12172                            .as_str()
12173                            .map(str::to_string)
12174                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12175                    })
12176                    .collect::<LinkResult<Vec<_>>>()?;
12177                let required = asset
12178                    .get("required")
12179                    .and_then(Value::as_bool)
12180                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12181                if asset.get("disposition").and_then(Value::as_str) != Some("withheld") {
12182                    return Err(invalid_feed("proposal asset withdrawal is not withheld"));
12183                }
12184                expected_candidate_assets.insert(
12185                    path.to_string(),
12186                    V2BaselineAsset {
12187                        blob_sha256: blob_sha256.to_string(),
12188                        bytes,
12189                        media_type: media_type.to_string(),
12190                        wrappers,
12191                        required,
12192                        disposition: "withheld".to_string(),
12193                        leaf_hash: String::new(),
12194                    },
12195                );
12196            }
12197            "asset_put" | "asset_resume" => {
12198                let path = operation
12199                    .get("path")
12200                    .and_then(Value::as_str)
12201                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12202                let asset = operation
12203                    .get("asset")
12204                    .and_then(Value::as_object)
12205                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12206                let blob_sha256 = asset
12207                    .get("blob_sha256")
12208                    .and_then(Value::as_str)
12209                    .filter(|hash| is_sha256(hash))
12210                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12211                let bytes = asset
12212                    .get("bytes")
12213                    .and_then(Value::as_u64)
12214                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12215                let media_type = asset
12216                    .get("media_type")
12217                    .and_then(Value::as_str)
12218                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12219                let wrappers = asset
12220                    .get("wrappers")
12221                    .and_then(Value::as_array)
12222                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12223                    .iter()
12224                    .map(|wrapper| {
12225                        wrapper
12226                            .as_str()
12227                            .map(str::to_string)
12228                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12229                    })
12230                    .collect::<LinkResult<Vec<_>>>()?;
12231                let required = asset
12232                    .get("required")
12233                    .and_then(Value::as_bool)
12234                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12235                let disposition = asset
12236                    .get("disposition")
12237                    .and_then(Value::as_str)
12238                    .filter(|value| matches!(*value, "hosted" | "withheld"))
12239                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
12240                expected_candidate_assets.insert(
12241                    path.to_string(),
12242                    V2BaselineAsset {
12243                        blob_sha256: blob_sha256.to_string(),
12244                        bytes,
12245                        media_type: media_type.to_string(),
12246                        wrappers,
12247                        required,
12248                        disposition: disposition.to_string(),
12249                        leaf_hash: String::new(),
12250                    },
12251                );
12252            }
12253            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
12254        }
12255    }
12256    let base = head.pointer.as_ref().map(|pointer| {
12257        json!({
12258            "seq": pointer.seq,
12259            "commit_hash": pointer.commit_hash,
12260            "content_root": pointer.content_root,
12261            "asset_root": pointer.asset_root,
12262        })
12263    });
12264    let mut body = json!({
12265        "mutation_id": mutation_id,
12266        "base": base,
12267        "rebase": "strict",
12268        "reason": reason,
12269        "operations": operations,
12270        "blobs": downloaded
12271            .iter()
12272            .map(|(sha256, bytes)| json!({
12273                "sha256": sha256,
12274                "bytes": bytes.len(),
12275                "content_base64": STANDARD.encode(bytes),
12276            }))
12277            .collect::<Vec<_>>(),
12278        "proposal_id": proposal_id,
12279        "proposal_mode": "exact",
12280    });
12281    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
12282        total
12283            .checked_add(bytes.len())
12284            .ok_or_else(|| LinkError::PushTooLarge {
12285                detail: "proposal changed-byte total overflow".to_string(),
12286            })
12287    })?;
12288    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
12289        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
12290        for operation in &operations {
12291            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
12292                return Err(invalid_feed("proposal upload operation has no kind"));
12293            };
12294            let hash = match kind {
12295                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
12296                "asset_put" | "asset_resume" => operation
12297                    .get("asset")
12298                    .and_then(|asset| asset.get("blob_sha256"))
12299                    .and_then(Value::as_str),
12300                _ => None,
12301            };
12302            let Some(hash) = hash else { continue };
12303            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
12304            if kind == "rename" {
12305                for field in ["from", "to"] {
12306                    coordinates.insert(
12307                        operation
12308                            .get(field)
12309                            .and_then(Value::as_str)
12310                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
12311                            .to_string(),
12312                    );
12313                }
12314            } else {
12315                let path = operation
12316                    .get("path")
12317                    .and_then(Value::as_str)
12318                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
12319                coordinates.insert(if kind.starts_with("asset_") {
12320                    format!("assets/{path}")
12321                } else {
12322                    path.to_string()
12323                });
12324            }
12325        }
12326        let declarations = downloaded
12327            .iter()
12328            .map(|(sha256, bytes)| {
12329                json!({
12330                    "sha256": sha256,
12331                    "bytes": bytes.len(),
12332                    "coordinates": coordinates_by_hash
12333                        .get(sha256)
12334                        .into_iter()
12335                        .flatten()
12336                        .collect::<Vec<_>>(),
12337                })
12338            })
12339            .collect::<Vec<_>>();
12340        let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
12341        for batch in batch_upload_declarations(declarations) {
12342            let reserved = reserve_upload_window(
12343                cfg,
12344                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
12345                &json!({ "blobs": batch }),
12346                "prepare proposal blob transport",
12347            )?;
12348            let reserved_items = reserved
12349                .get("uploads")
12350                .and_then(Value::as_array)
12351                .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
12352            items.extend(reserved_items.iter().cloned());
12353        }
12354        if items.len() != downloaded.len() {
12355            return Err(invalid_feed("proposal upload reservation changed the set"));
12356        }
12357        let mut references = Vec::with_capacity(items.len());
12358        for item in items {
12359            let hash = item
12360                .get("sha256")
12361                .and_then(Value::as_str)
12362                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
12363            let bytes = downloaded
12364                .get(hash)
12365                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
12366            let reservation_id = item
12367                .get("reservation_id")
12368                .and_then(Value::as_str)
12369                .filter(|id| crate::ulid::is_ulid(id))
12370                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
12371            let expected_coordinates = coordinates_by_hash
12372                .get(hash)
12373                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
12374            let returned_coordinates = item
12375                .get("coordinates")
12376                .and_then(Value::as_array)
12377                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
12378            if returned_coordinates.len() != expected_coordinates.len()
12379                || returned_coordinates
12380                    .iter()
12381                    .zip(expected_coordinates)
12382                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
12383            {
12384                return Err(invalid_feed(
12385                    "proposal upload reservation changed its coordinates",
12386                ));
12387            }
12388            match item.get("status").and_then(Value::as_str) {
12389                Some("upload") => put_presigned(
12390                    cfg,
12391                    item.get("url")
12392                        .and_then(Value::as_str)
12393                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
12394                    item.get("headers").unwrap_or(&Value::Null),
12395                    bytes,
12396                )?,
12397                Some("already_present") => {}
12398                _ => return Err(invalid_feed("proposal upload status is invalid")),
12399            }
12400            references.push(json!({
12401                "sha256": hash,
12402                "bytes": bytes.len(),
12403                "reservation_id": reservation_id,
12404            }));
12405        }
12406        body["blobs"] = Value::Array(references);
12407    }
12408    // A proposal can be queued through the staged lane, so acceptance has to be
12409    // able to restate it. Capping only this side would make a large proposal
12410    // permanently unacceptable.
12411    stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
12412    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
12413    let mut result = ensure_ok(
12414        request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
12415        "exact proposal acceptance",
12416    )?;
12417    let mut candidate_hub_signer = None;
12418    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12419        let request_id = result
12420            .get("request_id")
12421            .and_then(Value::as_str)
12422            .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12423            .to_string();
12424        let challenge = result
12425            .get("signing_challenge")
12426            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12427        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12428            cfg,
12429            &head,
12430            &expected_candidate,
12431            &expected_candidate_assets,
12432            mutation_id,
12433            &v2_signed_request_view(&body, &operations),
12434            challenge,
12435        )?;
12436        body["signing_challenge_id"] = Value::String(challenge_id);
12437        body["signature_base64url"] = Value::String(signature);
12438        candidate_hub_signer = Some(actor_signer);
12439        result = ensure_ok(
12440            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12441            "signed exact proposal acceptance",
12442        )?;
12443    }
12444    let refreshed = v2_verified_head(cfg, brain)?
12445        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12446    if candidate_hub_signer
12447        .as_ref()
12448        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12449        || refreshed
12450            .pointer
12451            .as_ref()
12452            .map(|pointer| pointer.commit_hash.as_str())
12453            != result.get("commit_hash").and_then(Value::as_str)
12454        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12455        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12456    {
12457        return Err(LinkError::RemoteAdvancedDuringSync);
12458    }
12459    accept_v2_head(cfg, &refreshed)?;
12460    Ok(result)
12461}
12462
12463// ─────────────────────────────────────────────────────────────────────────────
12464// propose — write without trust: evidence into the owner's inbox
12465// ─────────────────────────────────────────────────────────────────────────────
12466
12467/// Submit `body` to the published site `handle`, addressed to its app page
12468/// `app` (a page that declares the `write-inbox` capability). Deliberately
12469/// unauthenticated — this is the cross-party door; the submission lands as
12470/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
12471/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
12472/// receipt.
12473pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12474    require_valid_handle(handle)?;
12475    if body.len() as u64 > MAX_PROPOSE_BYTES {
12476        return Err(LinkError::ProposeTooLarge {
12477            bytes: body.len() as u64,
12478        });
12479    }
12480    let payload = json!({ "app": app, "body": body });
12481    // A ULID-shaped target is a bare brain address (link.md §7.4's
12482    // generalization): the brain inbox door, open on public brains, where a
12483    // configured credential earns a bigger actor-class budget. Anything else
12484    // is a published-site handle: that door is unauthenticated by design.
12485    let (path, auth) = if crate::ulid::is_ulid(handle) {
12486        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12487    } else {
12488        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12489    };
12490    ensure_ok(
12491        request(cfg, "POST", &path, Some(&payload), auth)?,
12492        "propose",
12493    )
12494}
12495
12496// ─────────────────────────────────────────────────────────────────────────────
12497// subscribe — follow feed-head movement
12498// ─────────────────────────────────────────────────────────────────────────────
12499
12500/// One observation of a brain's feed head.
12501#[derive(Debug, serde::Serialize)]
12502pub struct Head {
12503    /// The brain id.
12504    pub brain: String,
12505    /// The hub's durable feed cursor — advances on every accepted write.
12506    pub seq: u64,
12507    /// The hub's `updatedAt` for the brain, when present.
12508    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12509    pub updated_at: Option<String>,
12510    /// SHA-256 of the exact signed head entry.
12511    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12512    pub feed_hash: Option<String>,
12513    /// Whether the head entry's content hash, identity, and Ed25519 signature
12514    /// were verified locally. Path-scoped grants get head movement only.
12515    pub verified: bool,
12516}
12517
12518struct BoundedVecVisitor<T, const MAX: usize> {
12519    label: &'static str,
12520    marker: std::marker::PhantomData<T>,
12521}
12522
12523impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12524where
12525    T: Deserialize<'de>,
12526{
12527    type Value = Vec<T>;
12528
12529    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12530        write!(formatter, "at most {MAX} {}", self.label)
12531    }
12532
12533    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12534    where
12535        A: serde::de::SeqAccess<'de>,
12536    {
12537        if sequence.size_hint().is_some_and(|size| size > MAX) {
12538            return Err(serde::de::Error::custom(format!(
12539                "{} exceeds the {MAX}-item limit",
12540                self.label
12541            )));
12542        }
12543        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12544        while let Some(value) = sequence.next_element()? {
12545            if values.len() == MAX {
12546                return Err(serde::de::Error::custom(format!(
12547                    "{} exceeds the {MAX}-item limit",
12548                    self.label
12549                )));
12550            }
12551            values.push(value);
12552        }
12553        Ok(values)
12554    }
12555}
12556
12557fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12558    deserializer: D,
12559    label: &'static str,
12560) -> Result<Vec<T>, D::Error>
12561where
12562    D: serde::Deserializer<'de>,
12563    T: Deserialize<'de>,
12564{
12565    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12566        label,
12567        marker: std::marker::PhantomData,
12568    })
12569}
12570
12571fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12572where
12573    D: serde::Deserializer<'de>,
12574{
12575    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12576}
12577
12578fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12579where
12580    D: serde::Deserializer<'de>,
12581{
12582    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12583}
12584
12585fn deserialize_previous_identities<'de, D>(
12586    deserializer: D,
12587) -> Result<Vec<PreviousIdentity>, D::Error>
12588where
12589    D: serde::Deserializer<'de>,
12590{
12591    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12592        deserializer,
12593        "previous identities",
12594    )
12595}
12596
12597fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12598where
12599    D: serde::Deserializer<'de>,
12600{
12601    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12602        deserializer,
12603        "rotation statements",
12604    )
12605}
12606
12607fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12608where
12609    D: serde::Deserializer<'de>,
12610{
12611    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12612}
12613
12614#[derive(Debug, Clone, Deserialize, Serialize)]
12615struct FeedFile {
12616    path: String,
12617    sha256: String,
12618    bytes: u64,
12619}
12620
12621#[cfg(test)]
12622#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12623enum V1DisclosureError {
12624    DuplicateFile,
12625    DuplicateRemoved,
12626    PushManifestMismatch,
12627    EditMissingChange,
12628    EditFalseFile,
12629    RemovedMismatch,
12630}
12631
12632/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
12633/// the exact previous and resulting pack manifests. `edit` accepts the
12634/// historical superset form while still requiring every actual changed path.
12635#[cfg(test)]
12636fn verify_v1_manifest_disclosure(
12637    kind: &str,
12638    previous: &[FeedFile],
12639    resulting: &[FeedFile],
12640    files: &[FeedFile],
12641    removed: &[String],
12642) -> Result<(), V1DisclosureError> {
12643    fn as_map(
12644        files: &[FeedFile],
12645    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12646        let mut result = std::collections::BTreeMap::new();
12647        for file in files {
12648            if result
12649                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12650                .is_some()
12651            {
12652                return Err(V1DisclosureError::DuplicateFile);
12653            }
12654        }
12655        Ok(result)
12656    }
12657    let previous = as_map(previous)?;
12658    let resulting = as_map(resulting)?;
12659    let disclosed = as_map(files)?;
12660    let removed_set: std::collections::BTreeSet<&str> =
12661        removed.iter().map(String::as_str).collect();
12662    if removed_set.len() != removed.len() {
12663        return Err(V1DisclosureError::DuplicateRemoved);
12664    }
12665    let expected_removed: std::collections::BTreeSet<&str> = previous
12666        .keys()
12667        .copied()
12668        .filter(|path| !resulting.contains_key(path))
12669        .collect();
12670    if removed_set != expected_removed {
12671        return Err(V1DisclosureError::RemovedMismatch);
12672    }
12673    if kind == "push" {
12674        return if disclosed == resulting {
12675            Ok(())
12676        } else {
12677            Err(V1DisclosureError::PushManifestMismatch)
12678        };
12679    }
12680    if kind != "edit" {
12681        return Err(V1DisclosureError::EditFalseFile);
12682    }
12683    if disclosed
12684        .iter()
12685        .any(|(path, value)| resulting.get(path) != Some(value))
12686    {
12687        return Err(V1DisclosureError::EditFalseFile);
12688    }
12689    for (path, value) in &resulting {
12690        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12691            return Err(V1DisclosureError::EditMissingChange);
12692        }
12693    }
12694    Ok(())
12695}
12696
12697#[derive(Debug, Clone, Deserialize, Serialize)]
12698struct FeedEntry {
12699    v: u8,
12700    seq: u64,
12701    ts: String,
12702    brain: String,
12703    public_key: String,
12704    kind: String,
12705    op: String,
12706    pack_sha256: String,
12707    #[serde(deserialize_with = "deserialize_feed_files")]
12708    files: Vec<FeedFile>,
12709    #[serde(deserialize_with = "deserialize_removed_paths")]
12710    removed: Vec<String>,
12711    prev_entry_hash: Option<String>,
12712    sig: String,
12713}
12714
12715#[derive(Serialize)]
12716struct UnsignedFeedEntry<'a> {
12717    v: u8,
12718    seq: u64,
12719    ts: &'a str,
12720    brain: &'a str,
12721    public_key: &'a str,
12722    kind: &'a str,
12723    op: &'a str,
12724    pack_sha256: &'a str,
12725    files: &'a [FeedFile],
12726    removed: &'a [String],
12727    prev_entry_hash: &'a Option<String>,
12728}
12729
12730#[derive(Debug, Clone, Deserialize, Serialize)]
12731struct FeedItem {
12732    hash: String,
12733    entry: FeedEntry,
12734}
12735
12736#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12737struct FeedIdentity {
12738    fingerprint: String,
12739    #[serde(rename = "publicKeySpki")]
12740    public_key_spki: String,
12741    /// Rotation history (link.md §9.1): identities this brain previously
12742    /// signed as. Entries verify against current OR previous — rotation
12743    /// never invalidates history.
12744    #[serde(default, deserialize_with = "deserialize_previous_identities")]
12745    previous: Vec<PreviousIdentity>,
12746    /// Exact normative rotation statements, oldest first. A list of previous
12747    /// public keys without these old-key signatures is not a trust chain.
12748    #[serde(default, deserialize_with = "deserialize_rotations")]
12749    rotations: Vec<String>,
12750}
12751
12752#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12753struct PreviousIdentity {
12754    fingerprint: String,
12755    #[serde(rename = "publicKeySpki")]
12756    public_key_spki: String,
12757}
12758
12759#[derive(Debug, Deserialize)]
12760struct FeedResponse {
12761    #[serde(rename = "headSeq")]
12762    head_seq: u64,
12763    #[serde(rename = "feedHash")]
12764    feed_hash: Option<String>,
12765    identity: Option<FeedIdentity>,
12766    #[serde(deserialize_with = "deserialize_feed_items")]
12767    entries: Vec<FeedItem>,
12768    #[serde(rename = "scopeLimited")]
12769    scope_limited: bool,
12770}
12771
12772#[derive(Debug, Deserialize, Serialize)]
12773#[serde(deny_unknown_fields)]
12774struct RotationStatement {
12775    v: u8,
12776    op: String,
12777    brain: String,
12778    public_key: String,
12779    new_brain: String,
12780    new_public_key: String,
12781    prior_head_seq: u64,
12782    prior_feed_hash: Option<String>,
12783    ts: String,
12784    sig: String,
12785}
12786
12787#[derive(Debug, Clone, Deserialize, Serialize)]
12788struct TrustState {
12789    v: u8,
12790    origin: String,
12791    /// The exact caller-visible ref whose resolution this checkpoint pins.
12792    /// Slugs/handles are mutable names at the hub; once observed, they may not
12793    /// silently resolve to a different canonical brain.
12794    #[serde(default)]
12795    requested: String,
12796    /// Canonical hub brain id returned for `requested`.
12797    brain: String,
12798    /// Federation home origin when this ref was learned from a registry.
12799    /// Once observed, the registry cannot silently relocate the same handle.
12800    #[serde(default, skip_serializing_if = "Option::is_none")]
12801    home: Option<String>,
12802    anchor: String,
12803    current: String,
12804    #[serde(rename = "headSeq")]
12805    head_seq: u64,
12806    #[serde(rename = "feedHash")]
12807    feed_hash: Option<String>,
12808    /// Exact accepted old-key-signed rotation statements, oldest first. v2
12809    /// checkpoints require this vector to be an immutable prefix of every
12810    /// subsequently served identity chain.
12811    #[serde(default)]
12812    rotations: Vec<String>,
12813    /// Hub pointer-signing identity pinned on first v2 observation. This is
12814    /// separate from the brain key, which signs commits.
12815    #[serde(default, skip_serializing_if = "Option::is_none")]
12816    hub_signer: Option<String>,
12817    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
12818    /// are recognized by their pinned hub signer during one-way migration.
12819    #[serde(default, skip_serializing_if = "Option::is_none")]
12820    protocol_profile: Option<String>,
12821}
12822
12823fn accepted_as_v2(state: &TrustState) -> bool {
12824    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12825}
12826
12827fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12828    let directory = open_trust_dir(cfg)?;
12829    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12830        return Ok(true);
12831    }
12832    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12833        return Ok(false);
12834    };
12835    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12836}
12837
12838#[derive(Debug, Clone, Deserialize, Serialize)]
12839struct AliasBinding {
12840    v: u8,
12841    origin: String,
12842    requested: String,
12843    brain: String,
12844    #[serde(default, skip_serializing_if = "Option::is_none")]
12845    home: Option<String>,
12846}
12847
12848struct VerifiedRemote {
12849    head: Head,
12850    identity: Option<FeedIdentity>,
12851    head_entry: Option<FeedItem>,
12852    /// Present when the caller requested and verified the complete chain.
12853    entries: Vec<FeedItem>,
12854    anchor: Option<String>,
12855}
12856
12857fn invalid_feed(message: impl Into<String>) -> LinkError {
12858    LinkError::InvalidFeed {
12859        message: message.into(),
12860    }
12861}
12862
12863fn is_sha256(value: &str) -> bool {
12864    value.len() == 64
12865        && value
12866            .bytes()
12867            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12868}
12869
12870fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12871    let der = URL_SAFE_NO_PAD
12872        .decode(public_key_spki)
12873        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12874    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12875        return Err(invalid_feed(
12876            "identity public key is not a valid Ed25519 SPKI",
12877        ));
12878    }
12879    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12880}
12881
12882/// Verify the hub's current identity and every old-key-signed rotation that
12883/// leads from the original TOFU anchor to it. `previous` alone is metadata,
12884/// never authority.
12885fn verify_identity_chain(
12886    identity: &FeedIdentity,
12887    pinned: Option<&TrustState>,
12888) -> LinkResult<String> {
12889    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12890        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12891    {
12892        return Err(invalid_feed(
12893            "identity rotation history exceeds the client cap",
12894        ));
12895    }
12896    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12897        return Err(invalid_feed(
12898            "current identity fingerprint does not match its public key",
12899        ));
12900    }
12901    for previous in &identity.previous {
12902        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12903            return Err(invalid_feed(
12904                "previous identity fingerprint does not match its public key",
12905            ));
12906        }
12907    }
12908    if identity.rotations.len() != identity.previous.len() {
12909        return Err(invalid_feed(
12910            "identity history is missing an old-key-signed rotation statement",
12911        ));
12912    }
12913
12914    // The HTTP identity lists prior identities newest first. Rotation
12915    // statements are chronological, so verification walks the reversed list
12916    // and ends at the current identity.
12917    let mut chain: Vec<(&str, &str)> = identity
12918        .previous
12919        .iter()
12920        .rev()
12921        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12922        .collect();
12923    chain.push((&identity.fingerprint, &identity.public_key_spki));
12924
12925    for (index, raw) in identity.rotations.iter().enumerate() {
12926        let statement: RotationStatement = serde_json::from_str(raw)
12927            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12928        let (old_fingerprint, old_spki) = chain[index];
12929        let (new_fingerprint, new_spki) = chain[index + 1];
12930        if statement.v != 1
12931            || statement.op != "rotate"
12932            || statement.brain != format!("ed25519:{old_fingerprint}")
12933            || statement.public_key != old_spki
12934            || statement.new_brain != format!("ed25519:{new_fingerprint}")
12935            || statement.new_public_key != new_spki
12936            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12937            || (statement.prior_head_seq > 0
12938                && statement
12939                    .prior_feed_hash
12940                    .as_deref()
12941                    .is_none_or(|hash| !is_sha256(hash)))
12942        {
12943            return Err(invalid_feed(
12944                "rotation statement does not connect adjacent identities",
12945            ));
12946        }
12947        let unsigned = serde_json::to_string(&UnsignedRotation {
12948            v: statement.v,
12949            op: &statement.op,
12950            brain: &statement.brain,
12951            public_key: &statement.public_key,
12952            new_brain: &statement.new_brain,
12953            new_public_key: &statement.new_public_key,
12954            prior_head_seq: statement.prior_head_seq,
12955            prior_feed_hash: statement.prior_feed_hash.as_deref(),
12956            ts: statement.ts.clone(),
12957        })
12958        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12959        let exact = format!(
12960            "{},\"sig\":\"{}\"}}",
12961            &unsigned[..unsigned.len() - 1],
12962            statement.sig
12963        );
12964        if exact != *raw {
12965            return Err(invalid_feed(
12966                "rotation statement is not in normative serialization",
12967            ));
12968        }
12969        let der = URL_SAFE_NO_PAD
12970            .decode(old_spki)
12971            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12972        let signature = URL_SAFE_NO_PAD
12973            .decode(&statement.sig)
12974            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12975        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12976            .verify(unsigned.as_bytes(), &signature)
12977            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12978        if index > 0 {
12979            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12980                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12981            if statement.prior_head_seq < prior.prior_head_seq {
12982                return Err(invalid_feed("rotation feed boundaries move backward"));
12983            }
12984        }
12985    }
12986
12987    let anchor = format!("ed25519:{}", chain[0].0);
12988    let current = format!("ed25519:{}", identity.fingerprint);
12989    if let Some(pin) = pinned {
12990        if pin.anchor != anchor {
12991            return Err(invalid_feed(
12992                "served identity chain does not descend from the pinned anchor",
12993            ));
12994        }
12995        if !chain
12996            .iter()
12997            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12998        {
12999            return Err(invalid_feed(
13000                "served identity chain forked away from the last pinned identity",
13001            ));
13002        }
13003        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
13004            return Err(invalid_feed("served identity discarded its rotation chain"));
13005        }
13006        if pin.v >= 2
13007            && (identity.rotations.len() < pin.rotations.len()
13008                || identity.rotations[..pin.rotations.len()] != pin.rotations)
13009        {
13010            return Err(invalid_feed(
13011                "served identity rewrote the locally accepted rotation history",
13012            ));
13013        }
13014    }
13015    Ok(anchor)
13016}
13017
13018fn verify_rotation_feed_boundaries(
13019    identity: &FeedIdentity,
13020    pinned: Option<&TrustState>,
13021    observed: &[FeedItem],
13022    advertised_seq: u64,
13023) -> LinkResult<()> {
13024    let mut chain: Vec<String> = identity
13025        .previous
13026        .iter()
13027        .rev()
13028        .map(|previous| format!("ed25519:{}", previous.fingerprint))
13029        .collect();
13030    chain.push(format!("ed25519:{}", identity.fingerprint));
13031    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
13032
13033    for (index, raw) in identity.rotations.iter().enumerate() {
13034        let rotation: RotationStatement = serde_json::from_str(raw)
13035            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13036        if rotation.prior_head_seq > advertised_seq {
13037            return Err(invalid_feed(
13038                "rotation claims a feed boundary beyond the advertised head",
13039            ));
13040        }
13041        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
13042            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
13043                return Err(invalid_feed(
13044                    "newly disclosed rotation predates the local feed checkpoint",
13045                ));
13046            }
13047        }
13048        let actual = if rotation.prior_head_seq == 0 {
13049            None
13050        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
13051            pinned.and_then(|pin| pin.feed_hash.as_deref())
13052        } else {
13053            observed
13054                .iter()
13055                .find(|item| item.entry.seq == rotation.prior_head_seq)
13056                .map(|item| item.hash.as_str())
13057        };
13058        if let Some(actual) = actual {
13059            if rotation.prior_feed_hash.as_deref() != Some(actual) {
13060                return Err(invalid_feed(
13061                    "rotation statement does not commit the verified feed boundary",
13062                ));
13063            }
13064        } else if rotation.prior_head_seq == 0 {
13065            // The empty-feed boundary is represented by (0, null); there is
13066            // no entry hash to find in `observed`.
13067        } else if pinned.is_some_and(|pin| {
13068            pinned_index.is_some_and(|pin_index| index >= pin_index)
13069                || rotation.prior_head_seq >= pin.head_seq
13070        }) {
13071            return Err(invalid_feed(
13072                "rotation feed boundary was not present in the verified chain",
13073            ));
13074        }
13075    }
13076    Ok(())
13077}
13078
13079/// Once a client has checkpointed identity K, identities retired before K may
13080/// verify old history but can never regain authority over a later sequence.
13081/// This is also the safe migration rule for legacy v1 checkpoints that did not
13082/// persist the exact accepted rotation statements.
13083fn reject_retired_signer_after_checkpoint(
13084    identity: &FeedIdentity,
13085    pinned: Option<&TrustState>,
13086    item: &FeedItem,
13087) -> LinkResult<()> {
13088    let Some(pin) = pinned else {
13089        return Ok(());
13090    };
13091    if item.entry.seq <= pin.head_seq {
13092        return Ok(());
13093    }
13094    let mut chain: Vec<String> = identity
13095        .previous
13096        .iter()
13097        .rev()
13098        .map(|previous| format!("ed25519:{}", previous.fingerprint))
13099        .collect();
13100    chain.push(format!("ed25519:{}", identity.fingerprint));
13101    let pinned_index = chain
13102        .iter()
13103        .position(|key| key == &pin.current)
13104        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
13105    let signer_index = chain
13106        .iter()
13107        .position(|key| key == &item.entry.brain)
13108        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
13109    if signer_index < pinned_index {
13110        return Err(invalid_feed(
13111            "a retired identity attempted to sign after the local checkpoint",
13112        ));
13113    }
13114    Ok(())
13115}
13116
13117fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
13118    let origin = normalized_origin(&cfg.hub)?;
13119    let key = format!(
13120        "{:x}",
13121        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
13122    );
13123    Ok(format!("{key}.json"))
13124}
13125
13126fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13127    let origin = normalized_origin(&cfg.hub)?;
13128    let key = format!(
13129        "{:x}",
13130        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13131    );
13132    Ok(format!("alias-{key}.json"))
13133}
13134
13135#[cfg(any(unix, windows))]
13136struct TrustLock {
13137    _file: std::fs::File,
13138}
13139
13140#[cfg(unix)]
13141fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13142    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13143
13144    let lock_string = format!(".{state_name}.lock");
13145    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13146    let fd = unsafe {
13147        libc::openat(
13148            directory.as_raw_fd(),
13149            lock_name.as_ptr(),
13150            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13151            0o600,
13152        )
13153    };
13154    if fd < 0 {
13155        return Err(std::io::Error::last_os_error().into());
13156    }
13157    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13158    if !file.metadata()?.is_file() {
13159        return Err(LinkError::UnsafePath { path: lock_string });
13160    }
13161    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13162        return Err(std::io::Error::last_os_error().into());
13163    }
13164    Ok(TrustLock { _file: file })
13165}
13166
13167#[cfg(windows)]
13168fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13169    let lock_name = format!(".{state_name}.lock");
13170    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13171    Ok(TrustLock { _file: file })
13172}
13173
13174#[cfg(any(unix, windows))]
13175fn lock_trust_many(
13176    cfg: &HubConfig,
13177    directory: &std::fs::File,
13178    refs: &[&str],
13179) -> LinkResult<Vec<TrustLock>> {
13180    let mut names = refs
13181        .iter()
13182        .map(|reference| trust_file_name(cfg, reference))
13183        .collect::<LinkResult<Vec<_>>>()?;
13184    names.sort();
13185    names.dedup();
13186    names
13187        .iter()
13188        .map(|name| lock_trust_name(directory, name))
13189        .collect()
13190}
13191
13192#[cfg(not(any(unix, windows)))]
13193fn lock_trust_many(
13194    _cfg: &HubConfig,
13195    _directory: &TrustDirectory,
13196    _refs: &[&str],
13197) -> LinkResult<Vec<()>> {
13198    Err(LinkError::UnsupportedPlatform {
13199        operation: "verified link.md state",
13200    })
13201}
13202
13203#[cfg(any(unix, windows))]
13204type TrustDirectory = std::fs::File;
13205
13206#[cfg(not(any(unix, windows)))]
13207struct TrustDirectory;
13208
13209#[cfg(unix)]
13210fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13211    use std::os::fd::AsRawFd as _;
13212
13213    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
13214    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
13215        return Err(std::io::Error::last_os_error().into());
13216    }
13217    directory.sync_all()?;
13218    Ok(directory)
13219}
13220
13221#[cfg(windows)]
13222fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13223    let marker = cfg.state_dir.join("trust").join(".directory");
13224    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
13225    Ok(crate::fsx::open_directory_nofollow(
13226        marker.parent().expect("trust marker has a parent"),
13227    )?)
13228}
13229
13230#[cfg(not(any(unix, windows)))]
13231fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
13232    Err(LinkError::UnsupportedPlatform {
13233        operation: "verified link.md state",
13234    })
13235}
13236
13237#[cfg(unix)]
13238fn load_trust_in(
13239    cfg: &HubConfig,
13240    directory: &TrustDirectory,
13241    requested: &str,
13242) -> LinkResult<Option<TrustState>> {
13243    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13244
13245    let name_string = trust_file_name(cfg, requested)?;
13246    let name = c_name(name_string.as_bytes(), &name_string)?;
13247    let fd = unsafe {
13248        libc::openat(
13249            directory.as_raw_fd(),
13250            name.as_ptr(),
13251            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13252        )
13253    };
13254    if fd < 0 {
13255        let error = std::io::Error::last_os_error();
13256        if error.kind() == std::io::ErrorKind::NotFound {
13257            return Ok(None);
13258        }
13259        return Err(LinkError::UnsafePath { path: name_string });
13260    }
13261    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13262    if !file.metadata()?.is_file() {
13263        return Err(LinkError::UnsafePath { path: name_string });
13264    }
13265    let mut bytes = Vec::new();
13266    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
13267    if bytes.len() > 1024 * 1024 {
13268        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
13269    }
13270    let mut state: TrustState = serde_json::from_slice(&bytes)
13271        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13272    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13273        return Err(invalid_feed(
13274            "local identity/feed checkpoint does not match this hub and brain",
13275        ));
13276    }
13277    if state.v == 1 {
13278        // Legacy files were keyed by canonical brain id. They can migrate only
13279        // when the caller used that exact id; old slug lookups had no durable
13280        // alias binding and therefore cannot be guessed safely.
13281        if state.brain != requested {
13282            return Err(invalid_feed(
13283                "legacy checkpoint is not bound to the requested brain id",
13284            ));
13285        }
13286        state.requested = requested.to_string();
13287    } else if state.requested != requested {
13288        return Err(invalid_feed(
13289            "local identity/feed checkpoint is bound to a different requested ref",
13290        ));
13291    }
13292    Ok(Some(state))
13293}
13294
13295#[cfg(windows)]
13296fn load_trust_in(
13297    cfg: &HubConfig,
13298    directory: &TrustDirectory,
13299    requested: &str,
13300) -> LinkResult<Option<TrustState>> {
13301    let name = trust_file_name(cfg, requested)?;
13302    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13303    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
13304        Ok(bytes) => bytes,
13305        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13306        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13307    };
13308    let mut state: TrustState = serde_json::from_slice(&bytes)
13309        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
13310    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
13311        return Err(invalid_feed(
13312            "local identity/feed checkpoint does not match this hub and brain",
13313        ));
13314    }
13315    if state.v == 1 {
13316        if state.brain != requested {
13317            return Err(invalid_feed(
13318                "legacy checkpoint is not bound to the requested brain id",
13319            ));
13320        }
13321        state.requested = requested.to_string();
13322    } else if state.requested != requested {
13323        return Err(invalid_feed(
13324            "local identity/feed checkpoint is bound to a different requested ref",
13325        ));
13326    }
13327    Ok(Some(state))
13328}
13329
13330#[cfg(not(any(unix, windows)))]
13331fn load_trust_in(
13332    _cfg: &HubConfig,
13333    _directory: &TrustDirectory,
13334    _brain: &str,
13335) -> LinkResult<Option<TrustState>> {
13336    Err(LinkError::UnsupportedPlatform {
13337        operation: "verified link.md state",
13338    })
13339}
13340
13341#[cfg(all(test, any(unix, windows)))]
13342fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
13343    let directory = open_trust_dir(cfg)?;
13344    load_trust_in(cfg, &directory, requested)
13345}
13346
13347#[cfg(unix)]
13348fn save_trust_in(
13349    cfg: &HubConfig,
13350    directory: &TrustDirectory,
13351    state: &TrustState,
13352) -> LinkResult<()> {
13353    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13354
13355    let name_string = trust_file_name(cfg, &state.requested)?;
13356    let name = c_name(name_string.as_bytes(), &name_string)?;
13357    let mut bytes = serde_json::to_vec(state)
13358        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13359    bytes.push(b'\n');
13360
13361    let nonce = std::time::SystemTime::now()
13362        .duration_since(std::time::UNIX_EPOCH)
13363        .unwrap_or_default()
13364        .as_nanos();
13365    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13366    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13367    let fd = unsafe {
13368        libc::openat(
13369            directory.as_raw_fd(),
13370            temp.as_ptr(),
13371            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13372            0o600,
13373        )
13374    };
13375    if fd < 0 {
13376        return Err(std::io::Error::last_os_error().into());
13377    }
13378    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13379    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13380        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13381        return Err(error.into());
13382    }
13383    drop(file);
13384    if unsafe {
13385        libc::renameat(
13386            directory.as_raw_fd(),
13387            temp.as_ptr(),
13388            directory.as_raw_fd(),
13389            name.as_ptr(),
13390        )
13391    } != 0
13392    {
13393        let error = std::io::Error::last_os_error();
13394        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13395        return Err(error.into());
13396    }
13397    directory.sync_all()?;
13398    Ok(())
13399}
13400
13401#[cfg(windows)]
13402fn save_trust_in(
13403    cfg: &HubConfig,
13404    directory: &TrustDirectory,
13405    state: &TrustState,
13406) -> LinkResult<()> {
13407    let name = trust_file_name(cfg, &state.requested)?;
13408    let mut bytes = serde_json::to_vec(state)
13409        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13410    bytes.push(b'\n');
13411    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13412    Ok(())
13413}
13414
13415#[cfg(not(any(unix, windows)))]
13416fn save_trust_in(
13417    _cfg: &HubConfig,
13418    _directory: &TrustDirectory,
13419    _state: &TrustState,
13420) -> LinkResult<()> {
13421    Err(LinkError::UnsupportedPlatform {
13422        operation: "verified link.md state",
13423    })
13424}
13425
13426#[cfg(unix)]
13427fn load_alias_in(
13428    cfg: &HubConfig,
13429    directory: &TrustDirectory,
13430    requested: &str,
13431) -> LinkResult<Option<AliasBinding>> {
13432    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13433
13434    let name_string = alias_file_name(cfg, requested)?;
13435    let name = c_name(name_string.as_bytes(), &name_string)?;
13436    let fd = unsafe {
13437        libc::openat(
13438            directory.as_raw_fd(),
13439            name.as_ptr(),
13440            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13441        )
13442    };
13443    if fd < 0 {
13444        let error = std::io::Error::last_os_error();
13445        if error.kind() == std::io::ErrorKind::NotFound {
13446            return Ok(None);
13447        }
13448        return Err(LinkError::UnsafePath { path: name_string });
13449    }
13450    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13451    if !file.metadata()?.is_file() {
13452        return Err(LinkError::UnsafePath { path: name_string });
13453    }
13454    let mut bytes = Vec::new();
13455    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13456    if bytes.len() > 64 * 1024 {
13457        return Err(invalid_feed("local alias binding is oversized"));
13458    }
13459    let alias: AliasBinding = serde_json::from_slice(&bytes)
13460        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13461    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13462    {
13463        return Err(invalid_feed(
13464            "local alias binding does not match this hub and requested ref",
13465        ));
13466    }
13467    Ok(Some(alias))
13468}
13469
13470#[cfg(windows)]
13471fn load_alias_in(
13472    cfg: &HubConfig,
13473    directory: &TrustDirectory,
13474    requested: &str,
13475) -> LinkResult<Option<AliasBinding>> {
13476    let name = alias_file_name(cfg, requested)?;
13477    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13478    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13479        Ok(bytes) => bytes,
13480        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13481        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13482    };
13483    let alias: AliasBinding = serde_json::from_slice(&bytes)
13484        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13485    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13486    {
13487        return Err(invalid_feed(
13488            "local alias binding does not match this hub and requested ref",
13489        ));
13490    }
13491    Ok(Some(alias))
13492}
13493
13494#[cfg(not(any(unix, windows)))]
13495fn load_alias_in(
13496    _cfg: &HubConfig,
13497    _directory: &TrustDirectory,
13498    _requested: &str,
13499) -> LinkResult<Option<AliasBinding>> {
13500    Err(LinkError::UnsupportedPlatform {
13501        operation: "verified link.md state",
13502    })
13503}
13504
13505#[cfg(unix)]
13506fn save_alias_in(
13507    cfg: &HubConfig,
13508    directory: &TrustDirectory,
13509    alias: &AliasBinding,
13510) -> LinkResult<()> {
13511    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13512
13513    let name_string = alias_file_name(cfg, &alias.requested)?;
13514    let name = c_name(name_string.as_bytes(), &name_string)?;
13515    let mut bytes = serde_json::to_vec(alias)
13516        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13517    bytes.push(b'\n');
13518    let nonce = std::time::SystemTime::now()
13519        .duration_since(std::time::UNIX_EPOCH)
13520        .unwrap_or_default()
13521        .as_nanos();
13522    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13523    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13524    let fd = unsafe {
13525        libc::openat(
13526            directory.as_raw_fd(),
13527            temp.as_ptr(),
13528            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13529            0o600,
13530        )
13531    };
13532    if fd < 0 {
13533        return Err(std::io::Error::last_os_error().into());
13534    }
13535    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13536    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13537        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13538        return Err(error.into());
13539    }
13540    drop(file);
13541    if unsafe {
13542        libc::renameat(
13543            directory.as_raw_fd(),
13544            temp.as_ptr(),
13545            directory.as_raw_fd(),
13546            name.as_ptr(),
13547        )
13548    } != 0
13549    {
13550        let error = std::io::Error::last_os_error();
13551        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13552        return Err(error.into());
13553    }
13554    directory.sync_all()?;
13555    Ok(())
13556}
13557
13558#[cfg(windows)]
13559fn save_alias_in(
13560    cfg: &HubConfig,
13561    directory: &TrustDirectory,
13562    alias: &AliasBinding,
13563) -> LinkResult<()> {
13564    let name = alias_file_name(cfg, &alias.requested)?;
13565    let mut bytes = serde_json::to_vec(alias)
13566        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13567    bytes.push(b'\n');
13568    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13569    Ok(())
13570}
13571
13572#[cfg(not(any(unix, windows)))]
13573fn save_alias_in(
13574    _cfg: &HubConfig,
13575    _directory: &TrustDirectory,
13576    _alias: &AliasBinding,
13577) -> LinkResult<()> {
13578    Err(LinkError::UnsupportedPlatform {
13579        operation: "verified link.md state",
13580    })
13581}
13582
13583/// Load the canonical checkpoint shared by every spelling of one brain and
13584/// the separate alias binding. This also performs the one-way migration from
13585/// pre-v3 checkpoints that stored a full trust state under the alias itself.
13586/// Callers hold both alias and canonical locks before entering.
13587fn load_canonical_pin(
13588    cfg: &HubConfig,
13589    directory: &TrustDirectory,
13590    requested: &str,
13591    resolved_brain: &str,
13592) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13593    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13594    if requested == resolved_brain {
13595        return Ok((canonical, None));
13596    }
13597
13598    let mut alias = load_alias_in(cfg, directory, requested)?;
13599    if let Some(binding) = &alias {
13600        if binding.brain != resolved_brain {
13601            return Err(LinkError::AliasRebindRequired {
13602                alias: requested.to_string(),
13603                from: binding.brain.clone(),
13604                to: resolved_brain.to_string(),
13605            });
13606        }
13607        return Ok((canonical, alias));
13608    }
13609
13610    // A v2 build stored the complete checkpoint under the requested slug.
13611    // Promote it to the canonical ULID key before creating the lightweight
13612    // alias binding. Never silently merge two independently advanced states.
13613    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13614        if legacy.brain != resolved_brain {
13615            return Err(invalid_feed(
13616                "legacy alias checkpoint names a different canonical brain",
13617            ));
13618        }
13619        if let Some(existing) = &canonical {
13620            if existing.brain != legacy.brain
13621                || existing.anchor != legacy.anchor
13622                || existing.current != legacy.current
13623                || existing.head_seq != legacy.head_seq
13624                || existing.feed_hash != legacy.feed_hash
13625                || existing.rotations != legacy.rotations
13626            {
13627                return Err(invalid_feed(
13628                    "legacy alias checkpoint conflicts with the canonical checkpoint",
13629                ));
13630            }
13631        } else {
13632            let mut promoted = legacy.clone();
13633            promoted.requested = resolved_brain.to_string();
13634            promoted.home = None;
13635            save_trust_in(cfg, directory, &promoted)?;
13636            canonical = Some(promoted);
13637        }
13638        alias = Some(AliasBinding {
13639            v: 1,
13640            origin: normalized_origin(&cfg.hub)?,
13641            requested: requested.to_string(),
13642            brain: resolved_brain.to_string(),
13643            home: legacy.home,
13644        });
13645        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13646    }
13647    Ok((canonical, alias))
13648}
13649
13650/// Explicitly replace one mutable alias binding after the operator reviews the
13651/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
13652/// or rewritten, so old history remains pinned and the new brain establishes
13653/// its own independently verified trust chain.
13654pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13655    require_hardened_filesystem("verified alias rebind")?;
13656    require_safe_ref(alias)?;
13657    require_safe_ref(from)?;
13658    require_safe_ref(to)?;
13659    if crate::ulid::is_ulid(alias)
13660        || !crate::ulid::is_ulid(from)
13661        || !crate::ulid::is_ulid(to)
13662        || from == to
13663    {
13664        return Err(LinkError::InvalidPack {
13665            message:
13666                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13667                    .to_string(),
13668        });
13669    }
13670
13671    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13672        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13673    })?;
13674    accept_v2_head(cfg, &verified)?;
13675
13676    let alias_response = ensure_ok(
13677        request(
13678            cfg,
13679            "GET",
13680            &format!("/api/hub/brains/{alias}/v2/head"),
13681            None,
13682            Auth::Required,
13683        )?,
13684        "resolve alias for explicit rebind",
13685    )?;
13686    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13687        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13688    if resolved.v != 2 || resolved.brain_id != to {
13689        return Err(LinkError::RemoteAdvancedDuringSync);
13690    }
13691
13692    let directory = open_trust_dir(cfg)?;
13693    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13694    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13695        message: "the requested alias has no existing local binding to replace".to_string(),
13696    })?;
13697    if binding.brain != from {
13698        return Err(LinkError::AliasRebindRequired {
13699            alias: alias.to_string(),
13700            from: binding.brain,
13701            to: to.to_string(),
13702        });
13703    }
13704    save_alias_in(
13705        cfg,
13706        &directory,
13707        &AliasBinding {
13708            v: 1,
13709            origin: normalized_origin(&cfg.hub)?,
13710            requested: alias.to_string(),
13711            brain: to.to_string(),
13712            home: binding.home,
13713        },
13714    )?;
13715    Ok(json!({
13716        "v": 2,
13717        "alias": alias,
13718        "from": from,
13719        "to": to,
13720        "outcome": "alias_rebound",
13721    }))
13722}
13723
13724fn save_canonical_pin_and_alias(
13725    cfg: &HubConfig,
13726    directory: &TrustDirectory,
13727    requested: &str,
13728    resolved_brain: &str,
13729    mut state: TrustState,
13730    existing_alias: Option<&AliasBinding>,
13731) -> LinkResult<()> {
13732    state.requested = resolved_brain.to_string();
13733    state.brain = resolved_brain.to_string();
13734    state.home = None;
13735    save_trust_in(cfg, directory, &state)?;
13736    if requested != resolved_brain {
13737        save_alias_in(
13738            cfg,
13739            directory,
13740            &AliasBinding {
13741                v: 1,
13742                origin: normalized_origin(&cfg.hub)?,
13743                requested: requested.to_string(),
13744                brain: resolved_brain.to_string(),
13745                home: existing_alias.and_then(|alias| alias.home.clone()),
13746            },
13747        )?;
13748    }
13749    Ok(())
13750}
13751
13752fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13753    const ED25519_SPKI_PREFIX: &[u8] = &[
13754        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13755    ];
13756    let entry = &item.entry;
13757    let public_der = URL_SAFE_NO_PAD
13758        .decode(&entry.public_key)
13759        .map_err(|_| invalid_feed("public key is not base64url"))?;
13760    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13761        || !public_der.starts_with(ED25519_SPKI_PREFIX)
13762    {
13763        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13764    }
13765    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13766    if entry.brain != format!("ed25519:{fingerprint}") {
13767        return Err(invalid_feed(
13768            "brain fingerprint does not match its public key",
13769        ));
13770    }
13771    // Verify the complete rotation authority before using any previous key.
13772    let _ = verify_identity_chain(identity, None)?;
13773    let mut chain: Vec<(&str, &str)> = identity
13774        .previous
13775        .iter()
13776        .rev()
13777        .map(|previous| {
13778            (
13779                previous.fingerprint.as_str(),
13780                previous.public_key_spki.as_str(),
13781            )
13782        })
13783        .collect();
13784    chain.push((&identity.fingerprint, &identity.public_key_spki));
13785    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13786        *known_fingerprint == fingerprint && *spki == entry.public_key
13787    });
13788    let Some(signer_index) = signer_index else {
13789        return Err(invalid_feed(
13790            "entry signer is not this brain's identity (current or rotated-from)",
13791        ));
13792    };
13793    let lower_boundary = if signer_index == 0 {
13794        None
13795    } else {
13796        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13797            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13798        Some(prior.prior_head_seq)
13799    };
13800    let upper_boundary = if signer_index == identity.rotations.len() {
13801        None
13802    } else {
13803        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13804            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13805        Some(next.prior_head_seq)
13806    };
13807    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13808        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13809    {
13810        return Err(invalid_feed(
13811            "entry signer is outside its authenticated rotation epoch",
13812        ));
13813    }
13814    let unsigned = UnsignedFeedEntry {
13815        v: entry.v,
13816        seq: entry.seq,
13817        ts: &entry.ts,
13818        brain: &entry.brain,
13819        public_key: &entry.public_key,
13820        kind: &entry.kind,
13821        op: &entry.op,
13822        pack_sha256: &entry.pack_sha256,
13823        files: &entry.files,
13824        removed: &entry.removed,
13825        prev_entry_hash: &entry.prev_entry_hash,
13826    };
13827    let message =
13828        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13829    let signature = URL_SAFE_NO_PAD
13830        .decode(&entry.sig)
13831        .map_err(|_| invalid_feed("signature is not base64url"))?;
13832    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13833        .verify(&message, &signature)
13834        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13835
13836    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13837    exact.push(b'\n');
13838    let actual_hash = format!("{:x}", Sha256::digest(&exact));
13839    if actual_hash != item.hash {
13840        return Err(invalid_feed("entry SHA-256 does not match"));
13841    }
13842    Ok(())
13843}
13844
13845// ─────────────────────────────────────────────────────────────────────────────
13846// key rotation — link.md §9.1: the new key, signed by the old one
13847// ─────────────────────────────────────────────────────────────────────────────
13848
13849/// The unsigned rotation statement in its normative field order.
13850#[derive(Serialize)]
13851struct UnsignedRotation<'a> {
13852    v: u8,
13853    op: &'a str,
13854    brain: &'a str,
13855    public_key: &'a str,
13856    new_brain: &'a str,
13857    new_public_key: &'a str,
13858    prior_head_seq: u64,
13859    prior_feed_hash: Option<&'a str>,
13860    ts: String,
13861}
13862
13863/// Durable intent for an in-flight key rotation. The hub's recovery contract
13864/// identifies an ambiguous retry by the exact statement bytes, so the
13865/// statement cannot be reconstructed from the key and feed boundary later:
13866/// its timestamp and signature would differ.
13867#[derive(Debug, Deserialize, Serialize)]
13868#[serde(deny_unknown_fields)]
13869struct RotationJournal {
13870    v: u8,
13871    origin: String,
13872    brain: String,
13873    old_brain: String,
13874    new_brain: String,
13875    prior_head_seq: u64,
13876    prior_feed_hash: Option<String>,
13877    statement: String,
13878}
13879
13880fn rotation_journal_path(key_path: &Path) -> PathBuf {
13881    let mut path = key_path.as_os_str().to_os_string();
13882    path.push(".rotation.json");
13883    PathBuf::from(path)
13884}
13885
13886fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13887    #[cfg(unix)]
13888    let file = {
13889        use std::os::fd::{AsRawFd as _, FromRawFd as _};
13890        use std::os::unix::ffi::OsStrExt as _;
13891        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13892            .map_err(|error| {
13893                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13894            })?;
13895        let leaf_name = path
13896            .file_name()
13897            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13898        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13899        let fd = unsafe {
13900            libc::openat(
13901                parent.as_raw_fd(),
13902                leaf.as_ptr(),
13903                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13904            )
13905        };
13906        if fd < 0 {
13907            return Err(bad_agent_key(
13908                "the rotation journal must be an existing regular file without symlink ancestors",
13909            ));
13910        }
13911        unsafe { std::fs::File::from_raw_fd(fd) }
13912    };
13913    #[cfg(not(unix))]
13914    let file = std::fs::File::open(path)
13915        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13916    let metadata = file
13917        .metadata()
13918        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13919    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13920        return Err(bad_agent_key(
13921            "the rotation journal must be a bounded regular file",
13922        ));
13923    }
13924    #[cfg(unix)]
13925    {
13926        use std::os::unix::fs::PermissionsExt as _;
13927        if metadata.permissions().mode() & 0o077 != 0 {
13928            return Err(bad_agent_key(
13929                "the rotation journal is accessible to group/other; set mode 0600",
13930            ));
13931        }
13932    }
13933    serde_json::from_reader(file)
13934        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13935}
13936
13937fn remove_rotation_journal(path: &Path) {
13938    #[cfg(unix)]
13939    {
13940        use std::os::fd::AsRawFd as _;
13941        use std::os::unix::ffi::OsStrExt as _;
13942        let Ok(parent) =
13943            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13944        else {
13945            return;
13946        };
13947        let Some(leaf_name) = path.file_name() else {
13948            return;
13949        };
13950        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13951            return;
13952        };
13953        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13954            let _ = parent.sync_all();
13955        }
13956    }
13957    #[cfg(not(unix))]
13958    {
13959        let _ = std::fs::remove_file(path);
13960    }
13961}
13962
13963fn validate_rotation_journal(
13964    journal: &RotationJournal,
13965    cfg: &HubConfig,
13966    canonical_brain: &str,
13967    old_key: &AgentSigningKey,
13968    new_key: &AgentSigningKey,
13969    head: &Head,
13970) -> LinkResult<()> {
13971    if journal.v != 1
13972        || journal.origin != normalized_origin(&cfg.hub)?
13973        || journal.brain != canonical_brain
13974        || journal.old_brain != old_key.multikey
13975        || journal.new_brain != new_key.multikey
13976        || journal.prior_head_seq != head.seq
13977        || journal.prior_feed_hash != head.feed_hash
13978    {
13979        return Err(invalid_feed(
13980            "rotation journal does not match the verified key and feed boundary",
13981        ));
13982    }
13983    let statement: RotationStatement = serde_json::from_str(&journal.statement)
13984        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13985    if statement.prior_head_seq != journal.prior_head_seq
13986        || statement.prior_feed_hash != journal.prior_feed_hash
13987        || statement.brain != old_key.multikey
13988        || statement.public_key != old_key.public_key_spki
13989        || statement.new_brain != new_key.multikey
13990        || statement.new_public_key != new_key.public_key_spki
13991    {
13992        return Err(invalid_feed(
13993            "rotation journal statement does not match its durable intent",
13994        ));
13995    }
13996    let identity = FeedIdentity {
13997        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13998        public_key_spki: new_key.public_key_spki.clone(),
13999        previous: vec![PreviousIdentity {
14000            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
14001            public_key_spki: old_key.public_key_spki.clone(),
14002        }],
14003        rotations: vec![journal.statement.clone()],
14004    };
14005    verify_identity_chain(&identity, None)?;
14006    Ok(())
14007}
14008
14009/// What `dbmd key rotate` returns.
14010#[derive(Debug, Serialize)]
14011pub struct RotationReport {
14012    /// The brain id rotated.
14013    pub brain: String,
14014    /// The NEW identity the hub now serves.
14015    pub multikey: String,
14016    /// Where the new PKCS#8 secret landed (0600).
14017    #[serde(rename = "keyFile")]
14018    pub key_file: String,
14019    /// Prior identities (newest first) the feed still verifies against.
14020    pub previous: Vec<String>,
14021}
14022
14023/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
14024/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
14025/// OLD key in normative serialization — and send it to the hub. The new secret
14026/// is durably created at 0600 before the POST; an existing output is reused for
14027/// idempotent retry/reconciliation. The old key is left untouched.
14028pub fn rotate_brain_key(
14029    cfg: &HubConfig,
14030    brain: &str,
14031    old_key: &AgentSigningKey,
14032    out: &Path,
14033) -> LinkResult<RotationReport> {
14034    require_hardened_filesystem("key rotation")?;
14035    require_safe_ref(brain)?;
14036    // The new private key must be durable *before* the hub can accept its
14037    // public half. An existing file is the retry/reconciliation path after an
14038    // ambiguous network failure: reuse it, never generate another identity.
14039    let new_key = if out.exists() {
14040        load_signing_key(out)?
14041    } else {
14042        let rng = ring::rand::SystemRandom::new();
14043        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
14044            .map_err(|_| bad_agent_key("key generation failed"))?;
14045        let pair = agent_keypair(pkcs8.as_ref())?;
14046        let (public_key_spki, multikey) = public_identity_for(&pair);
14047        write_secret_new(
14048            out,
14049            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
14050        )?;
14051        AgentSigningKey {
14052            pkcs8: pkcs8.as_ref().to_vec(),
14053            multikey,
14054            public_key_spki,
14055        }
14056    };
14057    let new_spki = new_key.public_key_spki.clone();
14058    let new_multikey = new_key.multikey.clone();
14059    let journal_path = rotation_journal_path(out);
14060    let before_v2 = v2_verified_head(cfg, brain)?;
14061    let (canonical_brain, served_identity, observed_head, v2_profile) =
14062        if let Some(head) = before_v2 {
14063            let observed = Head {
14064                brain: head.brain_id.clone(),
14065                seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14066                updated_at: head
14067                    .pointer
14068                    .as_ref()
14069                    .map(|pointer| pointer.signed_at.clone()),
14070                feed_hash: head
14071                    .pointer
14072                    .as_ref()
14073                    .map(|pointer| pointer.feed_hash.clone()),
14074                verified: true,
14075            };
14076            let identity = v2_identity(&head.identity);
14077            let canonical = head.brain_id.clone();
14078            accept_v2_head(cfg, &head)?;
14079            (canonical, identity, observed, true)
14080        } else {
14081            let remote = verified_remote_head(cfg, brain, false)?;
14082            let identity = remote
14083                .identity
14084                .clone()
14085                .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
14086            (remote.head.brain.clone(), identity, remote.head, false)
14087        };
14088    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
14089    let already_rotated = served_multikey == new_multikey;
14090    // A completed invocation removes the journal only after the server has
14091    // acknowledged its recovery boundary. If the verified identity advanced
14092    // but the journal remains, the prior response was ambiguous: replay the
14093    // exact signed statement so a v2 hub can repair/confirm its checkpoint.
14094    if already_rotated && !journal_path.exists() {
14095        remove_rotation_journal(&journal_path);
14096        return Ok(RotationReport {
14097            brain: brain.to_string(),
14098            multikey: new_multikey,
14099            key_file: out.display().to_string(),
14100            previous: served_identity
14101                .previous
14102                .iter()
14103                .map(|identity| format!("ed25519:{}", identity.fingerprint))
14104                .collect(),
14105        });
14106    }
14107    if !already_rotated && served_multikey != old_key.multikey {
14108        return Err(invalid_feed(
14109            "the supplied old key is not the brain's verified current identity",
14110        ));
14111    }
14112
14113    let journal = if journal_path.exists() {
14114        read_rotation_journal(&journal_path)?
14115    } else {
14116        let ts = crate::now()
14117            .with_timezone(&chrono::Utc)
14118            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14119            .to_string();
14120        let unsigned = serde_json::to_string(&UnsignedRotation {
14121            v: 1,
14122            op: "rotate",
14123            brain: &old_key.multikey,
14124            public_key: &old_key.public_key_spki,
14125            new_brain: &new_multikey,
14126            new_public_key: &new_spki,
14127            prior_head_seq: observed_head.seq,
14128            prior_feed_hash: observed_head.feed_hash.as_deref(),
14129            ts,
14130        })
14131        .expect("serialize rotation");
14132        let old_pair = agent_keypair(&old_key.pkcs8)?;
14133        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14134        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14135        let journal = RotationJournal {
14136            v: 1,
14137            origin: normalized_origin(&cfg.hub)?,
14138            brain: canonical_brain.clone(),
14139            old_brain: old_key.multikey.clone(),
14140            new_brain: new_multikey.clone(),
14141            prior_head_seq: observed_head.seq,
14142            prior_feed_hash: observed_head.feed_hash.clone(),
14143            statement,
14144        };
14145        let mut exact = serde_json::to_vec(&journal)
14146            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14147        exact.push(b'\n');
14148        if write_secret_new(&journal_path, &exact).is_err() {
14149            // A concurrent retry may have won the O_EXCL race. Only an exact,
14150            // fully validated journal is allowed to recover that race.
14151            read_rotation_journal(&journal_path)?
14152        } else {
14153            journal
14154        }
14155    };
14156    validate_rotation_journal(
14157        &journal,
14158        cfg,
14159        &canonical_brain,
14160        old_key,
14161        &new_key,
14162        &observed_head,
14163    )?;
14164
14165    let body = json!({ "statement": journal.statement });
14166    let path = format!("/api/hub/brains/{brain}/rotate");
14167    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14168    let attempted_failure = match attempted {
14169        Ok(response) if (200..300).contains(&response.status) => None,
14170        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14171        Err(error) => Some(error),
14172    };
14173
14174    // A 2xx body is not authority, and a failed response may have followed a
14175    // committed mutation. In both cases success means the normal verifier sees
14176    // an append-only rotation chain ending at the durable new key.
14177    let identity = if v2_profile {
14178        match v2_verified_head(cfg, brain) {
14179            Ok(Some(after)) => {
14180                let identity = v2_identity(&after.identity);
14181                accept_v2_head(cfg, &after)?;
14182                identity
14183            }
14184            Ok(None) => {
14185                return Err(attempted_failure.unwrap_or_else(|| {
14186                    invalid_feed("rotated v2 brain no longer serves a v2 head")
14187                }));
14188            }
14189            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14190        }
14191    } else {
14192        match verified_remote_head(cfg, brain, false) {
14193            Ok(after) => after
14194                .identity
14195                .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14196            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14197        }
14198    };
14199    if format!("ed25519:{}", identity.fingerprint) != new_multikey
14200        || identity.public_key_spki != new_spki
14201    {
14202        return Err(attempted_failure.unwrap_or_else(|| {
14203            invalid_feed("hub acknowledged rotation without committing the verified new identity")
14204        }));
14205    }
14206    if v2_profile {
14207        if let Some(error) = attempted_failure {
14208            // Seeing the new public identity is insufficient: v2 rotation is
14209            // successful only after the endpoint has forced and acknowledged
14210            // the recovery checkpoint that contains it. Keep the journal for
14211            // an exact retry after any non-2xx or transport ambiguity.
14212            return Err(error);
14213        }
14214    }
14215    let previous = identity
14216        .previous
14217        .iter()
14218        .map(|prior| format!("ed25519:{}", prior.fingerprint))
14219        .collect();
14220    remove_rotation_journal(&journal_path);
14221
14222    Ok(RotationReport {
14223        brain: brain.to_string(),
14224        multikey: new_multikey,
14225        key_file: out.display().to_string(),
14226        previous,
14227    })
14228}
14229
14230// ─────────────────────────────────────────────────────────────────────────────
14231// mirror — verified replication: the whole feed + files, re-servable
14232// ─────────────────────────────────────────────────────────────────────────────
14233
14234/// What `dbmd mirror` materialized.
14235#[derive(Debug, Serialize)]
14236pub struct MirrorReport {
14237    /// The brain id.
14238    pub brain: String,
14239    /// The mirrored feed head.
14240    #[serde(rename = "headSeq")]
14241    pub head_seq: u64,
14242    /// The head entry hash (the feed's advertised converged state).
14243    #[serde(rename = "feedHash")]
14244    pub feed_hash: Option<String>,
14245    /// Signed feed entries verified and stored.
14246    pub entries: u64,
14247    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
14248    pub pinned: String,
14249    /// Store files materialized by the pull.
14250    pub files: usize,
14251}
14252
14253/// The mirror state directory, relative to the mirror root.
14254pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
14255
14256/// Fully re-verified mirror material suitable for a read-only re-server.
14257#[derive(Debug)]
14258pub struct VerifiedMirrorMaterial {
14259    pub brain: String,
14260    pub head_seq: u64,
14261    pub feed_hash: Option<String>,
14262    pub identity: serde_json::Value,
14263    /// Sequence, exact normative entry JSON without newline, and entry hash.
14264    pub entries: Vec<(u64, String, String)>,
14265    pub pack_sha256: Option<String>,
14266}
14267
14268#[derive(Deserialize)]
14269#[serde(deny_unknown_fields)]
14270struct StoredMirrorHead {
14271    brain: String,
14272    #[serde(rename = "headSeq")]
14273    head_seq: u64,
14274    #[serde(rename = "feedHash")]
14275    feed_hash: Option<String>,
14276}
14277
14278/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
14279/// exact snapshot pack before `dbmd serve` exposes any bytes.
14280pub fn verify_mirror_material(
14281    head_bytes: &[u8],
14282    identity_bytes: &[u8],
14283    feed_bytes: &[Vec<u8>],
14284    snapshot_pack: Option<&[u8]>,
14285    expected_anchor: &str,
14286) -> LinkResult<VerifiedMirrorMaterial> {
14287    let snapshot_hash = snapshot_pack
14288        .filter(|pack| !pack.is_empty())
14289        .map(content_sha256);
14290    verify_mirror_material_with_pack_hash(
14291        head_bytes,
14292        identity_bytes,
14293        feed_bytes,
14294        snapshot_hash.as_deref(),
14295        expected_anchor,
14296    )
14297}
14298
14299/// Re-verify mirror metadata against a snapshot digest computed from a held
14300/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
14301/// large pack without ever buffering the pack in process memory.
14302pub fn verify_mirror_material_with_pack_hash(
14303    head_bytes: &[u8],
14304    identity_bytes: &[u8],
14305    feed_bytes: &[Vec<u8>],
14306    snapshot_pack_sha256: Option<&str>,
14307    expected_anchor: &str,
14308) -> LinkResult<VerifiedMirrorMaterial> {
14309    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
14310        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
14311    require_safe_ref(&head.brain)?;
14312    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
14313        return Err(invalid_feed(
14314            "stored mirror feed count does not match its bounded head sequence",
14315        ));
14316    }
14317    let aggregate = feed_bytes
14318        .iter()
14319        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
14320        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
14321    if aggregate > MAX_FEED_REPLAY_BYTES {
14322        return Err(invalid_feed(
14323            "stored mirror feed metadata exceeds the aggregate limit",
14324        ));
14325    }
14326    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
14327        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
14328    let anchor = verify_identity_chain(&identity, None)?;
14329    if anchor != expected_anchor {
14330        return Err(invalid_feed(
14331            "stored mirror identity does not descend from the explicitly trusted anchor",
14332        ));
14333    }
14334
14335    let mut entries = Vec::with_capacity(feed_bytes.len());
14336    let mut items = Vec::with_capacity(feed_bytes.len());
14337    let mut previous_hash = None;
14338    let mut pack_sha256 = None;
14339    for (index, bytes) in feed_bytes.iter().enumerate() {
14340        let exact = bytes
14341            .strip_suffix(b"\n")
14342            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
14343        if exact.ends_with(b"\n") {
14344            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
14345        }
14346        let entry: FeedEntry = serde_json::from_slice(exact)
14347            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
14348        let expected_seq = index as u64 + 1;
14349        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
14350            return Err(invalid_feed(
14351                "stored mirror feed is not contiguous and hash-chained",
14352            ));
14353        }
14354        let canonical = serde_json::to_vec(&entry)
14355            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
14356        if canonical != exact {
14357            return Err(invalid_feed(
14358                "stored feed entry is not in normative serialization",
14359            ));
14360        }
14361        let hash = content_sha256(bytes);
14362        let item = FeedItem {
14363            hash: hash.clone(),
14364            entry,
14365        };
14366        verify_feed_item(&item, &identity)?;
14367        previous_hash = Some(hash.clone());
14368        if expected_seq == head.head_seq {
14369            pack_sha256 = Some(item.entry.pack_sha256.clone());
14370        }
14371        entries.push((
14372            expected_seq,
14373            std::str::from_utf8(exact)
14374                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
14375                .to_string(),
14376            hash,
14377        ));
14378        items.push(item);
14379    }
14380    if previous_hash != head.feed_hash {
14381        return Err(invalid_feed(
14382            "stored mirror feed does not converge on its advertised head",
14383        ));
14384    }
14385    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
14386    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
14387        (0, None, None) => {}
14388        (_, Some(actual), Some(expected)) if actual == expected => {}
14389        _ => {
14390            return Err(LinkError::InvalidPack {
14391                message: "stored snapshot pack does not match the signed head digest".to_string(),
14392            });
14393        }
14394    }
14395    let identity_value = serde_json::to_value(&identity)
14396        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
14397    Ok(VerifiedMirrorMaterial {
14398        brain: head.brain,
14399        head_seq: head.head_seq,
14400        feed_hash: head.feed_hash,
14401        identity: identity_value,
14402        entries,
14403        pack_sha256,
14404    })
14405}
14406
14407/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
14408/// entry hash every consumer recomputes (SPEC §5.3).
14409pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
14410    format!(
14411        "{:x}",
14412        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
14413    )
14414}
14415
14416/// SHA-256 hex for content a signed manifest names. Exposed for the thin
14417/// `dbmd serve` adapter; cryptographic verification remains centralized here.
14418pub fn content_sha256(bytes: &[u8]) -> String {
14419    format!("{:x}", Sha256::digest(bytes))
14420}
14421
14422/// SHA-256 a stream with a fixed-size working buffer.
14423pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14424    let mut digest = Sha256::new();
14425    let mut buffer = [0u8; 64 * 1024];
14426    loop {
14427        let read = reader.read(&mut buffer)?;
14428        if read == 0 {
14429            break;
14430        }
14431        digest.update(&buffer[..read]);
14432    }
14433    Ok(format!("{:x}", digest.finalize()))
14434}
14435
14436/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
14437/// chain, not just the head): every entry's signature, hash, sequence
14438/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
14439/// checked in a sibling staging directory. Only then is the old mirror swapped
14440/// out through an atomic directory exchange. Every stage, install, and cleanup
14441/// operation is relative to one held no-follow parent-directory capability, so
14442/// renaming an ancestor cannot redirect any write or deletion.
14443#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14444pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14445    require_hardened_filesystem("mirror")?;
14446    require_safe_ref(brain)?;
14447    #[cfg(windows)]
14448    {
14449        let _ = (cfg, dest);
14450        return Err(LinkError::UnsupportedPlatform {
14451            operation: "atomic whole-mirror replacement on Windows",
14452        });
14453    }
14454    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14455    let name = dest
14456        .file_name()
14457        .and_then(|name| name.to_str())
14458        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14459        .ok_or_else(|| LinkError::UnsafePath {
14460            path: dest.display().to_string(),
14461        })?;
14462    #[cfg(unix)]
14463    let parent_dir = open_or_create_dir_nofollow(parent)?;
14464    #[cfg(unix)]
14465    use std::os::fd::AsRawFd as _;
14466    #[cfg(unix)]
14467    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14468    #[cfg(unix)]
14469    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14470        None => false,
14471        Some(true) => true,
14472        Some(false) => {
14473            return Err(LinkError::UnsafePath {
14474                path: dest.display().to_string(),
14475            });
14476        }
14477    };
14478
14479    // A fixed backup was used by pre-hardening builds. Never interpret or
14480    // delete an attacker-planted entry at that name; require manual recovery.
14481    #[cfg(unix)]
14482    let legacy_backup_name = c_name(
14483        format!(".{name}.dbmd-backup").as_bytes(),
14484        &dest.display().to_string(),
14485    )?;
14486    #[cfg(unix)]
14487    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14488        return Err(LinkError::UnsafePath {
14489            path: parent
14490                .join(format!(".{name}.dbmd-backup"))
14491                .display()
14492                .to_string(),
14493        });
14494    }
14495
14496    let nonce = std::time::SystemTime::now()
14497        .duration_since(std::time::UNIX_EPOCH)
14498        .unwrap_or_default()
14499        .as_nanos();
14500    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14501    #[cfg(unix)]
14502    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14503    #[cfg(unix)]
14504    let stage_dir = create_dir_exclusive_at(
14505        parent_dir.as_raw_fd(),
14506        &stage_name,
14507        &dest.display().to_string(),
14508    )?;
14509
14510    let assembled = (|| -> LinkResult<MirrorReport> {
14511        let remote = verified_remote_head(cfg, brain, true)?;
14512        let brain_id = remote.head.brain.clone();
14513        let identity = remote
14514            .identity
14515            .as_ref()
14516            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14517        let anchor = remote
14518            .anchor
14519            .clone()
14520            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14521        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14522        let snapshot_entries = parse_store_pack(pack.clone())?;
14523        let snapshot_count = snapshot_entries.len();
14524        let mut staged_entries = snapshot_entries;
14525        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14526        for item in &remote.entries {
14527            let mut exact = serde_json::to_vec(&item.entry)
14528                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14529            exact.push(b'\n');
14530            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14531                return Err(invalid_feed(
14532                    "serialized mirror entry differs from its verified hash",
14533                ));
14534            }
14535            staged_entries.push((
14536                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14537                exact,
14538            ));
14539        }
14540        let mut identity_bytes = serde_json::to_vec(identity)
14541            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14542        identity_bytes.push(b'\n');
14543        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14544        let mut head_bytes = serde_json::to_vec(&json!({
14545            "brain": brain_id,
14546            "headSeq": remote.head.seq,
14547            "feedHash": remote.head.feed_hash,
14548        }))
14549        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14550        head_bytes.push(b'\n');
14551        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14552        staged_entries.push((
14553            CONFIG_REL_PATH.to_string(),
14554            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14555        ));
14556        #[cfg(unix)]
14557        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14558
14559        Ok(MirrorReport {
14560            brain: brain_id,
14561            head_seq: remote.head.seq,
14562            feed_hash: remote.head.feed_hash,
14563            entries: remote.entries.len() as u64,
14564            pinned: anchor,
14565            files: snapshot_count,
14566        })
14567    })();
14568
14569    let report = match assembled {
14570        Ok(report) => report,
14571        Err(error) => {
14572            #[cfg(unix)]
14573            let _ = remove_tree_at(
14574                parent_dir.as_raw_fd(),
14575                &stage_name,
14576                &dest.display().to_string(),
14577            );
14578            return Err(error);
14579        }
14580    };
14581
14582    #[cfg(unix)]
14583    if let Err(error) =
14584        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14585    {
14586        let _ = remove_tree_at(
14587            parent_dir.as_raw_fd(),
14588            &stage_name,
14589            &dest.display().to_string(),
14590        );
14591        return Err(error);
14592    }
14593    // An exchange leaves the old mirror at the unique stage name. Cleanup is
14594    // capability-relative and never follows symlinks in hostile old content.
14595    #[cfg(unix)]
14596    if dest_exists {
14597        remove_tree_at(
14598            parent_dir.as_raw_fd(),
14599            &stage_name,
14600            &dest.display().to_string(),
14601        )?;
14602    }
14603    #[cfg(unix)]
14604    parent_dir.sync_all()?;
14605    Ok(report)
14606}
14607
14608fn verified_remote_head(
14609    cfg: &HubConfig,
14610    brain: &str,
14611    require_full_chain: bool,
14612) -> LinkResult<VerifiedRemote> {
14613    require_hardened_filesystem("verified link.md state")?;
14614    require_safe_ref(brain)?;
14615    // Refuse an unsafe state root before sending credentials or consulting an
14616    // untrusted card, and retain this exact directory inode for the complete
14617    // checkpoint transaction.
14618    let trust_directory = open_trust_dir(cfg)?;
14619    let path = format!("/api/hub/brains/{brain}");
14620    let body = ensure_ok(
14621        request(cfg, "GET", &path, None, Auth::Required)?,
14622        "subscribe",
14623    )?;
14624    let resolved_brain = body
14625        .get("id")
14626        .and_then(Value::as_str)
14627        .filter(|id| crate::ulid::is_ulid(id))
14628        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14629        .to_string();
14630    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14631        return Err(invalid_feed(
14632            "brain card id differs from the explicitly requested brain id",
14633        ));
14634    }
14635    // The card supplies the canonical ULID. Lock alias + canonical keys in
14636    // deterministic filename order, then hold both through verify + save.
14637    // Concurrent aliases for the same brain therefore converge on one
14638    // checkpoint instead of establishing independent TOFU universes.
14639    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14640    let (pinned, alias_binding) =
14641        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14642    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14643    let advertised_hash = body
14644        .get("feedHash")
14645        .and_then(Value::as_str)
14646        .map(str::to_string);
14647    let updated_at = body
14648        .get("updatedAt")
14649        .and_then(Value::as_str)
14650        .map(str::to_string);
14651    if let Some(pin) = &pinned {
14652        if seq < pin.head_seq {
14653            return Err(invalid_feed(format!(
14654                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14655                pin.head_seq
14656            )));
14657        }
14658        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14659            return Err(invalid_feed(
14660                "feed equivocation: the checkpoint sequence now has a different hash",
14661            ));
14662        }
14663    }
14664    if seq == 0 {
14665        if advertised_hash.is_some() {
14666            return Err(invalid_feed("an empty feed advertised a head hash"));
14667        }
14668        let identity: FeedIdentity = serde_json::from_value(
14669            body.get("identity")
14670                .cloned()
14671                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14672        )
14673        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14674        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14675        // A valid old-key-signed rotation is still inconsistent if it commits
14676        // to history that the same card now claims never existed. Run the
14677        // identical feed-boundary proof used by non-empty heads before this
14678        // identity can become a durable TOFU checkpoint.
14679        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14680        save_canonical_pin_and_alias(
14681            cfg,
14682            &trust_directory,
14683            brain,
14684            &resolved_brain,
14685            TrustState {
14686                v: 2,
14687                origin: normalized_origin(&cfg.hub)?,
14688                requested: resolved_brain.clone(),
14689                brain: resolved_brain.clone(),
14690                home: None,
14691                anchor: anchor.clone(),
14692                current: format!("ed25519:{}", identity.fingerprint),
14693                head_seq: 0,
14694                feed_hash: None,
14695                rotations: identity.rotations.clone(),
14696                hub_signer: None,
14697                protocol_profile: None,
14698            },
14699            alias_binding.as_ref(),
14700        )?;
14701        return Ok(VerifiedRemote {
14702            head: Head {
14703                brain: resolved_brain,
14704                seq,
14705                updated_at,
14706                feed_hash: None,
14707                verified: true,
14708            },
14709            identity: Some(identity),
14710            head_entry: None,
14711            entries: Vec::new(),
14712            anchor: Some(anchor),
14713        });
14714    }
14715    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14716        return Err(invalid_feed(
14717            "non-empty feed did not advertise a valid SHA-256 head",
14718        ));
14719    }
14720
14721    // On first contact the signed head itself is the TOFU checkpoint. A full
14722    // history replay cannot add authority before an anchor exists; mirrors
14723    // still request the complete chain because they promise an archival copy.
14724    let replay_head_only = !require_full_chain
14725        && pinned
14726            .as_ref()
14727            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14728    let mut after = if replay_head_only {
14729        seq - 1
14730    } else if require_full_chain || pinned.is_none() {
14731        0
14732    } else {
14733        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14734    };
14735    let mut expected_seq = after + 1;
14736    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14737        None
14738    } else {
14739        pinned
14740            .as_ref()
14741            .and_then(|checkpoint| checkpoint.feed_hash.clone())
14742    };
14743    let mut identity: Option<FeedIdentity> = None;
14744    let mut anchor: Option<String> = None;
14745    let mut head_entry: Option<FeedItem> = None;
14746    let mut all_entries = Vec::new();
14747    let mut observed_entries = Vec::new();
14748    let replay_count = seq
14749        .checked_sub(after)
14750        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14751    if replay_count > MAX_FEED_REPLAY_ENTRIES {
14752        return Err(invalid_feed(format!(
14753            "feed replay requires {replay_count} entries, over the client cap"
14754        )));
14755    }
14756    let mut replay_bytes = 0u64;
14757
14758    loop {
14759        let feed_bytes = ensure_raw_ok(
14760            request_raw(
14761                cfg,
14762                "GET",
14763                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14764                None,
14765                Auth::Required,
14766                MAX_FEED_RESPONSE_BYTES,
14767            )?,
14768            "subscribe feed",
14769        )?;
14770        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14771            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14772        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14773            return Err(invalid_feed("brain card and feed head disagree"));
14774        }
14775        if feed.entries.len() > FEED_PAGE_LIMIT {
14776            return Err(invalid_feed("feed page exceeds the requested entry limit"));
14777        }
14778        if feed.scope_limited {
14779            if require_full_chain {
14780                return Err(invalid_feed(
14781                    "path-scoped grants cannot verify a full snapshot chain",
14782                ));
14783            }
14784            return Ok(VerifiedRemote {
14785                head: Head {
14786                    brain: resolved_brain,
14787                    seq,
14788                    updated_at,
14789                    feed_hash: advertised_hash,
14790                    verified: false,
14791                },
14792                identity: None,
14793                head_entry: None,
14794                entries: Vec::new(),
14795                anchor: None,
14796            });
14797        }
14798        let page_identity = feed
14799            .identity
14800            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14801        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14802        if identity
14803            .as_ref()
14804            .is_some_and(|existing| existing != &page_identity)
14805        {
14806            return Err(invalid_feed("identity changed while reading the feed"));
14807        }
14808        if anchor
14809            .as_ref()
14810            .is_some_and(|existing| existing != &page_anchor)
14811        {
14812            return Err(invalid_feed(
14813                "identity anchor changed while reading the feed",
14814            ));
14815        }
14816        identity = Some(page_identity.clone());
14817        if anchor.is_none() {
14818            anchor = Some(page_anchor);
14819        }
14820        if feed.entries.is_empty() {
14821            return Err(invalid_feed("feed page was empty before the signed head"));
14822        }
14823
14824        for item in feed.entries {
14825            if item.entry.seq != expected_seq {
14826                return Err(invalid_feed(format!(
14827                    "expected entry {expected_seq}, feed served {}",
14828                    item.entry.seq
14829                )));
14830            }
14831            if item.entry.seq > seq {
14832                return Err(invalid_feed("feed advanced past the card snapshot"));
14833            }
14834            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14835                return Err(invalid_feed(format!(
14836                    "entry {} does not chain to the local checkpoint",
14837                    item.entry.seq
14838                )));
14839            }
14840            verify_feed_item(&item, &page_identity)?;
14841            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14842            replay_bytes = replay_bytes.saturating_add(
14843                serde_json::to_vec(&item)
14844                    .map_err(|_| invalid_feed("could not size feed entry"))?
14845                    .len() as u64,
14846            );
14847            if replay_bytes > MAX_FEED_REPLAY_BYTES {
14848                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14849            }
14850            previous_hash = Some(item.hash.clone());
14851            after = item.entry.seq;
14852            expected_seq = expected_seq
14853                .checked_add(1)
14854                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14855            if require_full_chain {
14856                all_entries.push(item.clone());
14857            }
14858            observed_entries.push(item.clone());
14859            head_entry = Some(item);
14860        }
14861        if after == seq {
14862            break;
14863        }
14864    }
14865
14866    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14867        return Err(invalid_feed(
14868            "verified chain does not converge on the advertised head",
14869        ));
14870    }
14871    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14872    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14873    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14874    save_canonical_pin_and_alias(
14875        cfg,
14876        &trust_directory,
14877        brain,
14878        &resolved_brain,
14879        TrustState {
14880            v: 2,
14881            origin: normalized_origin(&cfg.hub)?,
14882            requested: resolved_brain.clone(),
14883            brain: resolved_brain.clone(),
14884            home: None,
14885            anchor: anchor.clone(),
14886            current: format!("ed25519:{}", identity.fingerprint),
14887            head_seq: seq,
14888            feed_hash: advertised_hash.clone(),
14889            rotations: identity.rotations.clone(),
14890            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14891            protocol_profile: pinned
14892                .as_ref()
14893                .and_then(|state| state.protocol_profile.clone()),
14894        },
14895        alias_binding.as_ref(),
14896    )?;
14897    Ok(VerifiedRemote {
14898        head: Head {
14899            brain: resolved_brain,
14900            seq,
14901            updated_at,
14902            feed_hash: advertised_hash,
14903            verified: true,
14904        },
14905        identity: Some(identity),
14906        head_entry,
14907        entries: all_entries,
14908        anchor: Some(anchor),
14909    })
14910}
14911
14912/// Read and locally verify the brain's current signed feed head. Identity
14913/// rotation is accepted only through an old-key-signed chain rooted at the
14914/// local TOFU anchor; sequence and hash checkpoints reject rollback and
14915/// equivocation across invocations.
14916pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14917    if let Some(verified) = v2_verified_head(cfg, brain)? {
14918        let observation = Head {
14919            brain: verified.brain_id.clone(),
14920            seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14921            updated_at: verified
14922                .pointer
14923                .as_ref()
14924                .map(|pointer| pointer.signed_at.clone()),
14925            feed_hash: verified
14926                .pointer
14927                .as_ref()
14928                .map(|pointer| pointer.feed_hash.clone()),
14929            verified: true,
14930        };
14931        accept_v2_head(cfg, &verified)?;
14932        return Ok(observation);
14933    }
14934    Ok(verified_remote_head(cfg, brain, false)?.head)
14935}
14936
14937#[cfg(test)]
14938mod tests {
14939    use super::*;
14940
14941    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14942
14943    fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14944        json!({
14945            "sha256": "a".repeat(64),
14946            "bytes": 10,
14947            "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14948        })
14949    }
14950
14951    #[test]
14952    fn upload_reservations_batch_by_count_and_by_size() {
14953        // A real store's first push declares one blob per changed file, which
14954        // does not fit in one request. Every batch has to stay inside both
14955        // bounds, and concatenating them has to reproduce the original order.
14956        let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14957        let batches = batch_upload_declarations(declarations.clone());
14958
14959        assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14960        for batch in &batches {
14961            assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14962            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14963                .expect("batch serializes")
14964                .len();
14965            assert!(
14966                bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14967                "batch body {bytes} exceeds the reservation budget"
14968            );
14969        }
14970        let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14971        assert_eq!(
14972            flattened, declarations,
14973            "batching must preserve the set and order"
14974        );
14975    }
14976
14977    #[test]
14978    fn only_load_shaped_hub_answers_are_worth_asking_again() {
14979        // A migration opens dozens of staging windows, so a busy hub's 429 or
14980        // 5xx must not end the push — while a refusal that states something
14981        // about the request itself has to surface immediately rather than
14982        // being repeated six times behind a minute of waiting.
14983        for status in [408, 429, 500, 502, 503, 504] {
14984            assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14985        }
14986        for status in [400, 401, 403, 404, 409, 413, 422] {
14987            assert!(
14988                !is_retryable_hub_status(status),
14989                "{status} states something about the request"
14990            );
14991        }
14992        // The waits must reach past a whole limiter window to be worth taking.
14993        let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14994        assert!(total >= 60_000, "backoff totals only {total}ms");
14995    }
14996
14997    #[test]
14998    fn a_batch_shares_a_connection_only_within_one_authority() {
14999        // Sharing a pooled agent is what makes a migration stage in minutes,
15000        // but it must never widen where bytes can go: a batch that spans two
15001        // authorities, or names an unsafe URL, falls back to per-object agents
15002        // rather than reusing a connection pinned to somewhere else.
15003        let cfg = HubConfig {
15004            hub: "https://www.sevrahq.com".to_string(),
15005            key: Some("k".to_string()),
15006            agent_key: None,
15007            brain_key: None,
15008            state_dir: PathBuf::from("."),
15009            store_selected: false,
15010        };
15011        assert!(shared_staging_agent(&cfg, &[]).is_none());
15012        assert!(
15013            shared_staging_agent(
15014                &cfg,
15015                &[
15016                    "https://one.example.com/a?sig=1",
15017                    "https://two.example.com/b?sig=2",
15018                ]
15019            )
15020            .is_none(),
15021            "two authorities must not share a pinned pool"
15022        );
15023        assert!(
15024            shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
15025            "an unsafe object-store URL must not produce an agent"
15026        );
15027        assert!(
15028            shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
15029            "credentials in the URL must not produce an agent"
15030        );
15031    }
15032
15033    #[test]
15034    fn a_staged_change_states_only_operations_and_blobs() {
15035        // The hub refuses a manifest with any other key, so the two sides have
15036        // to agree exactly. A drifting extra field would fail a whole migration
15037        // at the commit, after every byte had already been staged.
15038        let operations = vec![json!({
15039            "op": "put",
15040            "path": "records/a.md",
15041            "blob": "a".repeat(64),
15042            "bytes": 3,
15043        })];
15044        let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
15045        let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
15046        let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
15047        let keys: Vec<&str> = parsed
15048            .as_object()
15049            .expect("manifest is an object")
15050            .keys()
15051            .map(String::as_str)
15052            .collect();
15053        assert_eq!(keys, ["blobs", "operations"]);
15054        assert_eq!(parsed["operations"], Value::Array(operations));
15055        assert_eq!(parsed["blobs"], blobs);
15056    }
15057
15058    #[test]
15059    fn a_staged_push_signs_the_change_not_the_transport() {
15060        // Self-custody signs a request hash the hub recomputes from the parsed
15061        // change. A staged body no longer states its operations, so the signed
15062        // view has to restore them or every large self-custodied push would
15063        // sign a different request than the one the hub validated.
15064        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15065        let staged = json!({
15066            "mutation_id": "dbmd-1",
15067            "rebase": "strict",
15068            "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
15069        });
15070        let view = v2_signed_request_view(&staged, &operations);
15071        assert_eq!(view["operations"], Value::Array(operations.clone()));
15072        assert!(view.get("staged_change").is_none());
15073        assert_eq!(view["mutation_id"], staged["mutation_id"]);
15074
15075        let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
15076        assert_eq!(v2_signed_request_view(&inline, &[]), inline);
15077    }
15078
15079    #[test]
15080    fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
15081        let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
15082        let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
15083            .expect_err("an oversized change must not be staged");
15084        assert!(
15085            matches!(error, LinkError::PushTooLarge { .. }),
15086            "expected a size refusal, got {error:?}"
15087        );
15088    }
15089
15090    #[test]
15091    fn a_push_that_fits_the_request_is_left_inline() {
15092        // The staged lane costs a reservation and a round trip, so it must
15093        // engage only when the body genuinely does not fit. The hub here is a
15094        // closed port: reaching for it at all would fail this test.
15095        let cfg = HubConfig {
15096            hub: "http://127.0.0.1:9".to_string(),
15097            key: Some("k".to_string()),
15098            agent_key: None,
15099            brain_key: None,
15100            state_dir: PathBuf::from("."),
15101            store_selected: false,
15102        };
15103        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15104        let mut body = json!({
15105            "mutation_id": "dbmd-1",
15106            "operations": operations,
15107            "blobs": [],
15108        });
15109        stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
15110        assert!(body.get("staged_change").is_none());
15111        assert_eq!(body["operations"], Value::Array(operations));
15112    }
15113
15114    #[test]
15115    fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
15116        // Byte budget, not just count. One hash can carry many coordinates
15117        // (the same bytes reachable at several paths), so a batch sized only by
15118        // count would blow the body budget on a deduplicated store.
15119        let declarations: Vec<Value> = (0..2_000)
15120            .map(|index| {
15121                json!({
15122                    "sha256": "a".repeat(64),
15123                    "bytes": 10,
15124                    "coordinates": (0..24)
15125                        .map(|slot| format!(
15126                            "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15127                        ))
15128                        .collect::<Vec<_>>(),
15129                })
15130            })
15131            .collect();
15132        let batches = batch_upload_declarations(declarations);
15133        assert!(
15134            batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15135            "wide coordinate sets must bound the batch by size"
15136        );
15137        for batch in &batches {
15138            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15139                .expect("batch serializes")
15140                .len();
15141            assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15142        }
15143    }
15144
15145    #[test]
15146    fn a_small_push_still_rides_exactly_one_request() {
15147        let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
15148        assert_eq!(batch_upload_declarations(declarations).len(), 1);
15149        assert!(batch_upload_declarations(Vec::new()).is_empty());
15150    }
15151
15152    #[test]
15153    fn exact_source_move_becomes_one_provenance_preserving_rename() {
15154        let hash = "a".repeat(64);
15155        let operations = vec![
15156            json!({
15157                "op": "put",
15158                "path": "sources/curated/item.md",
15159                "expected": { "kind": "absent" },
15160                "blob": hash,
15161                "bytes": 19,
15162            }),
15163            json!({
15164                "op": "delete",
15165                "path": "sources/inbox/item.md",
15166                "expected": { "kind": "blob", "hash": hash },
15167            }),
15168        ];
15169
15170        assert_eq!(
15171            infer_exact_source_promotions(operations),
15172            vec![json!({
15173                "op": "rename",
15174                "from": "sources/inbox/item.md",
15175                "to": "sources/curated/item.md",
15176                "expected_from": { "kind": "blob", "hash": hash },
15177                "expected_to": { "kind": "absent" },
15178                "blob": hash,
15179                "bytes": 19,
15180            })]
15181        );
15182    }
15183
15184    #[test]
15185    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
15186        let hash = "b".repeat(64);
15187        let operations = vec![
15188            json!({
15189                "op": "delete",
15190                "path": "sources/inbox/a.md",
15191                "expected": { "kind": "blob", "hash": hash },
15192            }),
15193            json!({
15194                "op": "delete",
15195                "path": "sources/inbox/b.md",
15196                "expected": { "kind": "blob", "hash": hash },
15197            }),
15198            json!({
15199                "op": "put",
15200                "path": "sources/curated/item.md",
15201                "expected": { "kind": "absent" },
15202                "blob": hash,
15203                "bytes": 19,
15204            }),
15205        ];
15206
15207        assert_eq!(
15208            infer_exact_source_promotions(operations.clone()),
15209            operations,
15210            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
15211        );
15212    }
15213
15214    #[test]
15215    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
15216        let hash = "c".repeat(64);
15217        let mut candidate = std::collections::BTreeMap::from([(
15218            "sources/inbox/item.md".to_string(),
15219            V2BaselineFile {
15220                sha256: hash.clone(),
15221                bytes: 19,
15222                proof: None,
15223            },
15224        )]);
15225        let mut candidate_assets = std::collections::BTreeMap::new();
15226        let operations = vec![
15227            json!({
15228                "op": "rename",
15229                "from": "sources/inbox/item.md",
15230                "to": "sources/curated/item.md",
15231                "expected_from": { "kind": "blob", "hash": hash },
15232                "expected_to": { "kind": "absent" },
15233                "blob": hash,
15234                "bytes": 19,
15235            }),
15236            json!({
15237                "op": "put",
15238                "path": "records/rsvps/item.md",
15239                "expected": { "kind": "absent" },
15240                "blob": "d".repeat(64),
15241                "bytes": 23,
15242            }),
15243        ];
15244
15245        assert!(!apply_generated_v2_operations(
15246            &operations,
15247            &std::collections::BTreeMap::new(),
15248            &mut candidate,
15249            &mut candidate_assets,
15250        )
15251        .unwrap());
15252        assert!(!candidate.contains_key("sources/inbox/item.md"));
15253        assert_eq!(
15254            candidate
15255                .get("sources/curated/item.md")
15256                .map(|file| (&file.sha256, file.bytes)),
15257            Some((&hash, 19))
15258        );
15259        assert_eq!(
15260            candidate
15261                .get("records/rsvps/item.md")
15262                .map(|file| (file.sha256.as_str(), file.bytes)),
15263            Some((
15264                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
15265                23
15266            ))
15267        );
15268    }
15269
15270    fn merge_fixture(
15271        base: Option<&str>,
15272        remote: Option<&str>,
15273        local: Option<&str>,
15274        keep_local: bool,
15275    ) -> V2PulledMerge<String> {
15276        let map = |value: Option<&str>| {
15277            value
15278                .map(|value| [("records/a.md".to_string(), value.to_string())])
15279                .into_iter()
15280                .flatten()
15281                .collect::<std::collections::BTreeMap<_, _>>()
15282        };
15283        merge_v2_pulled_records(
15284            &map(base),
15285            &map(remote),
15286            &map(local),
15287            |value, _| value.clone(),
15288            |value, _| value.clone(),
15289            |_| keep_local,
15290        )
15291    }
15292
15293    #[test]
15294    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
15295        let path = "records/a.md".to_string();
15296
15297        let local_add = merge_fixture(None, None, Some("local"), false);
15298        assert_eq!(
15299            local_add.records.get(&path).map(String::as_str),
15300            Some("local")
15301        );
15302        assert!(local_add.accept_remote.is_empty());
15303        assert!(local_add.conflicts.is_empty());
15304
15305        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
15306        assert_eq!(
15307            local_edit.records.get(&path).map(String::as_str),
15308            Some("local")
15309        );
15310        assert!(local_edit.accept_remote.is_empty());
15311        assert!(local_edit.conflicts.is_empty());
15312
15313        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
15314        assert!(!local_delete.records.contains_key(&path));
15315        assert!(local_delete.accept_remote.is_empty());
15316        assert!(local_delete.conflicts.is_empty());
15317
15318        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
15319        assert_eq!(
15320            remote_edit.records.get(&path).map(String::as_str),
15321            Some("remote")
15322        );
15323        assert!(remote_edit.accept_remote.contains(&path));
15324        assert!(remote_edit.conflicts.is_empty());
15325
15326        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
15327        assert!(!remote_delete.records.contains_key(&path));
15328        assert!(remote_delete.accept_remote.contains(&path));
15329        assert!(remote_delete.conflicts.is_empty());
15330
15331        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
15332        assert_eq!(
15333            same_edit.records.get(&path).map(String::as_str),
15334            Some("same")
15335        );
15336        assert!(same_edit.accept_remote.contains(&path));
15337        assert!(same_edit.conflicts.is_empty());
15338
15339        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
15340        assert_eq!(conflict.conflicts, vec![path.clone()]);
15341        assert_eq!(
15342            conflict.records.get(&path).map(String::as_str),
15343            Some("local")
15344        );
15345        assert!(conflict.accept_remote.is_empty());
15346
15347        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
15348        assert_eq!(
15349            kept_home.records.get(&path).map(String::as_str),
15350            Some("local")
15351        );
15352        assert!(kept_home.accept_remote.is_empty());
15353        assert!(kept_home.conflicts.is_empty());
15354    }
15355
15356    #[test]
15357    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
15358        let path = "sources/report.pdf";
15359        let record = crate::AssetRecord {
15360            path: path.to_string(),
15361            sha256: "a".repeat(64),
15362            bytes: 42,
15363            media_type: "application/pdf".to_string(),
15364            wrappers: vec!["gzip".to_string()],
15365            required: true,
15366        };
15367        let mut remote = V2BaselineAsset {
15368            blob_sha256: record.sha256.clone(),
15369            bytes: record.bytes,
15370            media_type: record.media_type.clone(),
15371            wrappers: record.wrappers.clone(),
15372            required: record.required,
15373            disposition: "withheld".to_string(),
15374            leaf_hash: "b".repeat(64),
15375        };
15376
15377        assert!(v2_asset_resumes_hosting(
15378            Some(&remote),
15379            path,
15380            &record,
15381            "hosted"
15382        ));
15383        assert!(!v2_asset_resumes_hosting(
15384            Some(&remote),
15385            path,
15386            &record,
15387            "withheld"
15388        ));
15389
15390        remote.disposition = "hosted".to_string();
15391        assert!(!v2_asset_resumes_hosting(
15392            Some(&remote),
15393            path,
15394            &record,
15395            "hosted"
15396        ));
15397
15398        remote.disposition = "withheld".to_string();
15399        remote.blob_sha256 = "c".repeat(64);
15400        assert!(!v2_asset_resumes_hosting(
15401            Some(&remote),
15402            path,
15403            &record,
15404            "hosted"
15405        ));
15406        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
15407    }
15408
15409    #[test]
15410    fn v2_fresh_clone_preserves_only_exact_inherited_withheld_asset_absence() {
15411        let path = "sources/report.pdf";
15412        let record = crate::AssetRecord {
15413            path: path.to_string(),
15414            sha256: "a".repeat(64),
15415            bytes: 42,
15416            media_type: "application/pdf".to_string(),
15417            wrappers: vec!["records/report.md".to_string()],
15418            required: true,
15419        };
15420        let mut base = V2BaselineAsset {
15421            blob_sha256: record.sha256.clone(),
15422            bytes: record.bytes,
15423            media_type: record.media_type.clone(),
15424            wrappers: record.wrappers.clone(),
15425            required: record.required,
15426            disposition: "withheld".to_string(),
15427            leaf_hash: "b".repeat(64),
15428        };
15429
15430        assert!(v2_asset_inherits_withheld_absence(
15431            Some(&base),
15432            Some(&record),
15433            Some(&record),
15434            false,
15435        ));
15436        assert!(!v2_asset_inherits_withheld_absence(
15437            Some(&base),
15438            Some(&record),
15439            Some(&record),
15440            true,
15441        ));
15442
15443        base.disposition = "hosted".to_string();
15444        assert!(!v2_asset_inherits_withheld_absence(
15445            Some(&base),
15446            Some(&record),
15447            Some(&record),
15448            false,
15449        ));
15450
15451        base.disposition = "withheld".to_string();
15452        let mut changed = record.clone();
15453        changed.bytes += 1;
15454        assert!(!v2_asset_inherits_withheld_absence(
15455            Some(&base),
15456            Some(&record),
15457            Some(&changed),
15458            false,
15459        ));
15460        assert!(!v2_asset_inherits_withheld_absence(
15461            None,
15462            None,
15463            Some(&record),
15464            false,
15465        ));
15466    }
15467
15468    #[test]
15469    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
15470        let path = "records/team/alpha.md".to_string();
15471        let deleted_path = "records/team/deleted.md".to_string();
15472        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
15473            sha256,
15474            bytes,
15475            file: None,
15476        };
15477        let files = vec![
15478            V2ConflictFile {
15479                path: path.clone(),
15480                base: coordinate(None, None),
15481                local: coordinate(Some("b".repeat(64)), Some(7)),
15482                remote: coordinate(Some("a".repeat(64)), Some(5)),
15483            },
15484            V2ConflictFile {
15485                path: deleted_path.clone(),
15486                base: coordinate(Some("c".repeat(64)), Some(9)),
15487                local: coordinate(Some("d".repeat(64)), Some(11)),
15488                remote: coordinate(None, None),
15489            },
15490        ];
15491        let proven = V2BaselineFile {
15492            sha256: "a".repeat(64),
15493            bytes: 5,
15494            proof: None,
15495        };
15496        let current = [(path.clone(), proven.clone())]
15497            .into_iter()
15498            .collect::<std::collections::BTreeMap<_, _>>();
15499
15500        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
15501        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15502        assert_eq!(deleted, vec![deleted_path.clone()]);
15503
15504        let changed = [(
15505            path.clone(),
15506            V2BaselineFile {
15507                sha256: "e".repeat(64),
15508                bytes: 5,
15509                proof: None,
15510            },
15511        )]
15512        .into_iter()
15513        .collect::<std::collections::BTreeMap<_, _>>();
15514        assert!(v2_take_remote_selection(&files, &changed).is_err());
15515
15516        let resurrected = [
15517            (path, proven),
15518            (
15519                deleted_path,
15520                V2BaselineFile {
15521                    sha256: "f".repeat(64),
15522                    bytes: 13,
15523                    proof: None,
15524                },
15525            ),
15526        ]
15527        .into_iter()
15528        .collect::<std::collections::BTreeMap<_, _>>();
15529        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15530    }
15531
15532    #[cfg(target_os = "linux")]
15533    #[test]
15534    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15535        use std::os::fd::AsRawFd as _;
15536
15537        let sandbox = tempfile::TempDir::new().unwrap();
15538        let parent = std::fs::File::open(sandbox.path()).unwrap();
15539        let stage = std::ffi::CString::new("stage").unwrap();
15540        let destination = std::ffi::CString::new("brain").unwrap();
15541
15542        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15543        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15544        install_stage_at(
15545            parent.as_raw_fd(),
15546            stage.as_c_str(),
15547            destination.as_c_str(),
15548            false,
15549        )
15550        .unwrap();
15551        assert!(!sandbox.path().join("stage").exists());
15552        assert_eq!(
15553            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15554            b"created"
15555        );
15556
15557        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15558        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15559        install_stage_at(
15560            parent.as_raw_fd(),
15561            stage.as_c_str(),
15562            destination.as_c_str(),
15563            true,
15564        )
15565        .unwrap();
15566        assert_eq!(
15567            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15568            b"replacement"
15569        );
15570        assert_eq!(
15571            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15572            b"created",
15573            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15574        );
15575    }
15576
15577    struct SignedRemoteFixture {
15578        card: String,
15579        feed: String,
15580        key: AgentSigningKey,
15581        identity: FeedIdentity,
15582    }
15583
15584    fn signed_remote_fixture() -> SignedRemoteFixture {
15585        let rng = ring::rand::SystemRandom::new();
15586        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15587        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15588        let (public_key, multikey) = public_identity_for(&pair);
15589        let identity = FeedIdentity {
15590            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15591            public_key_spki: public_key.clone(),
15592            previous: Vec::new(),
15593            rotations: Vec::new(),
15594        };
15595        let mut entry = FeedEntry {
15596            v: 1,
15597            seq: 1,
15598            ts: "2026-07-30T12:00:00.000Z".to_string(),
15599            brain: multikey.clone(),
15600            public_key: public_key.clone(),
15601            kind: "push".to_string(),
15602            op: "snapshot".to_string(),
15603            pack_sha256: "a".repeat(64),
15604            files: Vec::new(),
15605            removed: Vec::new(),
15606            prev_entry_hash: None,
15607            sig: String::new(),
15608        };
15609        let unsigned = UnsignedFeedEntry {
15610            v: entry.v,
15611            seq: entry.seq,
15612            ts: &entry.ts,
15613            brain: &entry.brain,
15614            public_key: &entry.public_key,
15615            kind: &entry.kind,
15616            op: &entry.op,
15617            pack_sha256: &entry.pack_sha256,
15618            files: &entry.files,
15619            removed: &entry.removed,
15620            prev_entry_hash: &entry.prev_entry_hash,
15621        };
15622        entry.sig =
15623            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15624        let mut exact = serde_json::to_vec(&entry).unwrap();
15625        exact.push(b'\n');
15626        let hash = content_sha256(&exact);
15627        let card = json!({
15628            "id": TEST_BRAIN_ID,
15629            "headSeq": 1,
15630            "feedHash": hash,
15631            "identity": identity.clone(),
15632        })
15633        .to_string();
15634        let feed = json!({
15635            "headSeq": 1,
15636            "feedHash": hash,
15637            "identity": identity.clone(),
15638            "entries": [{"hash": hash, "entry": entry}],
15639            "scopeLimited": false,
15640        })
15641        .to_string();
15642        SignedRemoteFixture {
15643            card,
15644            feed,
15645            key: AgentSigningKey {
15646                pkcs8: pkcs8.as_ref().to_vec(),
15647                multikey,
15648                public_key_spki: public_key,
15649            },
15650            identity,
15651        }
15652    }
15653
15654    #[test]
15655    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15656        let file = |path: &str, byte: char| FeedFile {
15657            path: path.to_string(),
15658            sha256: byte.to_string().repeat(64),
15659            bytes: 1,
15660        };
15661        let a0 = file("records/a.md", 'a');
15662        let a1 = file("records/a.md", 'b');
15663        let stable = file("records/stable.md", 'c');
15664        let added = file("records/added.md", 'd');
15665        let removed_file = file("records/removed.md", 'e');
15666        let previous = vec![a0, stable.clone(), removed_file.clone()];
15667        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15668        let removed = vec![removed_file.path.clone()];
15669
15670        assert_eq!(
15671            verify_v1_manifest_disclosure(
15672                "edit",
15673                &previous,
15674                &resulting,
15675                &[a1.clone(), added.clone()],
15676                &removed,
15677            ),
15678            Ok(())
15679        );
15680        assert_eq!(
15681            verify_v1_manifest_disclosure(
15682                "edit",
15683                &previous,
15684                &resulting,
15685                &[stable.clone(), added.clone(), a1.clone()],
15686                &removed,
15687            ),
15688            Ok(())
15689        );
15690        assert_eq!(
15691            verify_v1_manifest_disclosure(
15692                "edit",
15693                &previous,
15694                &resulting,
15695                std::slice::from_ref(&added),
15696                &removed,
15697            ),
15698            Err(V1DisclosureError::EditMissingChange)
15699        );
15700        assert_eq!(
15701            verify_v1_manifest_disclosure(
15702                "edit",
15703                &previous,
15704                &resulting,
15705                &[file("records/a.md", 'f'), added.clone()],
15706                &removed,
15707            ),
15708            Err(V1DisclosureError::EditFalseFile)
15709        );
15710        assert_eq!(
15711            verify_v1_manifest_disclosure(
15712                "edit",
15713                &previous,
15714                &resulting,
15715                &[a1.clone(), added.clone()],
15716                &[],
15717            ),
15718            Err(V1DisclosureError::RemovedMismatch)
15719        );
15720        assert_eq!(
15721            verify_v1_manifest_disclosure(
15722                "push",
15723                &previous,
15724                &resulting,
15725                &[added.clone(), stable, a1],
15726                &removed,
15727            ),
15728            Ok(())
15729        );
15730        assert_eq!(
15731            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15732            Err(V1DisclosureError::PushManifestMismatch)
15733        );
15734    }
15735
15736    #[test]
15737    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15738        let fixture = signed_remote_fixture();
15739        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15740        let item = feed["entries"][0].to_string();
15741        let oversized_page = format!(
15742            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15743            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15744                .collect::<Vec<_>>()
15745                .join(",")
15746        );
15747        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15748
15749        let oversized_identity = format!(
15750            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15751            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15752                .collect::<Vec<_>>()
15753                .join(",")
15754        );
15755        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15756
15757        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15758        let oversized_entry = format!(
15759            "{{\"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\"}}",
15760            "a".repeat(64),
15761            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15762                .collect::<Vec<_>>()
15763                .join(",")
15764        );
15765        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15766    }
15767
15768    #[test]
15769    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15770        let id = "01arz3ndektsv4rrffq69g5fav";
15771        let digest = "a".repeat(64);
15772        assert_eq!(
15773            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15774            V2BulkConfirmation {
15775                id: id.to_string(),
15776                digest,
15777            }
15778        );
15779        for invalid in [
15780            "",
15781            "01arz3ndektsv4rrffq69g5fav",
15782            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15783            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15784            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15785        ] {
15786            assert!(matches!(
15787                V2BulkConfirmation::parse(invalid),
15788                Err(LinkError::InvalidPack { .. })
15789            ));
15790        }
15791    }
15792
15793    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15794        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15795        use std::net::TcpListener;
15796
15797        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15798        let url = format!("http://{}", listener.local_addr().unwrap());
15799        let handle = std::thread::spawn(move || {
15800            for (status, body) in responses {
15801                let (stream, _) = listener.accept().unwrap();
15802                let mut reader = BufReader::new(stream);
15803                let mut line = String::new();
15804                reader.read_line(&mut line).unwrap();
15805                let mut content_length = 0usize;
15806                loop {
15807                    line.clear();
15808                    reader.read_line(&mut line).unwrap();
15809                    if line == "\r\n" || line == "\n" || line.is_empty() {
15810                        break;
15811                    }
15812                    if let Some((name, value)) = line.split_once(':') {
15813                        if name.eq_ignore_ascii_case("content-length") {
15814                            content_length = value.trim().parse().unwrap();
15815                        }
15816                    }
15817                }
15818                let mut request_body = vec![0_u8; content_length];
15819                reader.read_exact(&mut request_body).unwrap();
15820                let response = format!(
15821                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15822                    body.len()
15823                );
15824                reader.get_mut().write_all(response.as_bytes()).unwrap();
15825            }
15826        });
15827        (url, handle)
15828    }
15829
15830    fn routed_json_hub(
15831        requests: usize,
15832        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15833    ) -> (String, std::thread::JoinHandle<()>) {
15834        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15835        use std::net::TcpListener;
15836
15837        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15838        let url = format!("http://{}", listener.local_addr().unwrap());
15839        let handle = std::thread::spawn(move || {
15840            for _ in 0..requests {
15841                let (stream, _) = listener.accept().unwrap();
15842                let mut reader = BufReader::new(stream);
15843                let mut line = String::new();
15844                reader.read_line(&mut line).unwrap();
15845                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15846                let mut content_length = 0usize;
15847                loop {
15848                    line.clear();
15849                    reader.read_line(&mut line).unwrap();
15850                    if line == "\r\n" || line == "\n" || line.is_empty() {
15851                        break;
15852                    }
15853                    if let Some((name, value)) = line.split_once(':') {
15854                        if name.eq_ignore_ascii_case("content-length") {
15855                            content_length = value.trim().parse().unwrap();
15856                        }
15857                    }
15858                }
15859                let mut request_body = vec![0_u8; content_length];
15860                reader.read_exact(&mut request_body).unwrap();
15861                let (status, body) = respond(&path);
15862                let response = format!(
15863                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15864                    body.len()
15865                );
15866                reader.get_mut().write_all(response.as_bytes()).unwrap();
15867            }
15868        });
15869        (url, handle)
15870    }
15871
15872    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15873        HubConfig {
15874            hub,
15875            key: Some("test-key".to_string()),
15876            agent_key: None,
15877            brain_key: None,
15878            state_dir,
15879            store_selected: false,
15880        }
15881    }
15882
15883    #[cfg(any(unix, windows))]
15884    #[test]
15885    fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
15886        use std::sync::{Arc, Mutex};
15887
15888        let bytes = b"immutable asset bytes".to_vec();
15889        let sha256 = content_sha256(&bytes);
15890        let commit_hash = "c".repeat(64);
15891        let base_url = Arc::new(Mutex::new(String::new()));
15892        let server_base = Arc::clone(&base_url);
15893        let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
15894        let server_attempt = Arc::clone(&object_attempt);
15895        let response_bytes = bytes.clone();
15896        let response_sha = sha256.clone();
15897        let response_commit = commit_hash.clone();
15898        let (hub, server) = routed_json_hub(4, move |path| {
15899            if path.contains("/v2/assets/downloads") {
15900                let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
15901                let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
15902                return (
15903                    200,
15904                    json!({
15905                        "v": 2,
15906                        "commit": response_commit,
15907                        "downloads": [{
15908                            "path": "assets/proof.bin",
15909                            "sha256": response_sha,
15910                            "bytes": response_bytes.len(),
15911                            "url": url,
15912                            "method": "GET"
15913                        }]
15914                    })
15915                    .to_string(),
15916                );
15917            }
15918            let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
15919            if attempt == 0 {
15920                (403, "{}".to_string())
15921            } else {
15922                (200, String::from_utf8(response_bytes.clone()).unwrap())
15923            }
15924        });
15925        *base_url.lock().unwrap() = hub.clone();
15926
15927        let temp = tempfile::tempdir().unwrap();
15928        let cache = temp.path().join("cache");
15929        std::fs::create_dir(&cache).unwrap();
15930        let cfg = test_hub_config(hub, temp.path().to_path_buf());
15931        let pointer = V2PointerBody {
15932            v: 2,
15933            brain: TEST_BRAIN_ID.to_string(),
15934            seq: 1,
15935            commit_hash,
15936            feed_hash: "f".repeat(64),
15937            content_root: Some("a".repeat(64)),
15938            asset_root: Some("b".repeat(64)),
15939            materializer: "m".repeat(64),
15940            signer_epoch: 1,
15941            control_revision: "d".repeat(64),
15942            backup_preparation: "ready".to_string(),
15943            prior_pointer_hash: None,
15944            signed_at: "2026-08-23T00:00:00Z".to_string(),
15945        };
15946        let path = "assets/proof.bin".to_string();
15947        let asset = V2BaselineAsset {
15948            blob_sha256: sha256.clone(),
15949            bytes: bytes.len() as u64,
15950            media_type: "application/octet-stream".to_string(),
15951            wrappers: Vec::new(),
15952            required: true,
15953            disposition: "hosted".to_string(),
15954            leaf_hash: "e".repeat(64),
15955        };
15956
15957        let staged = stage_v2_asset_download_window(
15958            &cfg,
15959            TEST_BRAIN_ID,
15960            &pointer,
15961            &cache,
15962            &[(&path, &asset)],
15963        )
15964        .expect("a fresh authority-checked capability recovers an expired one");
15965        assert_eq!(staged.len(), 1);
15966        assert_eq!(staged[0].path, path);
15967        assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
15968        assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
15969        server.join().unwrap();
15970    }
15971
15972    #[cfg(any(unix, windows))]
15973    #[test]
15974    fn asset_capability_windows_cannot_exceed_the_worker_bound() {
15975        let temp = tempfile::tempdir().unwrap();
15976        let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
15977        let pointer = V2PointerBody {
15978            v: 2,
15979            brain: TEST_BRAIN_ID.to_string(),
15980            seq: 1,
15981            commit_hash: "c".repeat(64),
15982            feed_hash: "f".repeat(64),
15983            content_root: Some("a".repeat(64)),
15984            asset_root: Some("b".repeat(64)),
15985            materializer: "m".repeat(64),
15986            signer_epoch: 1,
15987            control_revision: "d".repeat(64),
15988            backup_preparation: "ready".to_string(),
15989            prior_pointer_hash: None,
15990            signed_at: "2026-08-23T00:00:00Z".to_string(),
15991        };
15992        let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
15993            .map(|index| format!("assets/{index}.bin"))
15994            .collect::<Vec<_>>();
15995        let assets = paths
15996            .iter()
15997            .map(|_| V2BaselineAsset {
15998                blob_sha256: "a".repeat(64),
15999                bytes: 1,
16000                media_type: "application/octet-stream".to_string(),
16001                wrappers: Vec::new(),
16002                required: true,
16003                disposition: "hosted".to_string(),
16004                leaf_hash: "b".repeat(64),
16005            })
16006            .collect::<Vec<_>>();
16007        let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
16008
16009        let error =
16010            stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
16011                .expect_err("an oversized window must fail before any network request");
16012        assert!(matches!(error, LinkError::InvalidFeed { .. }));
16013    }
16014
16015    #[test]
16016    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
16017        use ring::signature::KeyPair as _;
16018
16019        let rng = ring::rand::SystemRandom::new();
16020        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16021        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16022        let (spki, multikey) = public_identity_for(&pair);
16023        let key = AgentSigningKey {
16024            pkcs8: pkcs8.as_ref().to_vec(),
16025            multikey,
16026            public_key_spki: spki,
16027        };
16028        let header = linkmd_sig_header(
16029            &key,
16030            "https://hub-a.example",
16031            "post",
16032            "/api/hub/brains/brain/push?mode=exact",
16033            Some("{\"ok\":true}"),
16034        )
16035        .unwrap();
16036        assert!(header.starts_with("LinkMD-Sig v2,"));
16037        let ts = header
16038            .split(",ts=")
16039            .nth(1)
16040            .unwrap()
16041            .split(',')
16042            .next()
16043            .unwrap();
16044        let signature = URL_SAFE_NO_PAD
16045            .decode(header.rsplit(",sig=").next().unwrap())
16046            .unwrap();
16047        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
16048        let accepted = format!(
16049            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16050        );
16051        let replayed = format!(
16052            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16053        );
16054        let public = pair.public_key().as_ref();
16055        assert!(UnparsedPublicKey::new(&ED25519, public)
16056            .verify(accepted.as_bytes(), &signature)
16057            .is_ok());
16058        assert!(
16059            UnparsedPublicKey::new(&ED25519, public)
16060                .verify(replayed.as_bytes(), &signature)
16061                .is_err(),
16062            "a proof captured at hub A must not authenticate at hub B"
16063        );
16064    }
16065
16066    #[test]
16067    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
16068        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16069        let card = json!({
16070            "id": other,
16071            "headSeq": 0,
16072            "identity": signed_remote_fixture().identity,
16073        })
16074        .to_string();
16075        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16076        let state = tempfile::tempdir().unwrap();
16077        let cfg = test_hub_config(hub, state.path().to_path_buf());
16078        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16079        assert!(
16080            error.contains("differs from the explicitly requested"),
16081            "{error}"
16082        );
16083        server.join().unwrap();
16084    }
16085
16086    #[test]
16087    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
16088        let first = signed_remote_fixture().identity;
16089        let second = signed_remote_fixture().identity;
16090        let card = |identity: FeedIdentity| {
16091            json!({
16092                "id": TEST_BRAIN_ID,
16093                "headSeq": 0,
16094                "identity": identity,
16095            })
16096            .to_string()
16097        };
16098        let (hub, server) = scripted_json_hub(vec![
16099            (404, "{}".to_string()),
16100            (200, card(first)),
16101            (404, "{}".to_string()),
16102            (200, card(second)),
16103        ]);
16104        let state = tempfile::tempdir().unwrap();
16105        let cfg = test_hub_config(hub, state.path().to_path_buf());
16106        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16107        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16108        assert!(
16109            error.contains("pinned anchor") || error.contains("forked away"),
16110            "{error}"
16111        );
16112        server.join().unwrap();
16113    }
16114
16115    #[test]
16116    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
16117        let old = signed_remote_fixture();
16118        let new = signed_remote_fixture();
16119        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
16120        let unsigned = serde_json::to_string(&UnsignedRotation {
16121            v: 1,
16122            op: "rotate",
16123            brain: &old.key.multikey,
16124            public_key: &old.key.public_key_spki,
16125            new_brain: &new.key.multikey,
16126            new_public_key: &new.key.public_key_spki,
16127            prior_head_seq: 1,
16128            prior_feed_hash: Some(&"a".repeat(64)),
16129            ts: "2026-07-30T12:00:00.000Z".to_string(),
16130        })
16131        .unwrap();
16132        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
16133        let rotation = format!(
16134            "{},\"sig\":\"{}\"}}",
16135            &unsigned[..unsigned.len() - 1],
16136            signature
16137        );
16138        let identity = FeedIdentity {
16139            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
16140            public_key_spki: new.key.public_key_spki,
16141            previous: vec![PreviousIdentity {
16142                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
16143                public_key_spki: old.key.public_key_spki,
16144            }],
16145            rotations: vec![rotation],
16146        };
16147        let card = json!({
16148            "id": TEST_BRAIN_ID,
16149            "headSeq": 0,
16150            "feedHash": null,
16151            "identity": identity,
16152        })
16153        .to_string();
16154        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16155        let state = tempfile::tempdir().unwrap();
16156        let cfg = test_hub_config(hub, state.path().to_path_buf());
16157        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16158        assert!(
16159            error.contains("rotation claims a feed boundary beyond the advertised head"),
16160            "{error}"
16161        );
16162        assert!(
16163            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
16164            "an inconsistent empty-head identity must not become the TOFU checkpoint"
16165        );
16166        server.join().unwrap();
16167    }
16168
16169    #[test]
16170    fn trust_checkpoint_rejects_a_later_fork() {
16171        let fixture = signed_remote_fixture();
16172        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
16173        fork["feedHash"] = Value::String("b".repeat(64));
16174        let (hub, server) = scripted_json_hub(vec![
16175            (404, "{}".to_string()),
16176            (200, fixture.card),
16177            (200, fixture.feed),
16178            (404, "{}".to_string()),
16179            (200, fork.to_string()),
16180        ]);
16181        let state = tempfile::tempdir().unwrap();
16182        let cfg = test_hub_config(hub, state.path().to_path_buf());
16183        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16184        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
16185        server.join().unwrap();
16186    }
16187
16188    #[test]
16189    fn alias_and_canonical_id_share_one_identity_checkpoint() {
16190        let trusted = signed_remote_fixture();
16191        let attacker = signed_remote_fixture();
16192        let (hub, server) = scripted_json_hub(vec![
16193            (404, "{}".to_string()),
16194            (200, trusted.card),
16195            (200, trusted.feed),
16196            (404, "{}".to_string()),
16197            (200, attacker.card),
16198        ]);
16199        let state = tempfile::tempdir().unwrap();
16200        let cfg = test_hub_config(hub, state.path().to_path_buf());
16201        assert!(head(&cfg, "trusted-slug").unwrap().verified);
16202        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16203        assert!(
16204            error.contains("equivocation")
16205                || error.contains("pinned")
16206                || error.contains("identity"),
16207            "{error}"
16208        );
16209        server.join().unwrap();
16210    }
16211
16212    #[test]
16213    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
16214        let state = tempfile::tempdir().unwrap();
16215        let cfg = test_hub_config(
16216            "https://hub.example".to_string(),
16217            state.path().to_path_buf(),
16218        );
16219        let directory = open_trust_dir(&cfg).unwrap();
16220        let old = TEST_BRAIN_ID;
16221        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16222        save_alias_in(
16223            &cfg,
16224            &directory,
16225            &AliasBinding {
16226                v: 1,
16227                origin: normalized_origin(&cfg.hub).unwrap(),
16228                requested: "company-brain".to_string(),
16229                brain: old.to_string(),
16230                home: Some("company-brain".to_string()),
16231            },
16232        )
16233        .unwrap();
16234
16235        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
16236        assert!(matches!(
16237            error,
16238            LinkError::AliasRebindRequired {
16239                alias,
16240                from,
16241                to
16242            } if alias == "company-brain" && from == old && to == new
16243        ));
16244        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
16245            .unwrap()
16246            .unwrap();
16247        assert_eq!(unchanged.brain, old);
16248        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
16249    }
16250
16251    #[test]
16252    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
16253        let alpha = signed_remote_fixture();
16254        let beta = signed_remote_fixture();
16255        let alpha_card = alpha.card.clone();
16256        let alpha_feed = alpha.feed.clone();
16257        let beta_card = beta.card.clone();
16258        let beta_feed = beta.feed.clone();
16259        let (hub, server) = routed_json_hub(5, move |path| {
16260            if path.ends_with("/v2/head") {
16261                (404, "{}".to_string())
16262            } else if path.contains("/alpha/feed?") {
16263                (200, alpha_feed.clone())
16264            } else if path.contains("/beta/feed?") {
16265                (200, beta_feed.clone())
16266            } else if path.ends_with("/alpha") {
16267                (200, alpha_card.clone())
16268            } else if path.ends_with("/beta") {
16269                (200, beta_card.clone())
16270            } else {
16271                (500, r#"{"error":"unexpected path"}"#.to_string())
16272            }
16273        });
16274        let state = tempfile::tempdir().unwrap();
16275        let cfg = test_hub_config(hub, state.path().to_path_buf());
16276        let alpha_cfg = cfg.clone();
16277        let beta_cfg = cfg;
16278        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
16279        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
16280        let results = [first.join().unwrap(), second.join().unwrap()];
16281        assert_eq!(
16282            results.iter().filter(|result| result.is_ok()).count(),
16283            1,
16284            "only one alias identity may establish canonical TOFU: {results:?}"
16285        );
16286        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
16287        server.join().unwrap();
16288    }
16289
16290    #[cfg(unix)]
16291    #[test]
16292    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
16293        use std::os::unix::fs::symlink;
16294
16295        let fixture = signed_remote_fixture();
16296        let card = json!({
16297            "id": TEST_BRAIN_ID,
16298            "headSeq": 0,
16299            "feedHash": Value::Null,
16300            "identity": fixture.identity,
16301        })
16302        .to_string();
16303        let work = tempfile::tempdir().unwrap();
16304        let outside = tempfile::tempdir().unwrap();
16305        let state = work.path().join("state");
16306        let moved = work.path().join("state-held");
16307        let swap_state = state.clone();
16308        let swap_moved = moved.clone();
16309        let outside_path = outside.path().to_path_buf();
16310        let (hub, server) = routed_json_hub(1, move |_| {
16311            // The client has already opened state/trust before this response.
16312            std::fs::rename(&swap_state, &swap_moved).unwrap();
16313            symlink(&outside_path, &swap_state).unwrap();
16314            (200, card.clone())
16315        });
16316        let cfg = test_hub_config(hub, state);
16317
16318        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
16319        assert_eq!(verified.head.seq, 0);
16320        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
16321        assert!(std::fs::read_dir(moved.join("trust"))
16322            .unwrap()
16323            .flatten()
16324            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
16325        server.join().unwrap();
16326    }
16327
16328    #[test]
16329    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
16330        let remote = signed_remote_fixture();
16331        let unrelated = signed_remote_fixture().key;
16332        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
16333        let state = tempfile::tempdir().unwrap();
16334        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
16335        cfg.brain_key = Some(unrelated);
16336        let error = sync_push(
16337            &cfg,
16338            TEST_BRAIN_ID,
16339            &[("DB.md".to_string(), "signed local content".to_string())],
16340        )
16341        .unwrap_err()
16342        .to_string();
16343        assert!(
16344            error.contains("not the verified current brain identity"),
16345            "{error}"
16346        );
16347        server.join().unwrap();
16348    }
16349
16350    #[test]
16351    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
16352        let remote = signed_remote_fixture();
16353        let new = signed_remote_fixture().key;
16354        let state = tempfile::tempdir().unwrap();
16355        let new_file = state.path().join("new.key");
16356        std::fs::write(
16357            &new_file,
16358            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
16359        )
16360        .unwrap();
16361        #[cfg(unix)]
16362        {
16363            use std::os::unix::fs::PermissionsExt as _;
16364            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
16365        }
16366        let forged = json!({
16367            "brain": TEST_BRAIN_ID,
16368            "identity": {
16369                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
16370                "publicKeySpki": new.public_key_spki,
16371            }
16372        })
16373        .to_string();
16374        let (hub, server) = scripted_json_hub(vec![
16375            (404, "{}".to_string()),
16376            (200, remote.card.clone()),
16377            (200, remote.feed.clone()),
16378            (200, forged),
16379            (200, remote.card),
16380            (200, remote.feed),
16381        ]);
16382        let cfg = test_hub_config(hub, state.path().to_path_buf());
16383        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
16384            .unwrap_err()
16385            .to_string();
16386        assert!(
16387            error.contains("without committing the verified new identity"),
16388            "{error}"
16389        );
16390        server.join().unwrap();
16391    }
16392
16393    #[test]
16394    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
16395        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16396        let raw = format!(
16397            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16398        );
16399        let pack = build_store_pack(&[
16400            (
16401                "DB.md".to_string(),
16402                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
16403            ),
16404            ("records/clients/truth.md".to_string(), raw.clone()),
16405        ])
16406        .unwrap();
16407        let by_id = resolve_from_verified_pack(
16408            "01j5qc3v9k4ym8rwbn2tqe6f7d",
16409            &AddressTarget::Id(record_id.to_string()),
16410            pack.clone(),
16411        )
16412        .unwrap();
16413        assert_eq!(by_id["document"]["summary"], "Signed truth");
16414        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
16415        assert_eq!(
16416            by_id["document"]["contentSha"],
16417            content_sha256(raw.as_bytes())
16418        );
16419
16420        let by_path = resolve_from_verified_pack(
16421            "01j5qc3v9k4ym8rwbn2tqe6f7d",
16422            &AddressTarget::Path("records/clients/truth.md".to_string()),
16423            pack,
16424        )
16425        .unwrap();
16426        assert_eq!(by_path["document"]["id"], record_id);
16427        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
16428
16429        let wrong_id = resolve_from_verified_record_bytes(
16430            TEST_BRAIN_ID,
16431            &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
16432            "records/clients/truth.md".to_string(),
16433            raw.as_bytes().to_vec(),
16434        )
16435        .unwrap_err()
16436        .to_string();
16437        assert!(wrong_id.contains("id differs"), "{wrong_id}");
16438
16439        let wrong_path = resolve_from_verified_record_bytes(
16440            TEST_BRAIN_ID,
16441            &AddressTarget::Path("records/clients/other.md".to_string()),
16442            "records/clients/truth.md".to_string(),
16443            raw.into_bytes(),
16444        )
16445        .unwrap_err()
16446        .to_string();
16447        assert!(wrong_path.contains("path differs"), "{wrong_path}");
16448    }
16449
16450    #[test]
16451    fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
16452        let path = "records/clients/truth.md";
16453        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16454        let raw = format!(
16455            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
16456        );
16457        let sha256 = content_sha256(raw.as_bytes());
16458        let mut nonce = 0_u128;
16459        let tree = crate::linkmd_v2::build_content_tree(
16460            &[crate::linkmd_v2::ContentFile {
16461                path: path.to_string(),
16462                blob_hash: sha256.clone(),
16463                bytes: raw.len() as u64,
16464            }],
16465            None,
16466            &mut || {
16467                nonce += 1;
16468                format!("{nonce:032x}")
16469            },
16470        )
16471        .unwrap();
16472        let root = tree.root.clone().unwrap();
16473        let mut directory_root = root.clone();
16474        let mut proof = Vec::new();
16475        for component in path.split('/') {
16476            let inclusion =
16477                crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
16478            let child = match &inclusion {
16479                crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
16480                crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
16481                    panic!("fixture path must have an inclusion proof")
16482                }
16483            };
16484            proof.push(json!({
16485                "directory_root": directory_root,
16486                "component": component,
16487                "proof": inclusion,
16488            }));
16489            directory_root = child;
16490        }
16491        let commit_hash = "c".repeat(64);
16492        let pointer = V2PointerBody {
16493            v: 2,
16494            brain: TEST_BRAIN_ID.to_string(),
16495            seq: 1,
16496            commit_hash: commit_hash.clone(),
16497            feed_hash: "f".repeat(64),
16498            content_root: Some(root.clone()),
16499            asset_root: None,
16500            materializer: "dbmd-projection-v1".to_string(),
16501            signer_epoch: 1,
16502            control_revision: "d".repeat(64),
16503            backup_preparation: "e".repeat(64),
16504            prior_pointer_hash: None,
16505            signed_at: "2026-08-21T12:00:00.000Z".to_string(),
16506        };
16507        let manifest = json!({
16508            "v": 2,
16509            "commit": commit_hash,
16510            "content_root": root,
16511            "files": [{
16512                "path": path,
16513                "sha256": sha256,
16514                "bytes": raw.len(),
16515                "proof": proof,
16516            }],
16517            "next_cursor": Value::Null,
16518        })
16519        .to_string();
16520
16521        let path_manifest = manifest.clone();
16522        let (hub, server) = routed_json_hub(1, move |request| {
16523            assert_eq!(
16524                request,
16525                format!(
16526                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
16527                    "c".repeat(64)
16528                )
16529            );
16530            (200, path_manifest.clone())
16531        });
16532        let state = tempfile::tempdir().unwrap();
16533        let cfg = test_hub_config(hub, state.path().to_path_buf());
16534        let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
16535            .unwrap()
16536            .unwrap();
16537        assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
16538        assert!(by_path.proof.is_some());
16539        server.join().unwrap();
16540
16541        let (hub, server) = routed_json_hub(1, move |request| {
16542            assert_eq!(
16543                request,
16544                format!(
16545                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
16546                    "c".repeat(64)
16547                )
16548            );
16549            (404, r#"{"error":"File not found"}"#.to_string())
16550        });
16551        let state = tempfile::tempdir().unwrap();
16552        let cfg = test_hub_config(hub, state.path().to_path_buf());
16553        assert!(
16554            v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
16555                .unwrap()
16556                .is_none()
16557        );
16558        server.join().unwrap();
16559
16560        let id_manifest = manifest;
16561        let (hub, server) = routed_json_hub(1, move |request| {
16562            assert_eq!(
16563                request,
16564                format!(
16565                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
16566                    "c".repeat(64)
16567                )
16568            );
16569            (200, id_manifest.clone())
16570        });
16571        let state = tempfile::tempdir().unwrap();
16572        let cfg = test_hub_config(hub, state.path().to_path_buf());
16573        let (located_path, by_id) =
16574            v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
16575        assert_eq!(located_path, path);
16576        assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
16577        server.join().unwrap();
16578    }
16579
16580    #[test]
16581    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
16582        let unsorted = vec![
16583            ("records/a.md".to_string(), "alpha\n".to_string()),
16584            ("DB.md".to_string(), "# db\n".to_string()),
16585        ];
16586        let sorted = vec![
16587            ("DB.md".to_string(), "# db\n".to_string()),
16588            ("records/a.md".to_string(), "alpha\n".to_string()),
16589        ];
16590        let pack = build_store_pack(&unsorted).unwrap();
16591
16592        // This digest is shared with the hub implementation. It locks every
16593        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
16594        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
16595        // absence of descriptors/extras/comments/ZIP64.
16596        assert_eq!(pack.len(), 219);
16597        assert_eq!(
16598            content_sha256(&pack),
16599            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
16600        );
16601        assert_eq!(pack, build_store_pack(&sorted).unwrap());
16602        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
16603        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
16604        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
16605
16606        assert_eq!(
16607            parse_store_pack(pack).unwrap(),
16608            vec![
16609                ("DB.md".to_string(), b"# db\n".to_vec()),
16610                ("records/a.md".to_string(), b"alpha\n".to_vec()),
16611            ]
16612        );
16613    }
16614
16615    #[test]
16616    fn canonical_store_pack_validates_every_path_before_writing() {
16617        let duplicate = vec![
16618            ("DB.md".to_string(), "first".to_string()),
16619            ("DB.md".to_string(), "second".to_string()),
16620        ];
16621        assert!(build_store_pack(&duplicate)
16622            .unwrap_err()
16623            .to_string()
16624            .contains("duplicate path"));
16625        assert!(matches!(
16626            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16627            Err(LinkError::UnsafePath { .. })
16628        ));
16629    }
16630
16631    #[test]
16632    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16633        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16634        // One byte stands in for the central directory. The trailer itself is
16635        // structurally valid; the count is the sole reason for refusal.
16636        let mut bytes = vec![0_u8];
16637        let zip64_offset = bytes.len() as u64;
16638        bytes.extend_from_slice(b"PK\x06\x06");
16639        bytes.extend_from_slice(&44_u64.to_le_bytes());
16640        bytes.extend_from_slice(&[0_u8; 12]);
16641        bytes.extend_from_slice(&COUNT.to_le_bytes());
16642        bytes.extend_from_slice(&COUNT.to_le_bytes());
16643        bytes.extend_from_slice(&1_u64.to_le_bytes());
16644        bytes.extend_from_slice(&0_u64.to_le_bytes());
16645        bytes.extend_from_slice(b"PK\x06\x07");
16646        bytes.extend_from_slice(&0_u32.to_le_bytes());
16647        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16648        bytes.extend_from_slice(&1_u32.to_le_bytes());
16649        bytes.extend_from_slice(b"PK\x05\x06");
16650        bytes.extend_from_slice(&0_u16.to_le_bytes());
16651        bytes.extend_from_slice(&0_u16.to_le_bytes());
16652        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16653        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16654        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16655        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16656        bytes.extend_from_slice(&0_u16.to_le_bytes());
16657
16658        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16659            .unwrap_err()
16660            .to_string();
16661        assert!(error.contains("invalid file count"), "{error}");
16662    }
16663
16664    #[test]
16665    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16666        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16667        let mut bytes = vec![0_u8];
16668        let zip64_offset = bytes.len() as u64;
16669        bytes.extend_from_slice(b"PK\x06\x06");
16670        bytes.extend_from_slice(&44_u64.to_le_bytes());
16671        bytes.extend_from_slice(&[0_u8; 12]);
16672        bytes.extend_from_slice(&COUNT.to_le_bytes());
16673        bytes.extend_from_slice(&COUNT.to_le_bytes());
16674        bytes.extend_from_slice(&1_u64.to_le_bytes());
16675        bytes.extend_from_slice(&0_u64.to_le_bytes());
16676        bytes.extend_from_slice(b"PK\x06\x07");
16677        bytes.extend_from_slice(&0_u32.to_le_bytes());
16678        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16679        bytes.extend_from_slice(&1_u32.to_le_bytes());
16680        bytes.extend_from_slice(b"PK\x05\x06");
16681        bytes.extend_from_slice(&0_u16.to_le_bytes());
16682        bytes.extend_from_slice(&0_u16.to_le_bytes());
16683        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16684        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16685        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16686        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16687        bytes.extend_from_slice(&0_u16.to_le_bytes());
16688        // The old parser trusted this last low-count signature, while
16689        // ZipArchive fell back to the real Zip64 directory and allocated for
16690        // COUNT entries. Its central-directory offsets are deliberately fake.
16691        let fake_eocd = bytes.len() as u32;
16692        bytes.extend_from_slice(b"PK\x05\x06");
16693        bytes.extend_from_slice(&0_u16.to_le_bytes());
16694        bytes.extend_from_slice(&0_u16.to_le_bytes());
16695        bytes.extend_from_slice(&1_u16.to_le_bytes());
16696        bytes.extend_from_slice(&1_u16.to_le_bytes());
16697        bytes.extend_from_slice(&0_u32.to_le_bytes());
16698        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16699        bytes.extend_from_slice(&0_u16.to_le_bytes());
16700
16701        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16702            .unwrap_err()
16703            .to_string();
16704        assert!(error.contains("central directory"), "{error}");
16705    }
16706
16707    #[test]
16708    fn strict_http_status_handling_rejects_redirects_without_panicking() {
16709        let error = ensure_ok(
16710            HubResponse {
16711                status: 302,
16712                body: Some(json!({"redirect": "/elsewhere"})),
16713            },
16714            "mutation",
16715        )
16716        .unwrap_err();
16717        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16718
16719        let error = ensure_raw_ok(
16720            RawHubResponse {
16721                status: 302,
16722                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16723            },
16724            "feed",
16725        )
16726        .unwrap_err();
16727        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16728    }
16729
16730    #[cfg(unix)]
16731    #[test]
16732    fn collect_push_files_refuses_external_symlink_and_nested_store() {
16733        use std::os::unix::fs::symlink;
16734
16735        let root = tempfile::tempdir().unwrap();
16736        std::fs::write(
16737            root.path().join("DB.md"),
16738            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16739        )
16740        .unwrap();
16741        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16742
16743        let external = tempfile::tempdir().unwrap();
16744        let secret = external.path().join("secret.md");
16745        std::fs::write(&secret, "TOP SECRET").unwrap();
16746        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16747
16748        let store = Store::open_strict(root.path()).unwrap();
16749        let err = collect_push_files(&store).unwrap_err().to_string();
16750        assert!(err.contains("cannot push"), "{err}");
16751        assert!(
16752            !err.contains("TOP SECRET"),
16753            "external bytes must never leak"
16754        );
16755
16756        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16757        let nested = root.path().join("records/nested");
16758        std::fs::create_dir_all(&nested).unwrap();
16759        std::fs::write(
16760            nested.join("DB.md"),
16761            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16762        )
16763        .unwrap();
16764        let err = collect_push_files(&store).unwrap_err().to_string();
16765        assert!(err.contains("nested db.md store"), "{err}");
16766    }
16767
16768    #[cfg(unix)]
16769    #[test]
16770    fn remote_push_uses_opened_root_after_path_replacement() {
16771        use std::os::unix::fs::symlink;
16772
16773        let sandbox = tempfile::tempdir().unwrap();
16774        let root = sandbox.path().join("store");
16775        std::fs::create_dir_all(root.join("records/notes")).unwrap();
16776        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16777        std::fs::write(
16778            root.join("records/notes/owned.md"),
16779            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16780        )
16781        .unwrap();
16782        let store = Store::open_strict(&root).unwrap();
16783        let detached = sandbox.path().join("detached");
16784        std::fs::rename(&root, &detached).unwrap();
16785
16786        let replacement = sandbox.path().join("replacement");
16787        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16788        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16789        std::fs::write(
16790            replacement.join("records/notes/secret.md"),
16791            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16792        )
16793        .unwrap();
16794        symlink(&replacement, &root).unwrap();
16795
16796        let files = collect_push_files(&store).unwrap();
16797        let wire_text = files
16798            .iter()
16799            .map(|(path, content)| format!("{path}\n{content}"))
16800            .collect::<Vec<_>>()
16801            .join("\n");
16802        assert!(wire_text.contains("owned upload"));
16803        assert!(!wire_text.contains("replacement sentinel"));
16804        assert!(!wire_text.contains("records/notes/secret.md"));
16805
16806        let remote = signed_remote_fixture();
16807        let (hub, server) = scripted_json_hub(vec![
16808            (200, remote.card),
16809            (200, remote.feed),
16810            (200, json!({"ok": true}).to_string()),
16811        ]);
16812        let state = tempfile::tempdir().unwrap();
16813        let cfg = test_hub_config(hub, state.path().to_path_buf());
16814        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16815        assert_eq!(pushed, json!({"ok": true}));
16816        server.join().unwrap();
16817    }
16818
16819    #[test]
16820    fn signed_feed_item_verifies_identity_hash_and_signature() {
16821        use ring::rand::SystemRandom;
16822        use ring::signature::{Ed25519KeyPair, KeyPair};
16823
16824        const PREFIX: &[u8] = &[
16825            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16826        ];
16827        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16828        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16829        let mut spki = PREFIX.to_vec();
16830        spki.extend_from_slice(pair.public_key().as_ref());
16831        let public_key = URL_SAFE_NO_PAD.encode(&spki);
16832        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16833        let mut entry = FeedEntry {
16834            v: 1,
16835            seq: 1,
16836            ts: "2026-07-14T00:00:00.000Z".to_string(),
16837            brain: format!("ed25519:{fingerprint}"),
16838            public_key: public_key.clone(),
16839            kind: "push".to_string(),
16840            op: "snapshot".to_string(),
16841            pack_sha256: "a".repeat(64),
16842            files: vec![FeedFile {
16843                path: "DB.md".to_string(),
16844                sha256: "b".repeat(64),
16845                bytes: 3,
16846            }],
16847            removed: vec![],
16848            prev_entry_hash: None,
16849            sig: String::new(),
16850        };
16851        let unsigned = UnsignedFeedEntry {
16852            v: entry.v,
16853            seq: entry.seq,
16854            ts: &entry.ts,
16855            brain: &entry.brain,
16856            public_key: &entry.public_key,
16857            kind: &entry.kind,
16858            op: &entry.op,
16859            pack_sha256: &entry.pack_sha256,
16860            files: &entry.files,
16861            removed: &entry.removed,
16862            prev_entry_hash: &entry.prev_entry_hash,
16863        };
16864        entry.sig =
16865            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16866        let mut exact = serde_json::to_vec(&entry).unwrap();
16867        exact.push(b'\n');
16868        let item = FeedItem {
16869            hash: format!("{:x}", Sha256::digest(&exact)),
16870            entry,
16871        };
16872        let identity = FeedIdentity {
16873            fingerprint,
16874            public_key_spki: public_key,
16875            previous: Vec::new(),
16876            rotations: Vec::new(),
16877        };
16878        assert!(verify_feed_item(&item, &identity).is_ok());
16879        let mut tampered = item;
16880        tampered.entry.pack_sha256 = "c".repeat(64);
16881        assert!(verify_feed_item(&tampered, &identity).is_err());
16882    }
16883
16884    #[test]
16885    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16886        let rng = ring::rand::SystemRandom::new();
16887        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16888        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16889        let (spki, multikey) = public_identity_for(&pair);
16890        let identity = V2HeadIdentity {
16891            custody: "self".to_string(),
16892            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16893            public_key_spki: spki.clone(),
16894            previous: Vec::new(),
16895            rotations: Vec::new(),
16896        };
16897        let unsigned = json!({
16898            "actor_ref": "a".repeat(64),
16899            "asset_root": Value::Null,
16900            "brain": multikey,
16901            "changes_sha256": "b".repeat(64),
16902            "control_revision": "c".repeat(64),
16903            "materializer": "dbmd-projection-v1",
16904            "op": "changeset",
16905            "parent_asset_root": Value::Null,
16906            "parent_commit": Value::Null,
16907            "parent_root": Value::Null,
16908            "prev_entry_hash": Value::Null,
16909            "public_key": spki,
16910            "seq": 1,
16911            "signer_epoch": 1,
16912            "state_root": "d".repeat(64),
16913            "ts": "2026-08-19T12:00:00.000Z",
16914            "v": 2,
16915            "v1_bridge": {
16916                "feed_hash": "e".repeat(64),
16917                "head_seq": 7,
16918                "pack_sha256": "f".repeat(64),
16919            },
16920        });
16921        let sign_value = |value: Value| {
16922            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16923            let mut object = value.as_object().unwrap().clone();
16924            object.insert(
16925                "sig".to_string(),
16926                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16927            );
16928            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16929        };
16930        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16931
16932        let mut extra = unsigned.clone();
16933        extra
16934            .as_object_mut()
16935            .unwrap()
16936            .insert("future".to_string(), Value::Bool(true));
16937        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16938
16939        let mut missing = unsigned.clone();
16940        missing.as_object_mut().unwrap().remove("v1_bridge");
16941        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16942
16943        let mut invalid_bridge = unsigned;
16944        invalid_bridge.as_object_mut().unwrap().insert(
16945            "v1_bridge".to_string(),
16946            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16947        );
16948        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16949    }
16950
16951    #[test]
16952    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16953        let vector: Value = serde_json::from_str(include_str!(
16954            "../tests/vectors/linkmd-v2-commit-bridge.json"
16955        ))
16956        .unwrap();
16957        let identity_value = vector.get("identity").unwrap();
16958        let identity = V2HeadIdentity {
16959            custody: "self".to_string(),
16960            fingerprint: identity_value
16961                .get("fingerprint")
16962                .and_then(Value::as_str)
16963                .unwrap()
16964                .to_string(),
16965            public_key_spki: identity_value
16966                .get("public_key_spki")
16967                .and_then(Value::as_str)
16968                .unwrap()
16969                .to_string(),
16970            previous: Vec::new(),
16971            rotations: Vec::new(),
16972        };
16973        let private = URL_SAFE_NO_PAD
16974            .decode(
16975                identity_value
16976                    .get("private_key_pkcs8")
16977                    .and_then(Value::as_str)
16978                    .unwrap(),
16979            )
16980            .unwrap();
16981        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16982            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16983            .unwrap();
16984        let base = vector.get("body").unwrap().as_object().unwrap();
16985
16986        for item in vector.get("valid").unwrap().as_array().unwrap() {
16987            let mut body = base.clone();
16988            body.insert(
16989                "v1_bridge".to_string(),
16990                item.get("v1_bridge").unwrap().clone(),
16991            );
16992            body.insert(
16993                "sig".to_string(),
16994                item.get("signature_base64url").unwrap().clone(),
16995            );
16996            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16997            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16998            assert_eq!(
16999                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
17000                item.get("commit_hash").and_then(Value::as_str).unwrap()
17001            );
17002            assert_eq!(
17003                format!("{:x}", Sha256::digest(&signed)),
17004                item.get("feed_hash").and_then(Value::as_str).unwrap()
17005            );
17006        }
17007
17008        for item in vector.get("invalid").unwrap().as_array().unwrap() {
17009            let mut body = base.clone();
17010            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
17011                for field in remove {
17012                    body.remove(field.as_str().unwrap());
17013                }
17014            }
17015            if let Some(set) = item.get("set").and_then(Value::as_object) {
17016                for (field, value) in set {
17017                    body.insert(field.clone(), value.clone());
17018                }
17019            }
17020            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
17021            body.insert(
17022                "sig".to_string(),
17023                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17024            );
17025            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17026            assert!(
17027                verified_v2_commit_object(&signed, &identity).is_err(),
17028                "accepted invalid shared vector {}",
17029                item.get("reason").and_then(Value::as_str).unwrap()
17030            );
17031        }
17032    }
17033
17034    #[test]
17035    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
17036        let vector: Value = serde_json::from_str(include_str!(
17037            "../tests/vectors/linkmd-v2-changeset-withheld.json"
17038        ))
17039        .unwrap();
17040        assert_eq!(
17041            vector.get("profile").and_then(Value::as_str),
17042            Some("link.md-v2-changeset-withheld")
17043        );
17044        let canonical =
17045            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
17046        let expected = STANDARD
17047            .decode(
17048                vector
17049                    .get("canonical_base64")
17050                    .and_then(Value::as_str)
17051                    .unwrap(),
17052            )
17053            .unwrap();
17054        assert_eq!(canonical, expected);
17055        assert_eq!(
17056            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
17057            vector.get("domain_hash").and_then(Value::as_str).unwrap()
17058        );
17059    }
17060
17061    #[test]
17062    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
17063        let remote = signed_remote_fixture();
17064        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
17065        let legacy_item = legacy.entries.first().unwrap();
17066        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
17067        let body = json!({
17068            "actor_ref": "a".repeat(64),
17069            "asset_root": Value::Null,
17070            "brain": remote.key.multikey,
17071            "changes_sha256": "b".repeat(64),
17072            "control_revision": "c".repeat(64),
17073            "materializer": "dbmd-projection-v1",
17074            "op": "changeset",
17075            "parent_asset_root": Value::Null,
17076            "parent_commit": Value::Null,
17077            "parent_root": Value::Null,
17078            "prev_entry_hash": Value::Null,
17079            "public_key": remote.key.public_key_spki,
17080            "seq": 1,
17081            "signer_epoch": 1,
17082            "state_root": "d".repeat(64),
17083            "ts": "2026-08-19T12:00:00.000Z",
17084            "v": 2,
17085            "v1_bridge": {
17086                "feed_hash": legacy_item.hash,
17087                "head_seq": legacy_item.entry.seq,
17088                "pack_sha256": legacy_item.entry.pack_sha256,
17089            },
17090        });
17091        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
17092        let mut signed = body.as_object().unwrap().clone();
17093        signed.insert(
17094            "sig".to_string(),
17095            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17096        );
17097        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
17098        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
17099        let feed_hash = content_sha256(&raw);
17100        let pointer = V2PointerBody {
17101            v: 2,
17102            brain: TEST_BRAIN_ID.to_string(),
17103            seq: 1,
17104            commit_hash: commit_hash.clone(),
17105            feed_hash: feed_hash.clone(),
17106            content_root: Some("d".repeat(64)),
17107            asset_root: None,
17108            materializer: "dbmd-projection-v1".to_string(),
17109            signer_epoch: 1,
17110            control_revision: "c".repeat(64),
17111            backup_preparation: "e".repeat(64),
17112            prior_pointer_hash: None,
17113            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
17114        };
17115        let v2_page = json!({
17116            "v": 2,
17117            "head_seq": 1,
17118            "head_commit_hash": commit_hash,
17119            "head_feed_hash": feed_hash,
17120            "entries": [{
17121                "seq": 1,
17122                "commit_hash": pointer.commit_hash,
17123                "feed_hash": pointer.feed_hash,
17124                "bytes_base64": STANDARD.encode(&raw),
17125            }],
17126            "next_after": 1,
17127            "complete": true,
17128        })
17129        .to_string();
17130        let identity = V2HeadIdentity {
17131            custody: "self".to_string(),
17132            fingerprint: remote.identity.fingerprint.clone(),
17133            public_key_spki: remote.identity.public_key_spki.clone(),
17134            previous: Vec::new(),
17135            rotations: Vec::new(),
17136        };
17137        let checkpoint = TrustState {
17138            v: 2,
17139            origin: "unused".to_string(),
17140            requested: TEST_BRAIN_ID.to_string(),
17141            brain: TEST_BRAIN_ID.to_string(),
17142            home: None,
17143            anchor: remote.key.multikey.clone(),
17144            current: remote.key.multikey,
17145            head_seq: legacy_item.entry.seq,
17146            feed_hash: Some(legacy_item.hash.clone()),
17147            rotations: Vec::new(),
17148            hub_signer: None,
17149            protocol_profile: None,
17150        };
17151        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
17152        let state = tempfile::tempdir().unwrap();
17153        let cfg = test_hub_config(hub, state.path().to_path_buf());
17154        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
17155        server.join().unwrap();
17156
17157        let mut wrong = checkpoint;
17158        wrong.feed_hash = Some("0".repeat(64));
17159        let (hub, server) = scripted_json_hub(vec![(
17160            200,
17161            json!({
17162                "v": 2,
17163                "head_seq": 1,
17164                "head_commit_hash": pointer.commit_hash,
17165                "head_feed_hash": pointer.feed_hash,
17166                "entries": [{
17167                    "seq": 1,
17168                    "commit_hash": pointer.commit_hash,
17169                    "feed_hash": pointer.feed_hash,
17170                    "bytes_base64": STANDARD.encode(&raw),
17171                }],
17172                "next_after": 1,
17173                "complete": true,
17174            })
17175            .to_string(),
17176        )]);
17177        let state = tempfile::tempdir().unwrap();
17178        let cfg = test_hub_config(hub, state.path().to_path_buf());
17179        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
17180        server.join().unwrap();
17181    }
17182
17183    #[test]
17184    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
17185        let rng = ring::rand::SystemRandom::new();
17186        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17187        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17188        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17189        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17190        let (old_spki, old_multikey) = public_identity_for(&old);
17191        let (new_spki, new_multikey) = public_identity_for(&new);
17192        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
17193            v: 1,
17194            op: "rotate",
17195            brain: &old_multikey,
17196            public_key: &old_spki,
17197            new_brain: &new_multikey,
17198            new_public_key: &new_spki,
17199            prior_head_seq: 1,
17200            prior_feed_hash: Some(&"9".repeat(64)),
17201            ts: "2026-08-19T12:01:00.000Z".to_string(),
17202        })
17203        .unwrap();
17204        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
17205        let rotation = format!(
17206            "{},\"sig\":\"{}\"}}",
17207            &rotation_unsigned[..rotation_unsigned.len() - 1],
17208            rotation_sig
17209        );
17210        let identity = V2HeadIdentity {
17211            custody: "self".to_string(),
17212            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17213            public_key_spki: new_spki.clone(),
17214            previous: vec![V2PreviousIdentity {
17215                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17216                public_key_spki: old_spki.clone(),
17217            }],
17218            rotations: vec![rotation],
17219        };
17220        let commit = |seq: u64,
17221                      epoch: u64,
17222                      multikey: &str,
17223                      spki: &str,
17224                      pair: &ring::signature::Ed25519KeyPair| {
17225            let value = json!({
17226                "actor_ref": "a".repeat(64),
17227                "asset_root": Value::Null,
17228                "brain": multikey,
17229                "changes_sha256": "b".repeat(64),
17230                "control_revision": "c".repeat(64),
17231                "materializer": "dbmd-projection-v1",
17232                "op": "changeset",
17233                "parent_asset_root": Value::Null,
17234                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
17235                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
17236                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
17237                "public_key": spki,
17238                "seq": seq,
17239                "signer_epoch": epoch,
17240                "state_root": "1".repeat(64),
17241                "ts": "2026-08-19T12:00:00.000Z",
17242                "v": 2,
17243                "v1_bridge": Value::Null,
17244            });
17245            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17246            let mut object = value.as_object().unwrap().clone();
17247            object.insert(
17248                "sig".to_string(),
17249                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17250            );
17251            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17252        };
17253
17254        assert!(verified_v2_commit_object(
17255            &commit(1, 1, &old_multikey, &old_spki, &old),
17256            &identity,
17257        )
17258        .is_ok());
17259        assert!(verified_v2_commit_object(
17260            &commit(2, 2, &new_multikey, &new_spki, &new),
17261            &identity,
17262        )
17263        .is_ok());
17264        assert!(verified_v2_commit_object(
17265            &commit(2, 1, &old_multikey, &old_spki, &old),
17266            &identity,
17267        )
17268        .is_err());
17269        assert!(verified_v2_commit_object(
17270            &commit(1, 2, &new_multikey, &new_spki, &new),
17271            &identity,
17272        )
17273        .is_err());
17274    }
17275
17276    #[test]
17277    fn a_self_custody_entry_verifies_like_any_hub_entry() {
17278        let rng = ring::rand::SystemRandom::new();
17279        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17280        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17281        let (spki, multikey) = public_identity_for(&pair);
17282        let key = AgentSigningKey {
17283            pkcs8: pkcs8.as_ref().to_vec(),
17284            multikey: multikey.clone(),
17285            public_key_spki: spki.clone(),
17286        };
17287        let files = vec![WireFeedFile {
17288            path: "DB.md".to_string(),
17289            sha256: "a".repeat(64),
17290            bytes: 3,
17291        }];
17292        let raw = self_custody_entry(
17293            &key,
17294            1,
17295            "2026-07-23T12:00:00.000Z".to_string(),
17296            &"c".repeat(64),
17297            &files,
17298            None,
17299        )
17300        .unwrap();
17301        // The exact client serialization parses as a feed entry and passes the
17302        // SAME verifier every subscribe read runs — the self-custody path
17303        // produces first-class wire-profile-v1 entries.
17304        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
17305        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
17306        let item = FeedItem { hash, entry };
17307        let identity = FeedIdentity {
17308            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17309            public_key_spki: spki,
17310            previous: Vec::new(),
17311            rotations: Vec::new(),
17312        };
17313        assert!(verify_feed_item(&item, &identity).is_ok());
17314    }
17315
17316    #[test]
17317    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
17318        let rng = ring::rand::SystemRandom::new();
17319        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17320        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
17321        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17322        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
17323        let (old_spki, old_multikey) = public_identity_for(&old);
17324        let (new_spki, new_multikey) = public_identity_for(&new);
17325        let unsigned = serde_json::to_string(&UnsignedRotation {
17326            v: 1,
17327            op: "rotate",
17328            brain: &old_multikey,
17329            public_key: &old_spki,
17330            new_brain: &new_multikey,
17331            new_public_key: &new_spki,
17332            prior_head_seq: 1,
17333            prior_feed_hash: Some(&"a".repeat(64)),
17334            ts: "2026-07-30T12:00:00.000Z".to_string(),
17335        })
17336        .unwrap();
17337        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
17338        let rotation = format!(
17339            "{},\"sig\":\"{}\"}}",
17340            &unsigned[..unsigned.len() - 1],
17341            signature
17342        );
17343        let identity = FeedIdentity {
17344            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
17345            public_key_spki: new_spki,
17346            previous: vec![PreviousIdentity {
17347                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
17348                public_key_spki: old_spki,
17349            }],
17350            rotations: vec![rotation],
17351        };
17352        let pin = TrustState {
17353            v: 2,
17354            origin: "https://hub.example".to_string(),
17355            requested: "brain".to_string(),
17356            brain: "brain".to_string(),
17357            home: None,
17358            anchor: old_multikey.clone(),
17359            current: old_multikey.clone(),
17360            head_seq: 1,
17361            feed_hash: Some("a".repeat(64)),
17362            rotations: Vec::new(),
17363            hub_signer: None,
17364            protocol_profile: None,
17365        };
17366        assert_eq!(
17367            verify_identity_chain(&identity, Some(&pin)).unwrap(),
17368            old_multikey
17369        );
17370        let mut accepted = pin.clone();
17371        accepted.current = new_multikey.clone();
17372        accepted.rotations = identity.rotations.clone();
17373        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
17374            v: 1,
17375            op: "rotate",
17376            brain: &old_multikey,
17377            public_key: &identity.previous[0].public_key_spki,
17378            new_brain: &new_multikey,
17379            new_public_key: &identity.public_key_spki,
17380            prior_head_seq: 1,
17381            prior_feed_hash: Some(&"a".repeat(64)),
17382            ts: "2026-07-30T12:00:01.000Z".to_string(),
17383        })
17384        .unwrap();
17385        let alternate_signature =
17386            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
17387        let mut rewritten = identity.clone();
17388        rewritten.rotations[0] = format!(
17389            "{},\"sig\":\"{}\"}}",
17390            &alternate_unsigned[..alternate_unsigned.len() - 1],
17391            alternate_signature
17392        );
17393        assert!(
17394            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
17395            "an alternate valid statement must not rewrite accepted history"
17396        );
17397
17398        let mut stale_entry = FeedEntry {
17399            v: 1,
17400            seq: 2,
17401            ts: "2026-07-30T12:01:00.000Z".to_string(),
17402            brain: pin.current.clone(),
17403            public_key: identity.previous[0].public_key_spki.clone(),
17404            kind: "push".to_string(),
17405            op: "snapshot".to_string(),
17406            pack_sha256: "b".repeat(64),
17407            files: Vec::new(),
17408            removed: Vec::new(),
17409            prev_entry_hash: pin.feed_hash.clone(),
17410            sig: String::new(),
17411        };
17412        let stale_unsigned = UnsignedFeedEntry {
17413            v: stale_entry.v,
17414            seq: stale_entry.seq,
17415            ts: &stale_entry.ts,
17416            brain: &stale_entry.brain,
17417            public_key: &stale_entry.public_key,
17418            kind: &stale_entry.kind,
17419            op: &stale_entry.op,
17420            pack_sha256: &stale_entry.pack_sha256,
17421            files: &stale_entry.files,
17422            removed: &stale_entry.removed,
17423            prev_entry_hash: &stale_entry.prev_entry_hash,
17424        };
17425        stale_entry.sig = URL_SAFE_NO_PAD.encode(
17426            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
17427                .as_ref(),
17428        );
17429        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
17430        stale_exact.push(b'\n');
17431        let stale_item = FeedItem {
17432            hash: content_sha256(&stale_exact),
17433            entry: stale_entry,
17434        };
17435        assert!(
17436            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
17437                .is_err(),
17438            "a key retired before the checkpoint must never append after it"
17439        );
17440        assert!(
17441            verify_feed_item(&stale_item, &identity).is_err(),
17442            "an old key must never append after its signed rotation boundary"
17443        );
17444
17445        let mut missing = identity.clone();
17446        missing.rotations.clear();
17447        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
17448
17449        let mut tampered = identity;
17450        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
17451        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
17452    }
17453
17454    #[cfg(unix)]
17455    #[test]
17456    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
17457        use std::os::unix::fs::symlink;
17458
17459        let dir = tempfile::tempdir().unwrap();
17460        let target = dir.path().join("valuable.txt");
17461        let planted = dir.path().join("agent.key");
17462        std::fs::write(&target, "do not overwrite").unwrap();
17463        symlink(&target, &planted).unwrap();
17464
17465        assert!(matches!(
17466            generate_agent_key(&planted),
17467            Err(LinkError::BadAgentKey { .. })
17468        ));
17469        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
17470    }
17471
17472    #[cfg(unix)]
17473    #[test]
17474    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
17475        use std::os::unix::fs::symlink;
17476
17477        let root = tempfile::tempdir().unwrap();
17478        let outside = tempfile::tempdir().unwrap();
17479        symlink(outside.path(), root.path().join("redirect")).unwrap();
17480
17481        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
17482        assert!(!outside.path().join("agent.key").exists());
17483    }
17484
17485    // ── Address parsing ─────────────────────────────────────────────────────
17486
17487    #[test]
17488    fn address_bare_brain_with_and_without_sigil() {
17489        for raw in ["@acme-ops", "acme-ops"] {
17490            let a = Address::parse(raw).expect(raw);
17491            assert_eq!(a.brain, "acme-ops");
17492            assert_eq!(a.target, None);
17493        }
17494    }
17495
17496    #[test]
17497    fn address_ulid_target_parses_as_id() {
17498        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
17499        assert_eq!(a.brain, "acme");
17500        assert_eq!(
17501            a.target,
17502            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
17503        );
17504    }
17505
17506    #[test]
17507    fn address_md_path_target_parses_as_path() {
17508        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
17509        assert_eq!(
17510            a.target,
17511            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
17512        );
17513    }
17514
17515    #[test]
17516    fn address_rejects_malformed_forms() {
17517        for raw in [
17518            "",
17519            "@",
17520            "@/x",
17521            "@acme/",
17522            "@acme/../etc/passwd",
17523            "@acme/records/.hidden.md",
17524            "@ACME",             // uppercase is not a hub ref shape
17525            "@acme/notes/x.txt", // target is neither ULID nor .md path
17526            "@a b",              // whitespace
17527        ] {
17528            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
17529        }
17530    }
17531
17532    // ── Path safety ─────────────────────────────────────────────────────────
17533
17534    #[test]
17535    fn safe_paths_accept_store_shapes_and_reject_escapes() {
17536        for ok in [
17537            "DB.md",
17538            "assets.jsonl",
17539            "records/clients/lumio.md",
17540            "sources/emails/2026/07/x.md",
17541        ] {
17542            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
17543        }
17544        for bad in [
17545            "",
17546            "/etc/passwd",
17547            "../up.md",
17548            "records/../../up.md",
17549            "records//x.md",
17550            ".dbmd/config",
17551            "records/.hidden/x.md",
17552            "records/a b.md",
17553            "records\\win.md",
17554        ] {
17555            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
17556        }
17557    }
17558
17559    #[cfg(unix)]
17560    #[test]
17561    fn opened_destination_capability_survives_an_ancestor_path_swap() {
17562        use std::os::unix::fs::symlink;
17563
17564        let work = tempfile::tempdir().unwrap();
17565        let outside = tempfile::tempdir().unwrap();
17566        let original = work.path().join("destination");
17567        let moved = work.path().join("destination-moved");
17568        let directory = open_or_create_dir_nofollow(&original).unwrap();
17569
17570        std::fs::rename(&original, &moved).unwrap();
17571        symlink(outside.path(), &original).unwrap();
17572        write_pull_entries_beneath_dir(
17573            &directory,
17574            &[("records/note.md".to_string(), b"held inode".to_vec())],
17575        )
17576        .unwrap();
17577
17578        assert_eq!(
17579            std::fs::read(moved.join("records/note.md")).unwrap(),
17580            b"held inode"
17581        );
17582        assert!(!outside.path().join("records/note.md").exists());
17583    }
17584
17585    // ── Config resolution (flag + file precedence; env is covered by the CLI
17586    //    integration tests, where a child process isolates it) ───────────────
17587
17588    #[test]
17589    fn hub_config_flag_beats_file_and_requires_some_source() {
17590        let dir = tempfile::tempdir().unwrap();
17591        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
17592        std::fs::write(
17593            dir.path().join(CONFIG_REL_PATH),
17594            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
17595        )
17596        .unwrap();
17597
17598        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
17599        assert_eq!(from_flag.hub, "https://flag.example.com");
17600
17601        let from_file = hub_config(None, dir.path()).unwrap();
17602        assert_eq!(from_file.hub, "https://file.example.com");
17603
17604        let none = hub_config(None, tempfile::tempdir().unwrap().path());
17605        assert!(matches!(none, Err(LinkError::NoHub)));
17606    }
17607
17608    #[test]
17609    fn https_guard_allows_loopback_only_for_plain_http() {
17610        assert!(assert_safe_hub("https://hub.example.com").is_ok());
17611        assert!(assert_safe_hub("http://localhost:3000").is_ok());
17612        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
17613        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
17614        assert!(matches!(
17615            assert_safe_hub("http://hub.example.com"),
17616            Err(LinkError::UnsafeHub { .. })
17617        ));
17618        assert!(matches!(
17619            assert_safe_hub("hub.example.com"),
17620            Err(LinkError::UnsafeHub { .. })
17621        ));
17622        assert!(matches!(
17623            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
17624            Err(LinkError::UnsafeHub { .. })
17625        ));
17626        assert!(matches!(
17627            assert_safe_hub("https://hub.example.com@attacker.example"),
17628            Err(LinkError::UnsafeHub { .. })
17629        ));
17630        assert!(matches!(
17631            assert_safe_hub("https://hub.example.com/base"),
17632            Err(LinkError::UnsafeHub { .. })
17633        ));
17634    }
17635
17636    #[test]
17637    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17638        for blocked in [
17639            "127.0.0.1",
17640            "10.0.0.1",
17641            "100.64.0.1",
17642            "169.254.169.254",
17643            "172.16.0.1",
17644            "192.168.0.1",
17645            "192.88.99.1",
17646            "198.18.0.1",
17647            "203.0.113.1",
17648            "::1",
17649            "fe80::1",
17650            "fd00::1",
17651            "2001:db8::1",
17652            "2001:1::1",
17653            "2002:7f00:1::",
17654            "3fff::1",
17655        ] {
17656            assert!(
17657                !is_public_registry_ip(blocked.parse().unwrap()),
17658                "must block {blocked}"
17659            );
17660        }
17661        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17662        assert!(is_public_registry_ip(
17663            "2606:4700:4700::1111".parse().unwrap()
17664        ));
17665        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17666    }
17667
17668    #[test]
17669    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17670        use ureq::Resolver as _;
17671
17672        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17673        let resolver = PinnedRegistryResolver {
17674            netloc: "home.example:443".to_string(),
17675            addresses: vec![pinned],
17676        };
17677        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17678        assert!(resolver.resolve("127.0.0.1:443").is_err());
17679        assert_eq!(
17680            resolver.resolve("home.example:443").unwrap(),
17681            vec![pinned],
17682            "subsequent connects reuse the validated answer instead of DNS"
17683        );
17684    }
17685
17686    #[test]
17687    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17688        let cfg = HubConfig {
17689            hub: "https://hub.example".to_string(),
17690            key: None,
17691            agent_key: None,
17692            brain_key: None,
17693            state_dir: tempfile::tempdir().unwrap().keep(),
17694            store_selected: false,
17695        };
17696        assert!(
17697            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17698            "a production hub must not turn its presigned URL into an SSRF primitive"
17699        );
17700
17701        let store_selected = HubConfig {
17702            hub: "https://127.0.0.1".to_string(),
17703            store_selected: true,
17704            ..cfg
17705        };
17706        assert!(
17707            hub_agent(&store_selected).is_err(),
17708            "bytes in a cloned store must not select a private-network hub"
17709        );
17710    }
17711
17712    #[test]
17713    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17714        assert_eq!(
17715            one_past_bounded_limit(MAX_PACK_BYTES),
17716            Some(MAX_PACK_BYTES + 1),
17717            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17718        );
17719        assert_eq!(
17720            presigned_download_read_limit(),
17721            MAX_PACK_BYTES + 1,
17722            "the presigned reader is capped by the client constant, not a hub response"
17723        );
17724        assert_eq!(
17725            one_past_bounded_limit(u64::MAX),
17726            None,
17727            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17728        );
17729    }
17730
17731    #[test]
17732    fn https_guard_matches_the_scheme_case_insensitively() {
17733        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
17734        // hub is still HTTPS, never a misleading non-HTTPS refusal.
17735        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17736        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17737        // And an uppercase plain-HTTP hub is still refused outside loopback.
17738        assert!(matches!(
17739            assert_safe_hub("HTTP://hub.example.com"),
17740            Err(LinkError::UnsafeHub { .. })
17741        ));
17742    }
17743
17744    #[test]
17745    fn clean_key_refuses_paste_artifacts_without_echoing() {
17746        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
17747        for bad in ["vc account", "vc\naccount", "ключ", ""] {
17748            let err = clean_key(bad).unwrap_err();
17749            assert!(matches!(err, LinkError::BadKey));
17750            assert!(
17751                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17752                "error must not echo the key"
17753            );
17754        }
17755    }
17756
17757    // ── Verb entry gates: refs must never reshape the request path ──────────
17758
17759    /// A config whose hub passes the loopback guard but is never listened on:
17760    /// every refusal below must come from the entry gate BEFORE a request
17761    /// exists — a dial on this dead port would surface `Transport` instead.
17762    fn dead_hub() -> HubConfig {
17763        HubConfig {
17764            hub: "http://127.0.0.1:9".to_string(),
17765            key: Some("k".to_string()),
17766            agent_key: None,
17767            brain_key: None,
17768            state_dir: PathBuf::from("."),
17769            store_selected: false,
17770        }
17771    }
17772
17773    #[test]
17774    fn request_retries_a_connection_failure_before_sending() {
17775        use std::io::{Read as _, Write as _};
17776        use std::net::TcpListener;
17777        use std::thread;
17778        use std::time::Duration;
17779
17780        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17781        let address = probe.local_addr().unwrap();
17782        drop(probe);
17783        let server = thread::spawn(move || {
17784            thread::sleep(Duration::from_millis(40));
17785            let listener = TcpListener::bind(address).unwrap();
17786            let (mut stream, _) = listener.accept().unwrap();
17787            let mut request_bytes = [0_u8; 1024];
17788            let _ = stream.read(&mut request_bytes).unwrap();
17789            stream
17790                .write_all(
17791                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17792                )
17793                .unwrap();
17794        });
17795        let cfg = HubConfig {
17796            hub: format!("http://{address}"),
17797            key: None,
17798            agent_key: None,
17799            brain_key: None,
17800            state_dir: tempfile::tempdir().unwrap().keep(),
17801            store_selected: false,
17802        };
17803
17804        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17805        assert_eq!(response.status, 200);
17806        assert_eq!(response.body, Some(json!({ "ok": true })));
17807        server.join().unwrap();
17808    }
17809
17810    #[test]
17811    fn a_commit_goes_back_for_a_receipt_it_lost() {
17812        use std::io::{Read as _, Write as _};
17813        use std::net::TcpListener;
17814        use std::thread;
17815
17816        // The mutation coordinate is derived from the observed base and the
17817        // exact operation set so a repeat is the SAME effect. A commit that
17818        // lands durably and then loses its connection must therefore be asked
17819        // after, not reported as a failure — which is what happened to a
17820        // 32,338-file store that had already committed.
17821        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17822        let address = listener.local_addr().unwrap();
17823        let server = thread::spawn(move || {
17824            // First attempt: promise a body, hang up mid-way.
17825            let (mut first, _) = listener.accept().unwrap();
17826            let mut bytes = [0_u8; 4096];
17827            let _ = first.read(&mut bytes).unwrap();
17828            first
17829                .write_all(
17830                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17831                )
17832                .unwrap();
17833            drop(first);
17834            // Second attempt: the receipt for the commit that already landed.
17835            let (mut second, _) = listener.accept().unwrap();
17836            let _ = second.read(&mut bytes).unwrap();
17837            second
17838                .write_all(
17839                    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\"}",
17840                )
17841                .unwrap();
17842        });
17843        let cfg = HubConfig {
17844            hub: format!("http://{address}"),
17845            key: Some("k".to_string()),
17846            agent_key: None,
17847            brain_key: None,
17848            state_dir: tempfile::tempdir().unwrap().keep(),
17849            store_selected: false,
17850        };
17851
17852        let response = request_patient(
17853            &cfg,
17854            "POST",
17855            "/api/hub/brains/b/v2/commits",
17856            Some(&json!({ "mutation_id": "dbmd-1" })),
17857            Auth::Required,
17858        )
17859        .expect("the receipt is collected on the second ask");
17860        assert_eq!(response.status, 200);
17861        assert_eq!(
17862            response
17863                .body
17864                .as_ref()
17865                .and_then(|value| value.get("outcome"))
17866                .and_then(Value::as_str),
17867            Some("converged"),
17868            "an already-applied mutation answers with its receipt"
17869        );
17870        server.join().unwrap();
17871    }
17872
17873    #[test]
17874    fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
17875        use std::io::{Read as _, Write as _};
17876        use std::net::TcpListener;
17877        use std::thread;
17878
17879        // A connection that fails while the body is arriving is the same class
17880        // of problem as one that fails before it arrives at all. Typed as a
17881        // bare io error it matched no retry path and named no peer, and one
17882        // stalled body ended a whole migration reporting only "timed out
17883        // reading response".
17884        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17885        let address = listener.local_addr().unwrap();
17886        let server = thread::spawn(move || {
17887            let (mut stream, _) = listener.accept().unwrap();
17888            let mut request_bytes = [0_u8; 1024];
17889            let _ = stream.read(&mut request_bytes).unwrap();
17890            // Promise a hundred bytes, send ten, hang up.
17891            stream
17892                .write_all(
17893                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17894                )
17895                .unwrap();
17896        });
17897        let cfg = HubConfig {
17898            hub: format!("http://{address}"),
17899            key: None,
17900            agent_key: None,
17901            brain_key: None,
17902            state_dir: tempfile::tempdir().unwrap().keep(),
17903            store_selected: false,
17904        };
17905
17906        let error = request(&cfg, "POST", "/truncated", None, Auth::None)
17907            .expect_err("a truncated body must not read as success");
17908        match error {
17909            LinkError::Transport { hub, .. } => {
17910                assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17911            }
17912            other => panic!("expected a transport failure, got {other:?}"),
17913        }
17914        server.join().unwrap();
17915    }
17916
17917    #[test]
17918    fn a_safe_get_retries_when_its_body_dies_mid_stream() {
17919        use std::io::{Read as _, Write as _};
17920        use std::net::{TcpListener, TcpStream};
17921        use std::thread;
17922
17923        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17924        let address = listener.local_addr().unwrap();
17925        let server = thread::spawn(move || {
17926            let read_request = |stream: &mut TcpStream| {
17927                let mut request = Vec::new();
17928                let mut bytes = [0_u8; 1024];
17929                loop {
17930                    let read = stream.read(&mut bytes).unwrap();
17931                    if read == 0 {
17932                        break;
17933                    }
17934                    request.extend_from_slice(&bytes[..read]);
17935                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
17936                    else {
17937                        continue;
17938                    };
17939                    let headers = String::from_utf8_lossy(&request[..header_end]);
17940                    let content_length = headers
17941                        .lines()
17942                        .find_map(|line| {
17943                            let (name, value) = line.split_once(':')?;
17944                            name.eq_ignore_ascii_case("content-length")
17945                                .then(|| value.trim().parse::<usize>().ok())
17946                                .flatten()
17947                        })
17948                        .unwrap_or(0);
17949                    if request.len() >= header_end + 4 + content_length {
17950                        break;
17951                    }
17952                }
17953            };
17954            let (mut first, _) = listener.accept().unwrap();
17955            read_request(&mut first);
17956            first
17957                .write_all(
17958                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17959                )
17960                .unwrap();
17961            drop(first);
17962
17963            let (mut second, _) = listener.accept().unwrap();
17964            read_request(&mut second);
17965            second
17966                .write_all(
17967                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17968                )
17969                .unwrap();
17970        });
17971        let cfg = HubConfig {
17972            hub: format!("http://{address}"),
17973            key: None,
17974            agent_key: None,
17975            brain_key: None,
17976            state_dir: tempfile::tempdir().unwrap().keep(),
17977            store_selected: false,
17978        };
17979
17980        let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
17981            .expect("a safe read retries the interrupted body");
17982        assert_eq!(response.status, 200);
17983        assert_eq!(response.body, Some(json!({ "ok": true })));
17984        server.join().unwrap();
17985    }
17986
17987    #[test]
17988    fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
17989        use std::io::{Read as _, Write as _};
17990        use std::net::{TcpListener, TcpStream};
17991        use std::thread;
17992
17993        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17994        let address = listener.local_addr().unwrap();
17995        let server = thread::spawn(move || {
17996            let read_request = |stream: &mut TcpStream| {
17997                let mut request = Vec::new();
17998                let mut bytes = [0_u8; 1024];
17999                loop {
18000                    let read = stream.read(&mut bytes).unwrap();
18001                    if read == 0 {
18002                        break;
18003                    }
18004                    request.extend_from_slice(&bytes[..read]);
18005                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18006                    else {
18007                        continue;
18008                    };
18009                    let headers = String::from_utf8_lossy(&request[..header_end]);
18010                    let content_length = headers
18011                        .lines()
18012                        .find_map(|line| {
18013                            let (name, value) = line.split_once(':')?;
18014                            name.eq_ignore_ascii_case("content-length")
18015                                .then(|| value.trim().parse::<usize>().ok())
18016                                .flatten()
18017                        })
18018                        .unwrap_or(0);
18019                    if request.len() >= header_end + 4 + content_length {
18020                        break;
18021                    }
18022                }
18023            };
18024            let (mut first, _) = listener.accept().unwrap();
18025            read_request(&mut first);
18026            first
18027                .write_all(
18028                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18029                )
18030                .unwrap();
18031            drop(first);
18032
18033            let (mut second, _) = listener.accept().unwrap();
18034            read_request(&mut second);
18035            second
18036                .write_all(
18037                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18038                )
18039                .unwrap();
18040        });
18041        let cfg = HubConfig {
18042            hub: format!("http://{address}"),
18043            key: None,
18044            agent_key: None,
18045            brain_key: None,
18046            state_dir: tempfile::tempdir().unwrap().keep(),
18047            store_selected: false,
18048        };
18049
18050        let response = request_raw_retryable_read(
18051            &cfg,
18052            "POST",
18053            "/v2/stream",
18054            Some(&json!({ "files": ["proof"] })),
18055            Auth::None,
18056            1_024,
18057        )
18058        .expect("an explicitly safe POST retries the interrupted body");
18059        assert_eq!(response.status, 200);
18060        assert_eq!(
18061            serde_json::from_slice::<Value>(&response.body).unwrap(),
18062            json!({ "ok": true })
18063        );
18064        server.join().unwrap();
18065    }
18066
18067    #[test]
18068    fn object_store_transport_errors_never_render_presigned_urls() {
18069        use std::net::TcpListener;
18070
18071        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18072        let address = listener.local_addr().unwrap();
18073        drop(listener);
18074        let signature = "do-not-render-this-presigned-signature";
18075        let raw =
18076            format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
18077        let error = ureq::get(&raw)
18078            .timeout(std::time::Duration::from_millis(250))
18079            .call()
18080            .expect_err("the closed local port must fail");
18081        let ureq::Error::Transport(transport) = error else {
18082            panic!("expected a transport failure");
18083        };
18084
18085        let rendered = object_store_transport_error(transport).to_string();
18086        assert!(rendered.contains("the object store"));
18087        assert!(rendered.contains("network error"));
18088        assert!(!rendered.contains(&raw));
18089        assert!(!rendered.contains(signature));
18090        assert!(!rendered.contains("X-Amz-"));
18091    }
18092
18093    #[test]
18094    fn endpoint_cap_refuses_a_body_before_json_parsing() {
18095        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
18096        let cfg = HubConfig {
18097            hub,
18098            key: None,
18099            agent_key: None,
18100            brain_key: None,
18101            state_dir: tempfile::tempdir().unwrap().keep(),
18102            store_selected: false,
18103        };
18104
18105        assert!(matches!(
18106            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
18107            Err(LinkError::ResponseTooLarge { .. })
18108        ));
18109        server.join().unwrap();
18110    }
18111
18112    #[test]
18113    fn overall_deadline_stops_a_dribbled_response_body() {
18114        use std::io::{Read as _, Write as _};
18115        use std::net::TcpListener;
18116        use std::time::{Duration, Instant};
18117
18118        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18119        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
18120        let server = std::thread::spawn(move || {
18121            let (mut stream, _) = listener.accept().unwrap();
18122            let mut request = [0_u8; 1024];
18123            let _ = stream.read(&mut request);
18124            stream
18125                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
18126                .unwrap();
18127            for byte in [b'x'; 32] {
18128                if stream.write_all(&[byte]).is_err() {
18129                    break;
18130                }
18131                std::thread::sleep(Duration::from_millis(40));
18132            }
18133        });
18134        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
18135        let started = Instant::now();
18136        let response = http.get(&url).call().unwrap();
18137        let mut body = Vec::new();
18138        let error = response
18139            .into_reader()
18140            .read_to_end(&mut body)
18141            .expect_err("per-read progress must not reset the overall deadline");
18142        assert!(
18143            started.elapsed() < Duration::from_millis(700),
18144            "dribbled body exceeded the wall-clock budget: {error}"
18145        );
18146        server.join().unwrap();
18147    }
18148
18149    #[test]
18150    fn overall_deadline_stops_a_stalled_upload() {
18151        use std::net::TcpListener;
18152        use std::time::{Duration, Instant};
18153
18154        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18155        let url = format!("http://{}/upload", listener.local_addr().unwrap());
18156        let server = std::thread::spawn(move || {
18157            let (_stream, _) = listener.accept().unwrap();
18158            // Never consume the request body. Once the kernel send buffer fills,
18159            // the client must leave on its absolute write deadline.
18160            std::thread::sleep(Duration::from_millis(600));
18161        });
18162        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
18163        let body = vec![0x5a; 32 * 1024 * 1024];
18164        let started = Instant::now();
18165        let error = http
18166            .put(&url)
18167            .send_bytes(&body)
18168            .expect_err("stalled request-body writes must time out");
18169        assert!(
18170            started.elapsed() < Duration::from_millis(700),
18171            "stalled upload exceeded the wall-clock budget: {error}"
18172        );
18173        server.join().unwrap();
18174    }
18175
18176    #[test]
18177    fn presigned_source_retries_share_one_upload_deadline() {
18178        use std::net::TcpListener;
18179        use std::time::{Duration, Instant};
18180
18181        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18182        let address = listener.local_addr().unwrap();
18183        let signature = "do-not-render-this-stalled-upload-signature";
18184        let url = format!(
18185            "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
18186        );
18187        let server = std::thread::spawn(move || {
18188            let (_stream, _) = listener.accept().unwrap();
18189            // Keep the socket open without consuming the request. The first
18190            // attempt must spend the complete shared budget; retries may not
18191            // each restart that budget.
18192            std::thread::sleep(Duration::from_millis(600));
18193        });
18194
18195        let directory = tempfile::tempdir().unwrap();
18196        std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
18197        std::fs::create_dir(directory.path().join("records")).unwrap();
18198        let relative = "records/stalled.bin";
18199        let bytes = vec![0x5a; 32 * 1024 * 1024];
18200        std::fs::write(directory.path().join(relative), &bytes).unwrap();
18201        let store = Store::open_strict(directory.path()).unwrap();
18202        let cfg = HubConfig {
18203            hub: format!("http://{address}"),
18204            key: None,
18205            agent_key: None,
18206            brain_key: None,
18207            state_dir: tempfile::tempdir().unwrap().keep(),
18208            store_selected: false,
18209        };
18210        let source = V2UploadSource {
18211            path: relative.to_string(),
18212            bytes: bytes.len() as u64,
18213        };
18214
18215        let started = Instant::now();
18216        let error = put_presigned_source_with_budget(
18217            &cfg,
18218            &url,
18219            &json!({ "content-length": source.bytes.to_string() }),
18220            &store,
18221            &source,
18222            None,
18223            Duration::from_millis(150),
18224        )
18225        .expect_err("a black-holed upload must leave at its shared deadline");
18226        assert!(
18227            started.elapsed() < Duration::from_millis(700),
18228            "presigned retries exceeded their shared budget: {error}"
18229        );
18230        let rendered = error.to_string();
18231        assert!(rendered.contains("the object store"));
18232        assert!(!rendered.contains(&url));
18233        assert!(!rendered.contains(signature));
18234        server.join().unwrap();
18235    }
18236
18237    #[test]
18238    fn verb_entry_gates_accept_the_hub_ref_shapes() {
18239        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
18240            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
18241            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
18242            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
18243        }
18244    }
18245
18246    #[test]
18247    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
18248        let cfg = dead_hub();
18249        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
18250            assert!(
18251                matches!(
18252                    sync_pull(&cfg, bad, None),
18253                    Err(LinkError::BadAddress { .. })
18254                ),
18255                "sync_pull must refuse {bad:?}"
18256            );
18257            assert!(
18258                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
18259                "sync_push must refuse {bad:?}"
18260            );
18261            assert!(
18262                matches!(
18263                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
18264                    Err(LinkError::BadAddress { .. })
18265                ),
18266                "grant_issue must refuse {bad:?}"
18267            );
18268            assert!(
18269                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
18270                "grant_list must refuse {bad:?}"
18271            );
18272            assert!(
18273                matches!(
18274                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
18275                    Err(LinkError::BadAddress { .. })
18276                ),
18277                "grant_revoke must refuse brain {bad:?}"
18278            );
18279            assert!(
18280                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
18281                "head must refuse {bad:?}"
18282            );
18283        }
18284    }
18285
18286    #[test]
18287    fn grant_revoke_refuses_url_reshaping_grant_ids() {
18288        let cfg = dead_hub();
18289        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
18290            assert!(
18291                matches!(
18292                    grant_revoke(&cfg, "acme", bad),
18293                    Err(LinkError::BadGrantId { .. })
18294                ),
18295                "grant_revoke must refuse grant id {bad:?}"
18296            );
18297        }
18298    }
18299
18300    #[test]
18301    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
18302        let cfg = dead_hub();
18303        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
18304            assert!(
18305                matches!(
18306                    propose(&cfg, bad, "intake", "hi"),
18307                    Err(LinkError::BadAddress { .. })
18308                ),
18309                "propose must refuse handle {bad:?}"
18310            );
18311        }
18312        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
18313        assert!(matches!(
18314            propose(&cfg, "acme-site", "intake", &oversize),
18315            Err(LinkError::ProposeTooLarge { .. })
18316        ));
18317        // A clean handle + in-cap body passes both gates: the failure is now
18318        // the (dead) wire, proving the gates refuse shape, not the verb.
18319        assert!(matches!(
18320            propose(&cfg, "acme-site", "intake", "hi"),
18321            Err(LinkError::Transport { .. })
18322        ));
18323    }
18324
18325    #[test]
18326    fn resolve_refuses_a_hand_built_unsafe_address() {
18327        let cfg = dead_hub();
18328        for brain in ["../up", "a/b", "a?x", "a#f"] {
18329            let addr = Address {
18330                brain: brain.to_string(),
18331                target: None,
18332            };
18333            assert!(
18334                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18335                "resolve must refuse brain {brain:?}"
18336            );
18337        }
18338        for target in [
18339            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
18340            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
18341            AddressTarget::Path("../up.md".to_string()),
18342            AddressTarget::Path("records/x.md#frag".to_string()),
18343        ] {
18344            let addr = Address {
18345                brain: "acme".to_string(),
18346                target: Some(target.clone()),
18347            };
18348            assert!(
18349                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
18350                "resolve must refuse target {target:?}"
18351            );
18352        }
18353    }
18354
18355    #[test]
18356    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
18357        let mut local = std::collections::BTreeMap::new();
18358        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
18359        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
18360        let mut remote = std::collections::BTreeMap::new();
18361        remote.insert(
18362            "records/a.md".to_string(),
18363            V2BaselineFile {
18364                sha256: "c".repeat(64),
18365                bytes: 1,
18366                proof: None,
18367            },
18368        );
18369        remote.insert(
18370            "records/b.md".to_string(),
18371            V2BaselineFile {
18372                sha256: "b".repeat(64),
18373                bytes: 1,
18374                proof: None,
18375            },
18376        );
18377        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18378    }
18379
18380    #[test]
18381    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
18382        let local = std::collections::BTreeMap::new();
18383        let mut remote = std::collections::BTreeMap::new();
18384        remote.insert(
18385            "private/local.md".to_string(),
18386            V2BaselineFile {
18387                sha256: "d".repeat(64),
18388                bytes: 1,
18389                proof: None,
18390            },
18391        );
18392        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
18393        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
18394    }
18395
18396    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
18397        V2VerifiedHead {
18398            requested: TEST_BRAIN_ID.to_string(),
18399            brain_id: TEST_BRAIN_ID.to_string(),
18400            view_kind: "scoped".to_string(),
18401            view_revision: revision.to_string(),
18402            control_revision: revision.to_string(),
18403            identity: V2HeadIdentity {
18404                custody: "hub".to_string(),
18405                fingerprint: "test".to_string(),
18406                public_key_spki: "test".to_string(),
18407                previous: Vec::new(),
18408                rotations: Vec::new(),
18409            },
18410            pointer: None,
18411            trust: TrustState {
18412                v: 2,
18413                origin: "https://hub.example".to_string(),
18414                requested: TEST_BRAIN_ID.to_string(),
18415                brain: TEST_BRAIN_ID.to_string(),
18416                home: None,
18417                anchor: "ed25519:test".to_string(),
18418                current: "ed25519:test".to_string(),
18419                head_seq: 0,
18420                feed_hash: None,
18421                rotations: Vec::new(),
18422                hub_signer: None,
18423                protocol_profile: Some("link-v2".to_string()),
18424            },
18425            alias: None,
18426        }
18427    }
18428
18429    #[test]
18430    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
18431        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
18432        assert!(accepted_as_v2(&trust));
18433
18434        trust.protocol_profile = None;
18435        trust.hub_signer = Some("ed25519:hub".to_string());
18436        assert!(accepted_as_v2(&trust));
18437
18438        trust.hub_signer = None;
18439        assert!(!accepted_as_v2(&trust));
18440    }
18441
18442    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
18443        V2SyncBaseline {
18444            v: 2,
18445            origin: "https://hub.example".to_string(),
18446            brain: TEST_BRAIN_ID.to_string(),
18447            checkout_id: Some("c".repeat(64)),
18448            head_seq: Some(0),
18449            commit_hash: None,
18450            content_root: None,
18451            asset_root: None,
18452            assets: std::collections::BTreeMap::new(),
18453            view_kind: Some("scoped".to_string()),
18454            view_revision: Some(revision.to_string()),
18455            control_revision: Some(revision.to_string()),
18456            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
18457            files: std::collections::BTreeMap::new(),
18458            local_policy_digest: None,
18459            local_eligibility: std::collections::BTreeMap::new(),
18460            remote_copy_remains: std::collections::BTreeMap::new(),
18461        }
18462    }
18463
18464    #[test]
18465    fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
18466        let cfg = test_hub_config(
18467            "https://hub.example".to_string(),
18468            tempfile::tempdir().unwrap().keep(),
18469        );
18470        let mut baseline = scoped_test_baseline(&"a".repeat(64));
18471        baseline.assets.insert(
18472            "assets/archive.bin".to_string(),
18473            V2BaselineAsset {
18474                blob_sha256: "b".repeat(64),
18475                bytes: MAX_STORE_BYTES + 1,
18476                media_type: "application/octet-stream".to_string(),
18477                wrappers: vec!["records/archive.md".to_string()],
18478                required: true,
18479                disposition: "hosted".to_string(),
18480                leaf_hash: "c".repeat(64),
18481            },
18482        );
18483
18484        let accepted = serde_json::to_vec(&baseline).unwrap();
18485        assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
18486
18487        baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
18488        let refused = serde_json::to_vec(&baseline).unwrap();
18489        assert!(matches!(
18490            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
18491            Err(LinkError::InvalidFeed { .. })
18492        ));
18493    }
18494
18495    #[test]
18496    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
18497        let directory = tempfile::tempdir().unwrap();
18498        std::fs::write(
18499            directory.path().join("DB.md"),
18500            scoped_projection_bytes(TEST_BRAIN_ID),
18501        )
18502        .unwrap();
18503        let store = Store::open_strict(directory.path()).unwrap();
18504        let head = scoped_test_head(&"a".repeat(64));
18505        let baseline = scoped_test_baseline(&"a".repeat(64));
18506        let mut view = v2_local_files(&store).unwrap();
18507        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
18508        assert!(!view.riding.contains_key("DB.md"));
18509        assert!(!view.eligibility.contains_key("DB.md"));
18510    }
18511
18512    #[test]
18513    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
18514        let directory = tempfile::tempdir().unwrap();
18515        std::fs::write(
18516            directory.path().join("DB.md"),
18517            scoped_projection_bytes(TEST_BRAIN_ID),
18518        )
18519        .unwrap();
18520        let store = Store::open_strict(directory.path()).unwrap();
18521        let head = scoped_test_head(&"a".repeat(64));
18522        let baseline = scoped_test_baseline(&"a".repeat(64));
18523
18524        let mut carried = v2_local_files(&store).unwrap();
18525        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
18526        let handed_off =
18527            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
18528        assert!(!handed_off.riding.contains_key("DB.md"));
18529
18530        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
18531        assert!(!freshly_scanned.riding.contains_key("DB.md"));
18532
18533        std::fs::write(
18534            directory.path().join("DB.md"),
18535            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18536        )
18537        .unwrap();
18538        let tampered = Store::open_strict(directory.path()).unwrap();
18539        assert!(matches!(
18540            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
18541            Err(LinkError::ScopedProjectionModified)
18542        ));
18543    }
18544
18545    #[test]
18546    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
18547        let directory = tempfile::tempdir().unwrap();
18548        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18549        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18550        std::fs::write(
18551            directory.path().join("DB.md"),
18552            b"---\nname: Kept home test\n---\n",
18553        )
18554        .unwrap();
18555        std::fs::write(
18556            directory.path().join("records/notes/a.md"),
18557            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
18558        )
18559        .unwrap();
18560        std::fs::write(
18561            directory.path().join("sources/private/secret.md"),
18562            b"---\ntype: note\n---\nlocal only\n",
18563        )
18564        .unwrap();
18565        std::fs::write(
18566            directory.path().join("sources/private/unlinked.md"),
18567            b"---\ntype: note\n---\nnot disclosed\n",
18568        )
18569        .unwrap();
18570        std::fs::write(
18571            directory.path().join(".sevralocal"),
18572            b"sources/private/**\n",
18573        )
18574        .unwrap();
18575
18576        let store = Store::open_strict(directory.path()).unwrap();
18577        let view = v2_local_files(&store).unwrap();
18578        assert!(!view.riding.contains_key("sources/private/secret.md"));
18579        assert_eq!(
18580            view.withheld_links,
18581            vec![V2WithheldLink {
18582                source: "records/notes/a.md".to_string(),
18583                target: "sources/private/secret.md".to_string(),
18584            }]
18585        );
18586    }
18587
18588    #[test]
18589    fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
18590        // An export omits kept-home content by construction, so a store
18591        // restored from one links to names it does not carry. Without this the
18592        // owner's own export could never be pushed back — the links would read
18593        // as broken and the commit would be refused.
18594        let directory = tempfile::tempdir().unwrap();
18595        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
18596        std::fs::write(
18597            directory.path().join("DB.md"),
18598            b"---\nname: Restored export\n---\n",
18599        )
18600        .unwrap();
18601        std::fs::write(
18602            directory.path().join("records/notes/a.md"),
18603            b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
18604        )
18605        .unwrap();
18606        std::fs::write(
18607            directory.path().join(".sevralocal"),
18608            b"sources/private/**\n",
18609        )
18610        .unwrap();
18611
18612        let store = Store::open_strict(directory.path()).unwrap();
18613        let view = v2_local_files(&store).unwrap();
18614        assert_eq!(
18615            view.withheld_links,
18616            vec![V2WithheldLink {
18617                source: "records/notes/a.md".to_string(),
18618                target: "sources/private/absent.md".to_string(),
18619            }]
18620        );
18621        // A dangling link the owner never claimed is still dangling.
18622        std::fs::write(
18623            directory.path().join("records/notes/b.md"),
18624            b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
18625        )
18626        .unwrap();
18627        let store = Store::open_strict(directory.path()).unwrap();
18628        let view = v2_local_files(&store).unwrap();
18629        assert!(
18630            !view
18631                .withheld_links
18632                .iter()
18633                .any(|link| link.target == "records/notes/nowhere.md"),
18634            "an unclaimed dangling target must not be declared withheld"
18635        );
18636    }
18637
18638    #[test]
18639    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
18640        let directory = tempfile::tempdir().unwrap();
18641        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
18642        std::fs::write(
18643            directory.path().join("DB.md"),
18644            b"---\nname: Withdrawal test\n---\n",
18645        )
18646        .unwrap();
18647        let source = b"---\ntype: note\n---\nlocal evidence\n";
18648        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
18649        std::fs::write(
18650            directory.path().join(".sevralocal"),
18651            b"sources/private/**\n",
18652        )
18653        .unwrap();
18654        let store = Store::open_strict(directory.path()).unwrap();
18655        let view = v2_local_files(&store).unwrap();
18656        let mut remote = std::collections::BTreeMap::new();
18657        remote.insert(
18658            "sources/private/evidence.md".to_string(),
18659            V2BaselineFile {
18660                sha256: content_sha256(source),
18661                bytes: source.len() as u64,
18662                proof: None,
18663            },
18664        );
18665        assert_eq!(
18666            v2_content_withdrawal_operation(
18667                &store,
18668                &view,
18669                &remote,
18670                "sources/private/evidence.md",
18671                "approved retention change",
18672            )
18673            .unwrap(),
18674            json!({
18675                "op": "withdraw_from_hosting",
18676                "path": "sources/private/evidence.md",
18677                "expected": { "kind": "blob", "hash": content_sha256(source) },
18678                "reason": "approved retention change",
18679            })
18680        );
18681
18682        std::fs::write(
18683            directory.path().join("sources/private/evidence.md"),
18684            b"changed after review",
18685        )
18686        .unwrap();
18687        assert!(matches!(
18688            v2_content_withdrawal_operation(
18689                &store,
18690                &view,
18691                &remote,
18692                "sources/private/evidence.md",
18693                "approved retention change",
18694            ),
18695            Err(LinkError::InvalidPack { .. })
18696        ));
18697    }
18698
18699    #[test]
18700    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
18701        let directory = tempfile::tempdir().unwrap();
18702        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
18703        std::fs::write(
18704            directory.path().join("DB.md"),
18705            b"---\nname: Asset withdrawal test\n---\n",
18706        )
18707        .unwrap();
18708        let bytes = b"private binary";
18709        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
18710        std::fs::write(
18711            directory.path().join(".sevralocal"),
18712            b"sources/files/private.pdf\n",
18713        )
18714        .unwrap();
18715        let store = Store::open_strict(directory.path()).unwrap();
18716        let view = v2_local_files(&store).unwrap();
18717        let local = crate::AssetRecord {
18718            path: "sources/files/private.pdf".to_string(),
18719            sha256: content_sha256(bytes),
18720            bytes: bytes.len() as u64,
18721            media_type: "application/pdf".to_string(),
18722            wrappers: vec![
18723                "sources/files/private.md".to_string(),
18724                "sources/redacted/private.md".to_string(),
18725            ],
18726            required: false,
18727        };
18728        let current = V2BaselineAsset {
18729            blob_sha256: local.sha256.clone(),
18730            bytes: local.bytes,
18731            media_type: local.media_type.clone(),
18732            wrappers: vec!["sources/files/private.md".to_string()],
18733            required: true,
18734            disposition: "hosted".to_string(),
18735            leaf_hash: "d".repeat(64),
18736        };
18737        assert_eq!(
18738            v2_asset_withdrawal_operation(
18739                &store,
18740                &view,
18741                &local.path,
18742                &local,
18743                &current,
18744                "approved retention change",
18745            )
18746            .unwrap(),
18747            json!({
18748                "op": "asset_withdraw",
18749                "path": local.path,
18750                "expected": { "kind": "asset", "hash": "d".repeat(64) },
18751                "asset": {
18752                    "blob_sha256": local.sha256.clone(),
18753                    "bytes": local.bytes,
18754                    "media_type": local.media_type.clone(),
18755                    "wrappers": local.wrappers.clone(),
18756                    "required": false,
18757                    "disposition": "withheld",
18758                },
18759                "reason": "approved retention change",
18760            })
18761        );
18762
18763        let mut mismatched = current.clone();
18764        mismatched.blob_sha256 = "f".repeat(64);
18765        assert!(matches!(
18766            v2_asset_withdrawal_operation(
18767                &store,
18768                &view,
18769                &local.path,
18770                &local,
18771                &mismatched,
18772                "approved retention change",
18773            ),
18774            Err(LinkError::InvalidPack { .. })
18775        ));
18776    }
18777
18778    #[test]
18779    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
18780        let first = v2_checkout_id(None).unwrap();
18781        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
18782        assert_ne!(first, v2_checkout_id(None).unwrap());
18783        assert!(is_sha256(&first));
18784    }
18785
18786    #[test]
18787    fn scoped_projection_edit_and_scope_transition_fail_closed() {
18788        let directory = tempfile::tempdir().unwrap();
18789        std::fs::write(
18790            directory.path().join("DB.md"),
18791            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
18792        )
18793        .unwrap();
18794        let store = Store::open_strict(directory.path()).unwrap();
18795        let head = scoped_test_head(&"a".repeat(64));
18796        let baseline = scoped_test_baseline(&"a".repeat(64));
18797        let mut view = v2_local_files(&store).unwrap();
18798        assert!(matches!(
18799            remove_scoped_projection(&head, Some(&baseline), &mut view),
18800            Err(LinkError::ScopedProjectionModified)
18801        ));
18802
18803        let changed = scoped_test_head(&"b".repeat(64));
18804        assert!(matches!(
18805            ensure_v2_view_compatible(&changed, Some(&baseline)),
18806            Err(LinkError::ScopedViewChanged)
18807        ));
18808
18809        let mut same_view_new_control = head.clone();
18810        same_view_new_control.control_revision = "c".repeat(64);
18811        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
18812        assert!(!same_v2_head(&head, &same_view_new_control));
18813    }
18814
18815    #[test]
18816    fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
18817        let mut head = scoped_test_head(&"a".repeat(64));
18818        head.control_revision = "b".repeat(64);
18819        head.pointer = Some(V2PointerBody {
18820            v: 2,
18821            brain: TEST_BRAIN_ID.to_string(),
18822            seq: 7,
18823            commit_hash: "c".repeat(64),
18824            feed_hash: "d".repeat(64),
18825            content_root: Some("e".repeat(64)),
18826            asset_root: Some("f".repeat(64)),
18827            materializer: "dbmd-projection-v1".to_string(),
18828            signer_epoch: 1,
18829            control_revision: head.control_revision.clone(),
18830            backup_preparation: "0".repeat(64),
18831            prior_pointer_hash: Some("1".repeat(64)),
18832            signed_at: "2026-08-24T00:00:00.000Z".to_string(),
18833        });
18834        let mut baseline = scoped_test_baseline(&head.view_revision);
18835        baseline.head_seq = Some(7);
18836        baseline.commit_hash = Some("c".repeat(64));
18837        baseline.content_root = Some("e".repeat(64));
18838        baseline.asset_root = Some("f".repeat(64));
18839        baseline.control_revision = Some(head.control_revision.clone());
18840        assert!(v2_baseline_matches_head(&head, &baseline));
18841
18842        let mut changed = baseline.clone();
18843        changed.head_seq = Some(8);
18844        assert!(!v2_baseline_matches_head(&head, &changed));
18845        let mut changed = baseline.clone();
18846        changed.commit_hash = Some("2".repeat(64));
18847        assert!(!v2_baseline_matches_head(&head, &changed));
18848        let mut changed = baseline.clone();
18849        changed.content_root = Some("3".repeat(64));
18850        assert!(!v2_baseline_matches_head(&head, &changed));
18851        let mut changed = baseline.clone();
18852        changed.asset_root = Some("4".repeat(64));
18853        assert!(!v2_baseline_matches_head(&head, &changed));
18854        let mut changed = baseline.clone();
18855        changed.view_revision = Some("5".repeat(64));
18856        assert!(!v2_baseline_matches_head(&head, &changed));
18857        let mut changed = baseline.clone();
18858        changed.control_revision = Some("6".repeat(64));
18859        assert!(!v2_baseline_matches_head(&head, &changed));
18860
18861        let mut changed_head = head.clone();
18862        changed_head.view_kind = "full".to_string();
18863        assert!(!v2_baseline_matches_head(&changed_head, &baseline));
18864    }
18865
18866    #[test]
18867    fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
18868        let sandbox = tempfile::tempdir().unwrap();
18869        let cfg = test_hub_config(
18870            "https://hub.example".to_string(),
18871            sandbox.path().to_path_buf(),
18872        );
18873        let head = scoped_test_head(&"a".repeat(64));
18874        let baseline = scoped_test_baseline(&head.view_revision);
18875        let mut encoded = serde_json::to_value(&baseline).unwrap();
18876        encoded.as_object_mut().unwrap().remove("control_revision");
18877        let parsed =
18878            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
18879        assert!(parsed.control_revision.is_none());
18880        assert!(!v2_baseline_matches_head(&head, &parsed));
18881    }
18882
18883    #[test]
18884    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
18885        let scoped = scoped_test_head(&"a".repeat(64));
18886        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
18887        assert!(matches!(
18888            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18889            Err(LinkError::ScopedProjectionModified)
18890        ));
18891
18892        let mut full = scoped.clone();
18893        full.view_kind = "full".to_string();
18894        let mut full_baseline = scoped_baseline.clone();
18895        full_baseline.view_kind = Some("full".to_string());
18896        full_baseline.projection_sha256 = None;
18897        assert!(matches!(
18898            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18899            Err(LinkError::InvalidPack { .. })
18900        ));
18901
18902        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18903        assert!(
18904            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18905        );
18906    }
18907
18908    #[test]
18909    fn scoped_view_metadata_is_explicitly_non_authoritative() {
18910        let head = scoped_test_head(&"a".repeat(64));
18911        let value: Value =
18912            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18913        assert_eq!(value["kind"], "link.md-scoped-view");
18914        assert_eq!(value["authoritative"], false);
18915        assert_eq!(value["visible_files"], 7);
18916        assert_eq!(value["brain"], TEST_BRAIN_ID);
18917    }
18918
18919    #[test]
18920    fn local_scoped_marker_requires_the_exact_generated_projection() {
18921        let directory = tempfile::tempdir().unwrap();
18922        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18923        std::fs::write(
18924            directory.path().join("DB.md"),
18925            scoped_projection_bytes(TEST_BRAIN_ID),
18926        )
18927        .unwrap();
18928        let head = scoped_test_head(&"a".repeat(64));
18929        std::fs::write(
18930            directory.path().join(".dbmd/view.json"),
18931            scoped_view_metadata(&head, 0).unwrap(),
18932        )
18933        .unwrap();
18934        let store = Store::open_strict(directory.path()).unwrap();
18935        assert!(has_verified_local_scoped_view(&store));
18936
18937        std::fs::write(
18938            directory.path().join("DB.md"),
18939            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18940        )
18941        .unwrap();
18942        let altered = Store::open_strict(directory.path()).unwrap();
18943        assert!(!has_verified_local_scoped_view(&altered));
18944    }
18945
18946    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18947        use ring::signature::KeyPair as _;
18948
18949        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18950        let rng = ring::rand::SystemRandom::new();
18951        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18952        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18953        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18954        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18955        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18956        let blob = b"new";
18957        let blob_hash = content_sha256(blob);
18958        let changes = json!({
18959            "mutation_id": "sync:proposal-fixture",
18960            "operations": [{
18961                "blob": blob_hash,
18962                "bytes": blob.len(),
18963                "expected": null,
18964                "op": "put",
18965                "path": "records/new.md",
18966            }],
18967            "reason": "fixture",
18968            "v": 2,
18969        });
18970        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18971        let changes_base64 = STANDARD.encode(&changes_bytes);
18972        let descriptor = json!({
18973            "base": null,
18974            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18975            "changes_base64": changes_base64,
18976            "rebase": "strict",
18977            "v": 2,
18978        });
18979        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18980        let payload_hash = "b".repeat(64);
18981        let submitted_at = "2026-08-19T12:00:00.000Z";
18982        let claim = json!({
18983            "actor_root": {
18984                "actor_class": "foreign_key",
18985                "credential": "ed25519:fixture",
18986                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18987                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18988                "principal": "key:fixture",
18989                "role": null,
18990            },
18991            "brain": TEST_BRAIN_ID,
18992            "clear_sha256": clear_hash,
18993            "control_revision": "c".repeat(64),
18994            "mutation_id": "sync:proposal-fixture",
18995            "payload_sha256": payload_hash,
18996            "proposal_id": proposal_id,
18997            "submitted_at": submitted_at,
18998            "v": 2,
18999        });
19000        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
19001        let envelope = json!({
19002            "claim": claim,
19003            "fingerprint": fingerprint,
19004            "public_key": public_key,
19005            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
19006        });
19007        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
19008        let submission_hash =
19009            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
19010        let mut head = scoped_test_head(&"c".repeat(64));
19011        head.view_kind = "full".to_string();
19012        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
19013        let value = json!({
19014            "proposal": {
19015                "base": null,
19016                "blobs": [{
19017                    "bytes": blob.len(),
19018                    "endpoint": format!(
19019                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
19020                    ),
19021                    "sha256": blob_hash,
19022                }],
19023                "changes_base64": changes_base64,
19024                "clear_sha256": clear_hash,
19025                "expires_at": "2026-08-26T12:00:00.000Z",
19026                "id": proposal_id,
19027                "payload_sha256": payload_hash,
19028                "proposer": { "class": "foreign_key" },
19029                "rebase": "strict",
19030                "state": "pending",
19031                "submission_claim_base64": STANDARD.encode(envelope_bytes),
19032                "submission_claim_sha256": submission_hash,
19033                "submitted_at": submitted_at,
19034            },
19035            "v": 2,
19036        });
19037        (head, proposal_id, value)
19038    }
19039
19040    #[test]
19041    fn v2_proposal_verifier_accepts_exact_signed_payload() {
19042        let (head, proposal_id, value) = signed_proposal_fixture();
19043        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
19044        assert_eq!(verified.blobs.len(), 1);
19045        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
19046    }
19047
19048    #[test]
19049    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
19050        let (head, proposal_id, value) = signed_proposal_fixture();
19051
19052        let mut changed = value.clone();
19053        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
19054        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
19055
19056        let mut redirected = value.clone();
19057        redirected["proposal"]["blobs"][0]["endpoint"] =
19058            Value::String("https://attacker.example/blob".to_string());
19059        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
19060
19061        let mut forged = value;
19062        let encoded = forged["proposal"]["submission_claim_base64"]
19063            .as_str()
19064            .unwrap();
19065        let mut envelope: Value =
19066            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
19067        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
19068        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
19069        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
19070        forged["proposal"]["submission_claim_sha256"] = Value::String(
19071            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
19072        );
19073        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
19074    }
19075
19076    #[cfg(unix)]
19077    #[test]
19078    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
19079        let sandbox = tempfile::tempdir().unwrap();
19080        let destination = sandbox.path().join("brain");
19081        let entries = vec![
19082            (
19083                "DB.md".to_string(),
19084                scoped_projection_bytes(TEST_BRAIN_ID),
19085            ),
19086            (
19087                "records/contacts/a.md".to_string(),
19088                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
19089                    .to_vec(),
19090            ),
19091        ];
19092        install_pulled_delta(&destination, &entries, &[], true).unwrap();
19093        assert!(destination.join("index.md").is_file());
19094        assert!(destination.join("records/index.md").is_file());
19095        assert!(destination.join("records/contacts/index.md").is_file());
19096        assert!(destination.join("records/contacts/index.jsonl").is_file());
19097    }
19098
19099    #[cfg(unix)]
19100    #[test]
19101    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
19102        let sandbox = tempfile::tempdir().unwrap();
19103        let destination = sandbox.path().join("brain");
19104        let cache = sandbox.path().join("cache");
19105        std::fs::create_dir(&cache).unwrap();
19106        let db = scoped_projection_bytes(TEST_BRAIN_ID);
19107        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
19108        let db_source = cache.join("db");
19109        let shared_source = cache.join("shared");
19110        crate::fsx::write_atomic(&db_source, &db).unwrap();
19111        crate::fsx::write_atomic(&shared_source, shared).unwrap();
19112        let mut entries = vec![V2StagedFile {
19113            path: "DB.md".to_string(),
19114            source: db_source,
19115            sha256: content_sha256(&db),
19116            bytes: db.len() as u64,
19117        }];
19118        for index in 0..512 {
19119            entries.push(V2StagedFile {
19120                path: format!("records/items/{index:05}.md"),
19121                source: shared_source.clone(),
19122                sha256: content_sha256(shared),
19123                bytes: shared.len() as u64,
19124            });
19125        }
19126        install_pulled_delta_sources(
19127            &destination,
19128            &entries,
19129            &[],
19130            false,
19131            None,
19132            &scoped_test_head(&"c".repeat(64)),
19133        )
19134        .unwrap();
19135        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
19136        for index in 0..512 {
19137            assert_eq!(
19138                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
19139                shared
19140            );
19141        }
19142        assert!(
19143            std::fs::read_dir(sandbox.path())
19144                .unwrap()
19145                .all(|entry| !entry
19146                    .unwrap()
19147                    .file_name()
19148                    .to_string_lossy()
19149                    .contains("pull-stage")),
19150            "the private stage must be atomically installed or removed"
19151        );
19152    }
19153
19154    #[cfg(unix)]
19155    #[test]
19156    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
19157        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
19158
19159        let sandbox = tempfile::tempdir().unwrap();
19160        let root = sandbox.path().join("brain");
19161        std::fs::create_dir_all(root.join("records/items")).unwrap();
19162        let db = scoped_projection_bytes(TEST_BRAIN_ID);
19163        let old = b"---\ntype: note\n---\n\nold\n";
19164        let new = b"---\ntype: note\n---\n\nnew\n";
19165        let removed = b"---\ntype: note\n---\n\nremove me\n";
19166        std::fs::write(root.join("DB.md"), &db).unwrap();
19167        std::fs::write(root.join("records/items/change.md"), old).unwrap();
19168        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
19169        for index in 0..512 {
19170            std::fs::write(
19171                root.join(format!("records/items/untouched-{index:04}.md")),
19172                old,
19173            )
19174            .unwrap();
19175        }
19176        let untouched = root.join("records/items/untouched-0256.md");
19177        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
19178        let source = sandbox.path().join("changed-source");
19179        crate::fsx::write_atomic(&source, new).unwrap();
19180        let same_source = sandbox.path().join("unchanged-source");
19181        crate::fsx::write_atomic(&same_source, old).unwrap();
19182        let same_entry = V2StagedFile {
19183            path: "records/items/change.md".to_string(),
19184            source: same_source,
19185            sha256: content_sha256(old),
19186            bytes: old.len() as u64,
19187        };
19188        let entry = V2StagedFile {
19189            path: "records/items/change.md".to_string(),
19190            source,
19191            sha256: content_sha256(new),
19192            bytes: new.len() as u64,
19193        };
19194        let head = scoped_test_head(&"c".repeat(64));
19195
19196        // A no-op established pull neither clones the checkout nor arms a
19197        // journal. Inode identity is a direct regression guard against the
19198        // former brain-wide private-copy implementation.
19199        install_established_v2_delta(
19200            Store::open_strict(&root).unwrap(),
19201            &[same_entry],
19202            &["records/items/already-absent.md".to_string()],
19203            true,
19204            None,
19205            &head,
19206        )
19207        .unwrap();
19208        assert_eq!(
19209            std::fs::metadata(&untouched).unwrap().ino(),
19210            untouched_inode
19211        );
19212        assert!(!root.join(V2_PULL_JOURNAL).exists());
19213
19214        install_established_v2_delta(
19215            Store::open_strict(&root).unwrap(),
19216            &[entry],
19217            &["records/items/delete.md".to_string()],
19218            false,
19219            None,
19220            &head,
19221        )
19222        .unwrap();
19223        assert_eq!(
19224            std::fs::read(root.join("records/items/change.md")).unwrap(),
19225            new
19226        );
19227        assert!(!root.join("records/items/delete.md").exists());
19228        assert_eq!(
19229            std::fs::metadata(&untouched).unwrap().ino(),
19230            untouched_inode
19231        );
19232        assert!(root.join(V2_PULL_JOURNAL).is_file());
19233        assert_eq!(
19234            std::fs::metadata(root.join(V2_PULL_JOURNAL))
19235                .unwrap()
19236                .permissions()
19237                .mode()
19238                & 0o777,
19239            0o600
19240        );
19241        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
19242            .unwrap()
19243            .unwrap();
19244        assert_eq!(
19245            std::fs::metadata(root.join(&journal.backup_dir))
19246                .unwrap()
19247                .permissions()
19248                .mode()
19249                & 0o777,
19250            0o700
19251        );
19252        for entry in &journal.entries {
19253            if let Some(backup) = &entry.backup {
19254                assert_eq!(
19255                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
19256                        .unwrap()
19257                        .permissions()
19258                        .mode()
19259                        & 0o777,
19260                    0o600
19261                );
19262            }
19263        }
19264
19265        let cfg = test_hub_config(
19266            "https://example.test".to_string(),
19267            sandbox.path().join("state"),
19268        );
19269        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19270        assert_eq!(
19271            std::fs::read(root.join("records/items/change.md")).unwrap(),
19272            old
19273        );
19274        assert_eq!(
19275            std::fs::read(root.join("records/items/delete.md")).unwrap(),
19276            removed
19277        );
19278        assert_eq!(
19279            std::fs::metadata(&untouched).unwrap().ino(),
19280            untouched_inode
19281        );
19282        assert!(!root.join(V2_PULL_JOURNAL).exists());
19283    }
19284
19285    #[test]
19286    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
19287        let body = b"bounded bytes";
19288        let path = "records/example.md".to_string();
19289        let file = V2BaselineFile {
19290            sha256: content_sha256(body),
19291            bytes: body.len() as u64,
19292            proof: None,
19293        };
19294        let header = serde_json::to_vec(&json!({
19295            "bytes": body.len(),
19296            "path": path,
19297            "sha256": file.sha256,
19298            "v": 2,
19299        }))
19300        .unwrap();
19301        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
19302        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
19303        stream.extend_from_slice(&header);
19304        stream.extend_from_slice(body);
19305        stream.extend_from_slice(&0_u32.to_be_bytes());
19306        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
19307        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
19308
19309        let mut tampered = stream.clone();
19310        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
19311        tampered[body_offset] ^= 1;
19312        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
19313
19314        let mut trailing = stream;
19315        trailing.push(0);
19316        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
19317    }
19318
19319    #[test]
19320    fn first_checkout_resolution_does_not_recreate_the_same_conflict() {
19321        let path = "records/value.md".to_string();
19322        let mut local = std::collections::BTreeMap::new();
19323        local.insert(path.clone(), (content_sha256(b"local"), 5));
19324        let mut remote = std::collections::BTreeMap::new();
19325        remote.insert(
19326            path.clone(),
19327            V2BaselineFile {
19328                sha256: content_sha256(b"remote"),
19329                bytes: 6,
19330                proof: None,
19331            },
19332        );
19333
19334        assert_eq!(
19335            v2_initial_content_conflicts(&local, &remote, false),
19336            vec![path]
19337        );
19338        assert!(v2_initial_content_conflicts(&local, &remote, true).is_empty());
19339
19340        let mut resolution = std::collections::BTreeMap::new();
19341        resolution.insert(
19342            "records/value.md".to_string(),
19343            V2ResolutionOverride {
19344                expected_remote: Some(content_sha256(b"remote")),
19345                selected_local: Some(content_sha256(b"local")),
19346            },
19347        );
19348        assert!(v2_resolution_allows_path(
19349            Some(&resolution),
19350            "records/value.md",
19351            true
19352        ));
19353        assert!(v2_resolution_allows_path(
19354            Some(&resolution),
19355            "records/new-target.md",
19356            false
19357        ));
19358        assert!(!v2_resolution_allows_path(
19359            Some(&resolution),
19360            "records/unreviewed-remote.md",
19361            true
19362        ));
19363    }
19364
19365    #[test]
19366    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
19367        let sandbox = tempfile::TempDir::new().unwrap();
19368        let root = sandbox.path().join("brain");
19369        std::fs::create_dir_all(&root).unwrap();
19370        std::fs::write(
19371            root.join("DB.md"),
19372            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19373        )
19374        .unwrap();
19375        let store = Store::open_strict(&root).unwrap();
19376        let incomplete = crate::ulid::mint();
19377        store
19378            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
19379            .unwrap();
19380        let expired = crate::ulid::mint();
19381        store
19382            .create_dir_all(&v2_conflict_relative(&expired, "files"))
19383            .unwrap();
19384        let plan = V2ConflictPlan {
19385            v: 2,
19386            class: "content_resolution_required".to_string(),
19387            bundle: expired.clone(),
19388            brain: TEST_BRAIN_ID.to_string(),
19389            origin: "https://example.test".to_string(),
19390            created_unix: 0,
19391            expires_unix: 0,
19392            base_seq: None,
19393            base_commit: None,
19394            remote_seq: 0,
19395            remote_commit: None,
19396            remote_content_root: None,
19397            view_kind: "full".to_string(),
19398            view_revision: "a".repeat(64),
19399            files: vec![V2ConflictFile {
19400                path: "records/value.md".to_string(),
19401                base: V2ConflictCoordinate {
19402                    sha256: None,
19403                    bytes: None,
19404                    file: None,
19405                },
19406                local: V2ConflictCoordinate {
19407                    sha256: None,
19408                    bytes: None,
19409                    file: None,
19410                },
19411                remote: V2ConflictCoordinate {
19412                    sha256: None,
19413                    bytes: None,
19414                    file: None,
19415                },
19416            }],
19417        };
19418        let mut bytes = serde_json::to_vec(&plan).unwrap();
19419        bytes.push(b'\n');
19420        store
19421            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
19422            .unwrap();
19423
19424        let listed = sync_conflicts(&root, false, false).unwrap();
19425        assert_eq!(listed["bundles"], 2);
19426        assert_eq!(listed["pruned"], 0);
19427        let pruned = sync_conflicts(&root, true, false).unwrap();
19428        assert_eq!(pruned["bundles"], 0);
19429        assert_eq!(pruned["pruned"], 2);
19430        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
19431        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
19432    }
19433
19434    #[test]
19435    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
19436        let sandbox = tempfile::TempDir::new().unwrap();
19437        let root = sandbox.path().join("brain");
19438        std::fs::create_dir_all(&root).unwrap();
19439        std::fs::write(
19440            root.join("DB.md"),
19441            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19442        )
19443        .unwrap();
19444        let store = Store::open_strict(&root).unwrap();
19445        let bundle = crate::ulid::mint();
19446        store
19447            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
19448            .unwrap();
19449        store
19450            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
19451            .unwrap();
19452
19453        assert!(sync_conflicts(&root, true, false).is_err());
19454        assert!(sync_conflicts(&root, false, true).is_err());
19455        let pruned = sync_conflicts(&root, true, true).unwrap();
19456        assert_eq!(pruned["pruned"], 1);
19457        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
19458    }
19459
19460    #[test]
19461    fn ready_pull_journal_rolls_back_exact_preimages() {
19462        let sandbox = tempfile::TempDir::new().unwrap();
19463        let root = sandbox.path().join("brain");
19464        std::fs::create_dir_all(root.join("records")).unwrap();
19465        std::fs::write(
19466            root.join("DB.md"),
19467            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19468        )
19469        .unwrap();
19470        let path = "records/value.md";
19471        let old = b"---\ntype: note\n---\n\nold\n";
19472        let new = b"---\ntype: note\n---\n\nnew\n";
19473        std::fs::write(root.join(path), old).unwrap();
19474        let store = Store::open_strict(&root).unwrap();
19475        let bundle = crate::ulid::mint();
19476        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19477        store
19478            .create_private_dir_all(Path::new(&backup_dir))
19479            .unwrap();
19480        store
19481            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
19482            .unwrap();
19483        let journal = V2PullJournal {
19484            v: 1,
19485            phase: V2PullPhase::Ready,
19486            brain: TEST_BRAIN_ID.to_string(),
19487            previous: V2PullCoordinate {
19488                head_seq: None,
19489                commit_hash: None,
19490                view_kind: None,
19491                view_revision: None,
19492            },
19493            next: V2PullCoordinate {
19494                head_seq: Some(2),
19495                commit_hash: Some("c".repeat(64)),
19496                view_kind: Some("full".to_string()),
19497                view_revision: Some("d".repeat(64)),
19498            },
19499            backup_dir: backup_dir.clone(),
19500            entries: vec![V2PullJournalEntry {
19501                path: path.to_string(),
19502                old: Some(V2PullFileCoordinate {
19503                    sha256: content_sha256(old),
19504                    bytes: old.len() as u64,
19505                }),
19506                new: Some(V2PullFileCoordinate {
19507                    sha256: content_sha256(new),
19508                    bytes: new.len() as u64,
19509                }),
19510                backup: Some("00000000".to_string()),
19511            }],
19512        };
19513        validate_v2_pull_journal(&journal).unwrap();
19514        store
19515            .write_private_atomic_new(
19516                Path::new(V2_PULL_JOURNAL),
19517                &v2_pull_journal_bytes(&journal).unwrap(),
19518            )
19519            .unwrap();
19520        store.write_atomic(Path::new(path), new).unwrap();
19521
19522        let cfg = test_hub_config(
19523            "https://example.test".to_string(),
19524            sandbox.path().join("state"),
19525        );
19526        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19527        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
19528        assert!(!root.join(V2_PULL_JOURNAL).exists());
19529        assert!(!root.join(backup_dir).exists());
19530    }
19531
19532    #[test]
19533    fn preparing_pull_journal_discards_only_private_staging() {
19534        let sandbox = tempfile::TempDir::new().unwrap();
19535        let root = sandbox.path().join("brain");
19536        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
19537        std::fs::write(
19538            root.join("DB.md"),
19539            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19540        )
19541        .unwrap();
19542        let store = Store::open_strict(&root).unwrap();
19543        let bundle = crate::ulid::mint();
19544        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19545        store
19546            .create_private_dir_all(Path::new(&backup_dir))
19547            .unwrap();
19548        let journal = V2PullJournal {
19549            v: 1,
19550            phase: V2PullPhase::Preparing,
19551            brain: TEST_BRAIN_ID.to_string(),
19552            previous: V2PullCoordinate {
19553                head_seq: None,
19554                commit_hash: None,
19555                view_kind: None,
19556                view_revision: None,
19557            },
19558            next: V2PullCoordinate {
19559                head_seq: Some(1),
19560                commit_hash: Some("a".repeat(64)),
19561                view_kind: Some("full".to_string()),
19562                view_revision: Some("b".repeat(64)),
19563            },
19564            backup_dir: backup_dir.clone(),
19565            entries: vec![V2PullJournalEntry {
19566                path: "records/new.md".to_string(),
19567                old: None,
19568                new: Some(V2PullFileCoordinate {
19569                    sha256: "c".repeat(64),
19570                    bytes: 1,
19571                }),
19572                backup: None,
19573            }],
19574        };
19575        store
19576            .write_private_atomic_new(
19577                Path::new(V2_PULL_JOURNAL),
19578                &v2_pull_journal_bytes(&journal).unwrap(),
19579            )
19580            .unwrap();
19581        let cfg = test_hub_config(
19582            "https://example.test".to_string(),
19583            sandbox.path().join("state"),
19584        );
19585
19586        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19587
19588        assert!(root.join("DB.md").is_file());
19589        assert!(!root.join(V2_PULL_JOURNAL).exists());
19590        assert!(!root.join(backup_dir).exists());
19591    }
19592
19593    #[test]
19594    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
19595        let sandbox = tempfile::TempDir::new().unwrap();
19596        let root = sandbox.path().join("brain");
19597        std::fs::create_dir_all(root.join("records")).unwrap();
19598        std::fs::write(
19599            root.join("DB.md"),
19600            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
19601        )
19602        .unwrap();
19603        let new = b"---\ntype: note\n---\n\nnew\n";
19604        std::fs::write(root.join("records/value.md"), new).unwrap();
19605        let store = Store::open_strict(&root).unwrap();
19606        let bundle = crate::ulid::mint();
19607        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
19608        store
19609            .create_private_dir_all(Path::new(&backup_dir))
19610            .unwrap();
19611        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
19612        store.create_private_dir_all(Path::new(&orphan)).unwrap();
19613        let next = V2PullCoordinate {
19614            head_seq: Some(2),
19615            commit_hash: Some("c".repeat(64)),
19616            view_kind: Some("full".to_string()),
19617            view_revision: Some("d".repeat(64)),
19618        };
19619        let journal = V2PullJournal {
19620            v: 1,
19621            phase: V2PullPhase::Ready,
19622            brain: TEST_BRAIN_ID.to_string(),
19623            previous: V2PullCoordinate {
19624                head_seq: Some(1),
19625                commit_hash: Some("a".repeat(64)),
19626                view_kind: Some("full".to_string()),
19627                view_revision: Some("b".repeat(64)),
19628            },
19629            next: next.clone(),
19630            backup_dir: backup_dir.clone(),
19631            entries: vec![V2PullJournalEntry {
19632                path: "records/value.md".to_string(),
19633                old: Some(V2PullFileCoordinate {
19634                    sha256: "e".repeat(64),
19635                    bytes: new.len() as u64,
19636                }),
19637                new: Some(V2PullFileCoordinate {
19638                    sha256: content_sha256(new),
19639                    bytes: new.len() as u64,
19640                }),
19641                backup: Some("00000000".to_string()),
19642            }],
19643        };
19644        store
19645            .write_private_atomic_new(
19646                Path::new(V2_PULL_JOURNAL),
19647                &v2_pull_journal_bytes(&journal).unwrap(),
19648            )
19649            .unwrap();
19650        let cfg = test_hub_config(
19651            "https://example.test".to_string(),
19652            sandbox.path().join("state"),
19653        );
19654        save_v2_baseline(
19655            &cfg,
19656            TEST_BRAIN_ID,
19657            &root,
19658            &V2SyncBaseline {
19659                v: 2,
19660                origin: "https://example.test".to_string(),
19661                brain: TEST_BRAIN_ID.to_string(),
19662                checkout_id: Some("c".repeat(64)),
19663                head_seq: next.head_seq,
19664                commit_hash: next.commit_hash.clone(),
19665                content_root: Some("f".repeat(64)),
19666                asset_root: None,
19667                assets: Default::default(),
19668                view_kind: next.view_kind.clone(),
19669                view_revision: next.view_revision.clone(),
19670                control_revision: Some("d".repeat(64)),
19671                projection_sha256: None,
19672                files: Default::default(),
19673                local_policy_digest: None,
19674                local_eligibility: Default::default(),
19675                remote_copy_remains: Default::default(),
19676            },
19677        )
19678        .unwrap();
19679
19680        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
19681
19682        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
19683        assert!(!root.join(V2_PULL_JOURNAL).exists());
19684        assert!(!root.join(backup_dir).exists());
19685        assert!(!root.join(orphan).exists());
19686    }
19687}