Skip to main content

dbmd_core/
linkmd.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The **link.md client** — the five interconnect verbs `dbmd` speaks against
4//! a hub: `resolve`, `sync`, `grant`, `propose`, `subscribe`.
5//!
6//! One binary, two specs (the git precedent: one binary carries both the
7//! object format and the wire protocol). The db.md FORMAT is untouched by this
8//! module: a store never needs link.md to be valid db.md, record files stay
9//! plain markdown, and SPEC.md reserves only the `@brain/id` address *shape*.
10//! Everything with a wire or a trust boundary — addressing across stores,
11//! pulling/pushing a hosted copy, capability grants, the propose door, feed
12//! polling and signed-entry verification — lives here, as a *client
13//! capability*, never a format requirement.
14//!
15//! # What this client speaks
16//!
17//! The v0 HTTP binding a hub serves under its base URL:
18//!
19//! | verb | binding |
20//! | --- | --- |
21//! | `resolve` | `GET /api/hub/brains/<brain>` (the brain card); records are read from the exact signed snapshot pack named by that card's verified feed head |
22//! | `sync` (pull) | `GET /api/hub/brains/<brain>/export?format=pack&atSeq=<n>&feedHash=<hash>` — the exact verified snapshot |
23//! | `sync` (push) | `POST /api/hub/brains/<brain>/push` for small snapshots; presign/upload/commit for large snapshots |
24//! | `grant` | `GET` / `POST /api/hub/brains/<brain>/grants`, `DELETE /api/hub/brains/<brain>/grants/<id>` |
25//! | `propose` | `POST /api/hub/sites/<handle>/inbox` — evidence in, without trust (unauthenticated by design) |
26//! | `subscribe` | `GET /api/hub/brains/<brain>` + `/feed` for a locally verified signed head |
27//!
28//! # Configuration — no default hub, credential never in the store
29//!
30//! There is **no built-in hub endpoint**: the toolkit is neutral and a hub is
31//! whatever the user points it at. Resolution order for the hub URL:
32//!
33//! 1. the `--hub <URL>` flag,
34//! 2. the `DBMD_HUB_URL` environment variable,
35//! 3. the `hub = <URL>` line in the store-local `.dbmd/config` file
36//!    (toolkit state, not store content — the walkers already skip hidden
37//!    directories, so `.dbmd/` never syncs, indexes, or validates).
38//!
39//! The credential is the `DBMD_HUB_KEY` environment variable, full stop. It is
40//! deliberately **not** read from `.dbmd/config`: a secret inside the store
41//! tree is one commit or one push away from leaking. A store-selected hub may
42//! receive an ambient bearer or agent key only when
43//! `DBMD_HUB_CREDENTIAL_ORIGIN` binds it to that exact origin. Identity pins
44//! and monotonic feed checkpoints live in the user's global state directory,
45//! never in store-controlled `.dbmd/`.
46//!
47//! Non-HTTPS hubs are refused (the bearer key must never travel in cleartext)
48//! with a loopback exemption for local development.
49//!
50//! # v0 honesty
51//!
52//! This client binds to what a hub enforces **today**: grantees are hub
53//! principals (an email), grant scopes are store-path prefixes, pushes are
54//! whole-store snapshots, and `subscribe` reports feed-head movement. The hub
55//! signs each committed snapshot in a hash-chained feed with a per-brain
56//! Ed25519 identity. This client verifies the rotation chain, signer epochs,
57//! monotonic feed checkpoint, snapshot token, and content-addressed pack before
58//! untrusted bytes touch their destination.
59
60use std::collections::{BTreeMap, BTreeSet};
61use std::io::{Cursor, Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{SystemTime, UNIX_EPOCH};
64
65use base64::{
66    engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
67    Engine as _,
68};
69use ring::signature::{UnparsedPublicKey, ED25519};
70use serde::{Deserialize, Serialize};
71use serde_json::{json, Value};
72use sha2::{Digest, Sha256};
73
74use crate::store::Store;
75
76/// Environment variable naming the hub base URL (e.g. `https://hub.example.com`).
77pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
78
79/// Environment variable carrying the hub bearer credential. The bearer
80/// credential source — see the module docs for why it is never store-based.
81pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
82
83/// Explicit origin binding required before an ambient bearer or agent key may
84/// be sent to a hub selected by untrusted store-local configuration. The value
85/// is an origin (`https://host[:port]`), not an arbitrary URL path.
86pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
87
88/// Override for dbmd's user-owned trust/checkpoint root. Must be absolute.
89/// Intended for managed installations and hermetic tests; untrusted stores
90/// cannot select it.
91pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
92
93/// Explicit test/development escape hatch for registry homes on loopback or
94/// private networks. Production federation rejects every non-public resolved
95/// address and pins the validated DNS answer into the HTTP agent.
96pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
97
98/// Explicit development escape hatch for object-store URLs on loopback/private
99/// networks. Production hubs must return public HTTPS URLs. A local loopback hub
100/// is allowed to use local object URLs without this switch.
101pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
102
103/// Environment variable naming the PATH of a self-custodied BRAIN key file
104/// (link.md §2.4). When set, `sync --push` signs each feed entry locally and
105/// ships it through the pack flow — the hub verifies and stores the exact
106/// client bytes and can never sign for the brain. Same file format as agent
107/// keys (`dbmd key generate`).
108pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
109
110/// Environment variable naming the PATH of an agent signing key file
111/// (link.md §8 `LinkMD-Sig` proof of possession). When set, authenticated
112/// requests are signed per-request with the agent's Ed25519 key instead of
113/// carrying a bearer: the signature binds method + path + body + a ±60s
114/// window, so nothing reusable ever crosses the wire or lands in a log or an
115/// agent transcript. The file holds the base64url PKCS#8 key minted by
116/// `dbmd key generate`; the path is not a secret, the file is (mode 0600).
117pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
118
119/// The store-local config file, relative to the store root. Holds non-secret
120/// toolkit state (`hub = <URL>`); hidden, so every store walk skips it.
121pub const CONFIG_REL_PATH: &str = ".dbmd/config";
122
123/// Control-plane JSON is metadata, never the store itself. Exact snapshot
124/// bytes travel through the separately bounded pack lane.
125const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
126/// A feed page can legitimately carry a large snapshot manifest, but remains
127/// far below a full pack and is parsed with count-bounded sequence visitors.
128const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
129/// Private v2 baselines also contain the local stat/hash cache for large
130/// checkouts. They never cross the wire, but remain bounded before parsing and
131/// writing so a corrupt cache cannot cause unbounded allocation.
132const MAX_V2_BASELINE_BYTES: u64 = 64 * 1024 * 1024;
133/// Foreign registry cards are identity metadata only.
134const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
135
136/// Direct JSON pushes stay below the serverless request-body cap. Larger
137/// snapshots switch to the bounded object-store pack lane.
138const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
139/// Ceiling for a staged change manifest. Mirrors the hub's bound so an
140/// oversized push is refused before it stages anything.
141const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
142
143/// Canonical raw ZIP32 uses a u16 entry count and stores each UTF-8 name twice.
144const MAX_PUSH_FILES: usize = u16::MAX as usize;
145const MAX_STORE_PATH_BYTES: usize = 1_024;
146const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
147/// Assets travel outside the Markdown store/pack lane and have their own
148/// protocol ceiling. Keep this distinct from `MAX_STORE_BYTES`: applying the
149/// pack bound to a signed asset leaf makes an otherwise valid checkout unable
150/// to load its own saved sync baseline after the first commit.
151const MAX_ASSET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
152/// Exact worst case for the canonical STORED profile:
153/// payload + (local 30 + central 46 + name twice) per entry + EOCD 22.
154const MAX_PACK_BYTES: u64 =
155    MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
156/// An upload reservation body carries one entry per changed blob, so a real
157/// store's first push does not fit in a single request: the hub bounds the body
158/// and the hosting platform rejects an oversized payload before the route sees
159/// it. Reservations are therefore batched. The budget is deliberately well
160/// under the hub's own ceiling, and both bounds are enforced because a single
161/// hash can carry many coordinates and a path may be long.
162/// The count matches the reservation window a hub grants per request; batching
163/// above it only fails deeper in, after the body has already been accepted.
164const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
165const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
166
167/// A legitimate brain should rotate rarely. Bound adversarial identity
168/// histories before allocating and repeatedly verifying an unbounded chain.
169const MAX_IDENTITY_ROTATIONS: usize = 1_024;
170
171/// Split blob declarations into request-sized batches, by serialized size and
172/// by count. Order is preserved so a caller can concatenate the responses.
173fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
174    let mut batches: Vec<Vec<Value>> = Vec::new();
175    let mut current: Vec<Value> = Vec::new();
176    let mut current_bytes = 0usize;
177    for declaration in declarations {
178        let declared_bytes = serde_json::to_string(&declaration)
179            .map(|text| text.len())
180            .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
181            + 1;
182        if !current.is_empty()
183            && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
184                || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
185        {
186            batches.push(std::mem::take(&mut current));
187            current_bytes = 0;
188        }
189        current_bytes += declared_bytes;
190        current.push(declaration);
191    }
192    if !current.is_empty() {
193        batches.push(current);
194    }
195    batches
196}
197/// A client never needs to replay an unbounded feed in one invocation. Large
198/// histories are mirrored incrementally; a first mirror beyond this cap needs a
199/// checkpoint/export rather than allocating attacker-controlled metadata.
200const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
201const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
202const FEED_PAGE_LIMIT: usize = 100;
203
204/// The hub's inbox cap on one `propose` submission body, mirrored client-side
205/// so an oversized body fails before the upload, not after (the same
206/// fail-before-upload contract as the push caps). Public so the CLI can
207/// pre-check a `--body-file` from file metadata without reading it.
208pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
209
210/// Bounded connect so a dead hub fails fast; a generous read window so a
211/// large export on a slow link still completes.
212const CONNECT_TIMEOUT_SECS: u64 = 10;
213const READ_TIMEOUT_SECS: u64 = 120;
214/// Hard wall-clock budget for one HTTP attempt, spanning socket writes,
215/// response headers, and the complete response body. DNS performed for pinned
216/// agents has its own interruptible deadline below.
217const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
218/// A commit of a whole store is brain-sized work the hub is allowed to spend
219/// its full route budget on, so the client has to outwait the server rather
220/// than the other way round. At the default 120 seconds a first push of a real
221/// store abandoned a commit the hub was still completing and reported a
222/// transport failure for work that had not failed. This sits above the hub's
223/// own genesis-commit ceiling so the client always learns the outcome.
224const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
225/// A commit is idempotent by mutation coordinate, so a lost response is worth
226/// asking after — and the work it lost the receipt for may still be finishing,
227/// which is why the waits are long enough to let a brain-sized commit land.
228const COMMIT_ATTEMPTS: usize = 4;
229const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
230const CONNECT_ATTEMPTS: usize = 3;
231const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
232/// Safe reads are idempotent, including authenticated GETs and the explicitly
233/// marked read-only POSTs whose proof lists do not fit in a URL. Retry the
234/// whole read when a connection dies after headers or part-way through its
235/// body; connect-only retries cannot recover that common large-export failure.
236const SAFE_READ_ATTEMPTS: usize = 4;
237const SAFE_READ_RETRY_BACKOFF_MS: [u64; SAFE_READ_ATTEMPTS - 1] = [200, 1_000, 3_000];
238
239/// Staging a real store means tens of thousands of immutable object PUTs in a
240/// row. A single refusal under sustained load must not fail the migration, so
241/// the upload lane retries further and waits longer than a connect blip does.
242const UPLOAD_ATTEMPTS: usize = 6;
243const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
244/// Retries share one wall-clock budget. Without this outer deadline, one
245/// black-holed object can consume the per-attempt timeout six times, outlive
246/// its presigned capability, and make a large migration appear hung.
247const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
248
249fn upload_retry_backoff_ms(attempt: usize) -> u64 {
250    UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
251}
252
253fn upload_deadline_error() -> LinkError {
254    LinkError::Transport {
255        hub: "the object store".to_string(),
256        message: "network error (upload deadline exceeded)".to_string(),
257    }
258}
259
260fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
261    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
262    if remaining.is_zero() {
263        return Err(upload_deadline_error());
264    }
265    Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
266}
267
268fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
269    if attempt + 1 >= UPLOAD_ATTEMPTS {
270        return false;
271    }
272    let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
273    if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
274        return false;
275    }
276    std::thread::sleep(pause);
277    true
278}
279
280/// A migration opens dozens of staging windows back to back, so it is ordinary
281/// for a shared limiter or a briefly busy hub to answer one of them with 429 or
282/// 5xx. Failing the push there throws away every byte already staged, and the
283/// waits have to reach past a whole limiter window to be worth anything.
284const RESERVATION_ATTEMPTS: usize = 7;
285const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
286    [500, 2_000, 5_000, 15_000, 30_000, 60_000];
287
288/// Answers a busy or briefly unhealthy hub gives, as opposed to a refusal that
289/// would repeat identically. A 4xx other than these states something about the
290/// request itself and is never retried.
291fn is_retryable_hub_status(status: u16) -> bool {
292    matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
293}
294
295/// Statuses worth replaying an immutable, content-addressed upload for: edge
296/// and object-store refusals that are load-shaped rather than a statement about
297/// these bytes. A checksum or precondition failure is never retried.
298fn is_retryable_upload_status(status: u16) -> bool {
299    matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
300}
301/// Cold checkouts may need every visible blob, but short-lived direct object
302/// capabilities must not create an unbounded connection storm. Keep the
303/// verified downloads inside this fixed worker count.
304const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
305/// File durability barriers are independent inside the private pull stage.
306/// Keep enough in flight to hide storage flush latency without approaching
307/// ordinary process descriptor limits.
308#[cfg(unix)]
309const V2_PULL_INSTALL_WORKERS: usize = 16;
310/// One authenticated link.md v2 bulk frame remains small enough for bounded
311/// retries and serverless streaming while amortizing tens of thousands of
312/// immutable blobs over a few hundred requests instead of one request each.
313const V2_BULK_STREAM_FILES: usize = 256;
314const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
315const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
316const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
317/// A read capability lives for five minutes. Ask only for the files the
318/// bounded worker pool can start immediately; collecting hundreds of signed
319/// asset URLs before consuming the first one deterministically expires the
320/// oldest capabilities on a company-sized brain.
321const V2_DOWNLOAD_CAPABILITY_FILES: usize = V2_BLOB_DOWNLOAD_WORKERS;
322const V2_DOWNLOAD_CAPABILITY_BYTES: u64 = 512 * 1024 * 1024;
323const V2_DOWNLOAD_CAPABILITY_ATTEMPTS: usize = 4;
324const V2_DOWNLOAD_CAPABILITY_BACKOFF_MS: [u64; V2_DOWNLOAD_CAPABILITY_ATTEMPTS - 1] =
325    [200, 1_000, 3_000];
326
327/// Everything that can go wrong on the wire or at its edges. Each variant maps
328/// onto one stable CLI error code; messages are single-line and never echo the
329/// credential.
330#[derive(Debug, thiserror::Error)]
331pub enum LinkError {
332    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
333    #[error(
334        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
335    )]
336    NoHub,
337
338    /// The verb needs a credential and none was present.
339    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
340    NoCredential,
341
342    /// The credential contains whitespace / non-ASCII (a paste artifact). The
343    /// key is deliberately not echoed.
344    #[error(
345        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
346    )]
347    BadKey,
348
349    /// A store selected the destination while an ambient credential was
350    /// present, but the operator did not bind that credential to the same
351    /// origin. This is a hard refusal, not an anonymous fallback: silently
352    /// dropping a credential can turn an intended private operation into a
353    /// confusing public one.
354    #[error(
355        "refusing to send an ambient credential to the hub selected by {CONFIG_REL_PATH} — set {HUB_CREDENTIAL_ORIGIN_ENV} to that exact origin, or choose the hub explicitly with --hub/{HUB_URL_ENV}"
356    )]
357    UnboundCredential,
358
359    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
360    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
361    /// echoed.
362    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
363    BadAgentKey {
364        /// What failed, without any key material.
365        message: String,
366    },
367
368    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
369    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
370    UnsafeHub {
371        /// The offending hub URL.
372        hub: String,
373    },
374
375    /// TCP/TLS-level failure: the hub never answered.
376    #[error("hub unreachable at {hub}: {message}")]
377    Transport {
378        /// The hub base URL.
379        hub: String,
380        /// The transport-layer error text.
381        message: String,
382    },
383
384    /// The hub answered with an HTTP error status.
385    #[error("{what} failed (HTTP {status}): {message}")]
386    Http {
387        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
388        what: &'static str,
389        /// The HTTP status code.
390        status: u16,
391        /// The hub's own `error` string when it sent one, else a placeholder.
392        message: String,
393        /// The hub's machine `code` field when it sent one.
394        code: Option<String>,
395        /// Permission-filtered structured refusal details, when supplied.
396        details: Option<Value>,
397    },
398
399    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
400    /// URL — refused here rather than deserializing into nothing downstream.
401    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
402    NotJson {
403        /// What the client was doing.
404        what: &'static str,
405        /// The (2xx) status that carried the non-JSON body.
406        status: u16,
407    },
408
409    /// The hub response exceeded the selected endpoint's byte cap.
410    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
411    ResponseTooLarge {
412        /// The endpoint-specific cap applied before JSON parsing.
413        limit_bytes: u64,
414    },
415
416    /// A malformed `@brain/id` address.
417    #[error("invalid address `{given}`: {reason}")]
418    BadAddress {
419        /// The raw address as typed.
420        given: String,
421        /// Why it did not parse.
422        reason: String,
423    },
424
425    /// A grant id whose shape cannot travel as a URL path segment.
426    #[error(
427        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
428    )]
429    BadGrantId {
430        /// The raw id as typed.
431        given: String,
432    },
433
434    /// An exported file path that would escape or pollute the destination
435    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
436    /// hub is not trusted with local path layout.
437    #[error("refusing unsafe path from the hub: `{path}`")]
438    UnsafePath {
439        /// The offending path as received.
440        path: String,
441    },
442
443    /// The store exceeds the hub's bounded whole-snapshot caps.
444    #[error(
445        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
446        MAX_STORE_BYTES / (1024 * 1024),
447        MAX_PACK_BYTES / (1024 * 1024)
448    )]
449    PushTooLarge {
450        /// Which cap was hit, human-readable.
451        detail: String,
452    },
453
454    /// The propose body exceeds the hub's inbox cap.
455    #[error(
456        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
457        MAX_PROPOSE_BYTES / 1024
458    )]
459    ProposeTooLarge {
460        /// The offending body size in bytes.
461        bytes: u64,
462    },
463
464    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
465    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
466    NotUtf8 {
467        /// The store-relative path of the offending file.
468        path: String,
469    },
470
471    /// A downloaded pack failed validation before any local write.
472    #[error("invalid store pack: {message}")]
473    InvalidPack {
474        /// Hash, ZIP, path, count, or expansion failure.
475        message: String,
476    },
477
478    /// A signed feed entry, hash chain, or advertised feed head did not verify.
479    #[error("invalid signed feed: {message}")]
480    InvalidFeed {
481        /// The failed integrity condition, without untrusted secret material.
482        message: String,
483    },
484
485    /// A mutable alias moved to another canonical brain. Ordinary sync fails
486    /// closed; only the exact old/new ids can authorize replacing that one
487    /// alias binding while preserving both canonical trust histories.
488    #[error(
489        "brain alias `{alias}` was pinned to `{from}` but now resolves to `{to}` — review both ids, then run `dbmd sync {alias} rebind --from {from} --to {to}`"
490    )]
491    AliasRebindRequired {
492        alias: String,
493        from: String,
494        to: String,
495    },
496
497    /// Local and remote both changed one or more coordinates since the last
498    /// verified sync baseline. No side was overwritten.
499    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
500    Conflict {
501        /// Bounded, portable paths safe to show to the agent/operator.
502        paths: Vec<String>,
503    },
504
505    /// A readable v2 conflict was preserved as a private, exact-head bundle.
506    /// The bundle is local control state; none of its bytes ride with the
507    /// brain, and resolving it always creates a fresh explicit mutation.
508    #[error(
509        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
510    )]
511    ConflictBundle {
512        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
513        bundle: String,
514        /// Bounded, readable conflict paths.
515        paths: Vec<String>,
516    },
517
518    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
519    /// explicit adoption boundary, never an accidental side effect of editing
520    /// or removing a local policy file.
521    #[error(
522        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
523    )]
524    LocalPolicyTransition {
525        /// Bounded local-only paths. They are never sent in telemetry.
526        paths: Vec<String>,
527    },
528
529    /// A currently hosted asset would become withheld. That custody change
530    /// requires an exact coordinate and a bounded company audit reason; a
531    /// manifest edit or `.sevralocal` rule cannot imply it.
532    #[error(
533        "hosted assets require explicit withdrawal {paths:?} — retry with one --withdraw-from-hosting <path> per asset and a non-empty --withdraw-reason"
534    )]
535    AssetWithdrawalRequired {
536        /// Bounded asset paths. They are never sent in telemetry.
537        paths: Vec<String>,
538    },
539
540    /// The exact mutation crosses a permissioned bulk-impact boundary. The
541    /// hub has not committed it; `preview` is the bounded, permission-filtered
542    /// receipt an agent must inspect before explicitly confirming the same
543    /// request.
544    #[error(
545        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
546    )]
547    BulkPreviewRequired {
548        /// Stable structured preview returned by the hub.
549        preview: Value,
550    },
551
552    /// A scoped checkout's generated store marker was edited or removed.
553    /// It is local projection metadata and is never accepted as brain data.
554    #[error(
555        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
556    )]
557    ScopedProjectionModified,
558
559    /// The effective permission slice changed after this checkout was pinned.
560    /// Reusing the directory could conceal removals or accidentally adopt files
561    /// revealed by a wider grant, so a new checkout is required.
562    #[error(
563        "the checkout's permission scope changed — clone into a new directory to accept the new view"
564    )]
565    ScopedViewChanged,
566
567    /// A v2 identity/head was previously accepted for this ref, but the hub
568    /// now hides it. Never reinterpret that as a v1 downgrade.
569    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
570    BrainUnavailable,
571
572    /// The verified remote head advanced while a pull or post-commit barrier
573    /// was in flight. The old baseline is deliberately retained.
574    #[error(
575        "the remote brain advanced during sync — retry to converge from the new verified head"
576    )]
577    RemoteAdvancedDuringSync,
578
579    /// This build cannot provide the no-follow, directory-handle-relative
580    /// filesystem semantics required for trust/key/snapshot state.
581    #[error(
582        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
583    )]
584    UnsupportedPlatform {
585        /// The operation that requires hardened local filesystem primitives.
586        operation: &'static str,
587    },
588
589    /// Local filesystem failure while materializing a pull or reading a push.
590    #[error(transparent)]
591    Io(#[from] std::io::Error),
592
593    /// A store-level failure (walking the local store for a push).
594    #[error(transparent)]
595    Store(#[from] crate::StoreError),
596}
597
598/// Result alias for link.md client operations.
599pub type LinkResult<T> = std::result::Result<T, LinkError>;
600
601/// Exact approval token returned by a permissioned v2 bulk preview.
602#[derive(Debug, Clone, PartialEq, Eq)]
603pub struct V2BulkConfirmation {
604    /// Opaque preview receipt id.
605    pub id: String,
606    /// Digest binding the receipt to principal, head, control revision, and
607    /// the exact strict mutation.
608    pub digest: String,
609}
610
611impl V2BulkConfirmation {
612    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
613    /// outside the protocol's exact lowercase shapes before network I/O.
614    pub fn parse(value: &str) -> LinkResult<Self> {
615        let (id, digest) = value
616            .split_once(':')
617            .ok_or_else(|| LinkError::InvalidPack {
618                message: "bulk confirmation must be <id>:<digest>".to_string(),
619            })?;
620        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
621            return Err(LinkError::InvalidPack {
622                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
623                    .to_string(),
624            });
625        }
626        Ok(Self {
627            id: id.to_string(),
628            digest: digest.to_string(),
629        })
630    }
631}
632
633/// Security-sensitive link.md state and destination writes require a hardened
634/// descriptor/handle-relative filesystem implementation. Unix uses
635/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
636/// refusal, exclusive share modes, and write-through replacement.
637fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
638    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
639    {
640        let _ = operation;
641        Ok(())
642    }
643    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
644    {
645        Err(LinkError::UnsupportedPlatform { operation })
646    }
647}
648
649// ─────────────────────────────────────────────────────────────────────────────
650// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
651// ─────────────────────────────────────────────────────────────────────────────
652
653/// What the part after `@brain/` names.
654#[derive(Debug, Clone, PartialEq, Eq)]
655pub enum AddressTarget {
656    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
657    Id(String),
658    /// A store-relative `.md` path — a client-side convenience the hub's
659    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
660    /// shape; unambiguous because a ULID is never a path.
661    Path(String),
662}
663
664/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
665/// and the per-verb entry gates so the two surfaces never drift.
666const BAD_BRAIN_REASON: &str =
667    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
668
669/// Why an address target failed its shape check — shared by [`Address::parse`]
670/// and the [`resolve`] entry gate.
671const BAD_TARGET_REASON: &str =
672    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
673
674/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
675/// brain's ULID id (works for any caller, including cross-party on a public
676/// brain) or a slug (which a hub resolves only against the caller's own
677/// brains; slugs are unique per owner, not globally).
678#[derive(Debug, Clone, PartialEq, Eq)]
679pub struct Address {
680    /// The brain reference (leading `@` stripped).
681    pub brain: String,
682    /// The record target, when the address names one.
683    pub target: Option<AddressTarget>,
684}
685
686impl Address {
687    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
688    /// sigil is optional (an agent piping ids around should not have to quote
689    /// it back on). Whitespace and empty segments are malformed.
690    pub fn parse(raw: &str) -> LinkResult<Address> {
691        let bad = |reason: &str| LinkError::BadAddress {
692            given: raw.to_string(),
693            reason: reason.to_string(),
694        };
695
696        let trimmed = raw.trim();
697        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
698        if body.is_empty() {
699            return Err(bad("empty address"));
700        }
701
702        let (brain, rest) = match body.split_once('/') {
703            Some((b, r)) => (b, Some(r)),
704            None => (body, None),
705        };
706
707        if brain.is_empty() {
708            return Err(bad("missing brain reference before `/`"));
709        }
710        if !is_safe_ref(brain) {
711            return Err(bad(BAD_BRAIN_REASON));
712        }
713
714        let target = match rest {
715            None => None,
716            Some("") => return Err(bad("trailing `/` with no record id or path")),
717            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
718            Some(r) => {
719                if !safe_store_rel_path(r) || !r.ends_with(".md") {
720                    return Err(bad(BAD_TARGET_REASON));
721                }
722                Some(AddressTarget::Path(r.to_string()))
723            }
724        };
725
726        Ok(Address {
727            brain: brain.to_string(),
728            target,
729        })
730    }
731}
732
733/// A brain reference safe to embed in a URL path segment: the shapes a hub
734/// accepts (ULID id or slug), which are also exactly URL-path-clean.
735fn is_safe_ref(s: &str) -> bool {
736    !s.is_empty()
737        && s.len() <= 64
738        && s.bytes()
739            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
740}
741
742/// A published-site handle (the `propose` target). Same lexical shape as a
743/// slug.
744pub fn is_valid_handle(s: &str) -> bool {
745    is_safe_ref(s)
746}
747
748/// True when `p` is a store-relative path this client will read from or write
749/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
750/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
751/// every path an export hands us (the hub is not trusted with local layout)
752/// and to every path a push sends (mirroring the hub's own gate).
753pub fn safe_store_rel_path(p: &str) -> bool {
754    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
755        return false;
756    }
757    if !p
758        .bytes()
759        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
760    {
761        return false;
762    }
763    p.split('/')
764        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
765}
766
767/// Entry gate for every verb that embeds a caller-supplied brain reference in
768/// a URL path segment. `resolve` reaches the same check through
769/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
770/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
771/// URL-reshaping byte is refused before a request exists (the `url` crate
772/// normalizes dot segments, so an unvalidated ref would redirect the
773/// authenticated request to a different hub path).
774fn require_safe_ref(brain: &str) -> LinkResult<()> {
775    if is_safe_ref(brain) {
776        Ok(())
777    } else {
778        Err(LinkError::BadAddress {
779            given: brain.to_string(),
780            reason: BAD_BRAIN_REASON.to_string(),
781        })
782    }
783}
784
785/// Entry gate for the published-site handle `propose` embeds in its URL path.
786fn require_valid_handle(handle: &str) -> LinkResult<()> {
787    if is_valid_handle(handle) {
788        Ok(())
789    } else {
790        Err(LinkError::BadAddress {
791            given: handle.to_string(),
792            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
793        })
794    }
795}
796
797/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
798/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
799/// shape as a brain ref rather than pinning one mint scheme.
800fn require_safe_grant_id(id: &str) -> LinkResult<()> {
801    if is_safe_ref(id) {
802        Ok(())
803    } else {
804        Err(LinkError::BadGrantId {
805            given: id.to_string(),
806        })
807    }
808}
809
810// ─────────────────────────────────────────────────────────────────────────────
811// Configuration — flag > env > .dbmd/config; credential from env only
812// ─────────────────────────────────────────────────────────────────────────────
813
814/// The resolved client configuration for one invocation.
815#[derive(Debug, Clone)]
816pub struct HubConfig {
817    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
818    pub hub: String,
819    /// The bearer credential, when the environment carries one.
820    pub key: Option<String>,
821    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
822    /// over the bearer for authenticated requests (link.md §8).
823    pub agent_key: Option<AgentSigningKey>,
824    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
825    /// names one — `sync --push` then signs feed entries locally (§2.4).
826    pub brain_key: Option<AgentSigningKey>,
827    /// User-owned global toolkit state root. Identity pins and monotonic feed
828    /// checkpoints live below `<state_dir>/trust/`, never under a store.
829    pub state_dir: PathBuf,
830    /// True only when the origin came from untrusted store-local configuration.
831    /// Such origins are resolved and public-IP-pinned for every request.
832    store_selected: bool,
833}
834
835/// A loaded agent signing key: the PKCS#8 secret plus its derived public
836/// multikey. Debug never prints key material.
837#[derive(Clone)]
838pub struct AgentSigningKey {
839    pkcs8: Vec<u8>,
840    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
841    pub multikey: String,
842    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
843    pub public_key_spki: String,
844}
845
846impl std::fmt::Debug for AgentSigningKey {
847    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
848        f.debug_struct("AgentSigningKey")
849            .field("multikey", &self.multikey)
850            .field("pkcs8", &"<redacted>")
851            .finish()
852    }
853}
854
855impl HubConfig {
856    /// The credential, or the canonical "not configured" error. Verbs that
857    /// authenticate call this; `propose` never does.
858    pub fn require_key(&self) -> LinkResult<&str> {
859        self.key.as_deref().ok_or(LinkError::NoCredential)
860    }
861}
862
863/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
864/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
865/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
866/// header token (never echoed on failure).
867pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
868    let explicit_hub = flag_hub
869        .map(str::to_string)
870        .or_else(|| env_nonempty(HUB_URL_ENV));
871    let selected_by_store = explicit_hub.is_none();
872    let hub = explicit_hub
873        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
874        .ok_or(LinkError::NoHub)?;
875    let hub = hub.trim().trim_end_matches('/').to_string();
876    assert_safe_hub(&hub)?;
877    if selected_by_store {
878        let parsed =
879            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
880        // A cloned store is not an operator opt-in to local-network access.
881        // Local/private development hubs must be selected explicitly by flag or
882        // environment, never by bytes inside the store.
883        if !parsed.scheme().eq_ignore_ascii_case("https")
884            || (parsed.path() != "/" && !parsed.path().is_empty())
885        {
886            return Err(LinkError::UnsafeHub { hub });
887        }
888    }
889
890    let key = match env_nonempty(HUB_KEY_ENV) {
891        Some(raw) => Some(clean_key(&raw)?),
892        None => None,
893    };
894
895    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
896        Some(path) => Some(load_agent_key(Path::new(&path))?),
897        None => None,
898    };
899
900    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
901        Some(path) => Some(load_agent_key(Path::new(&path))?),
902        None => None,
903    };
904
905    // A cloned store controls `.dbmd/config`. It must never be able to point
906    // the process at an attacker origin and harvest the user's ambient account
907    // bearer, signed agent identity, or self-custodied brain signatures/content.
908    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
909    // in the invocation environment; a store-selected target additionally
910    // needs an exact origin binding.
911    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
912        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
913            .and_then(|value| normalized_origin(&value).ok());
914        let selected_origin = normalized_origin(&hub)?;
915        if bound.as_deref() != Some(selected_origin.as_str()) {
916            return Err(LinkError::UnboundCredential);
917        }
918    }
919
920    Ok(HubConfig {
921        hub,
922        key,
923        agent_key,
924        brain_key,
925        state_dir: toolkit_state_dir()?,
926        store_selected: selected_by_store,
927    })
928}
929
930fn toolkit_state_dir() -> LinkResult<PathBuf> {
931    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
932        let path = PathBuf::from(path);
933        if !path.is_absolute() {
934            return Err(LinkError::UnsafePath {
935                path: path.display().to_string(),
936            });
937        }
938        return Ok(path);
939    }
940    #[cfg(windows)]
941    if let Some(base) = env_nonempty("LOCALAPPDATA") {
942        let base = PathBuf::from(base);
943        if base.is_absolute() {
944            return Ok(base.join("dbmd").join("state"));
945        }
946    }
947    #[cfg(windows)]
948    {
949        Err(LinkError::Io(std::io::Error::new(
950            std::io::ErrorKind::NotFound,
951            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
952        )))
953    }
954    #[cfg(not(windows))]
955    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
956        let base = PathBuf::from(base);
957        if base.is_absolute() {
958            return Ok(base.join("dbmd"));
959        }
960    }
961    #[cfg(not(windows))]
962    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
963        LinkError::Io(std::io::Error::new(
964            std::io::ErrorKind::NotFound,
965            format!("cannot locate user state; set {STATE_DIR_ENV}"),
966        ))
967    })?);
968    #[cfg(not(windows))]
969    if !home.is_absolute() {
970        return Err(LinkError::UnsafePath {
971            path: home.display().to_string(),
972        });
973    }
974    #[cfg(target_os = "macos")]
975    {
976        Ok(home
977            .join("Library")
978            .join("Application Support")
979            .join("dbmd")
980            .join("state"))
981    }
982    #[cfg(all(not(target_os = "macos"), not(windows)))]
983    {
984        Ok(home.join(".local").join("state").join("dbmd"))
985    }
986}
987
988fn normalized_origin(value: &str) -> LinkResult<String> {
989    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
990        hub: value.to_string(),
991    })?;
992    if !(parsed.scheme().eq_ignore_ascii_case("https")
993        || parsed.scheme().eq_ignore_ascii_case("http"))
994        || !parsed.username().is_empty()
995        || parsed.password().is_some()
996        || (parsed.path() != "/" && !parsed.path().is_empty())
997        || parsed.query().is_some()
998        || parsed.fragment().is_some()
999    {
1000        return Err(LinkError::UnsafeHub {
1001            hub: value.to_string(),
1002        });
1003    }
1004    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
1005        hub: value.to_string(),
1006    })?;
1007    let host = if host.contains(':') {
1008        format!("[{host}]")
1009    } else {
1010        host.to_ascii_lowercase()
1011    };
1012    let port = parsed
1013        .port_or_known_default()
1014        .ok_or_else(|| LinkError::UnsafeHub {
1015            hub: value.to_string(),
1016        })?;
1017    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
1018        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
1019    Ok(format!(
1020        "{}://{}{}",
1021        parsed.scheme().to_ascii_lowercase(),
1022        host,
1023        if default {
1024            String::new()
1025        } else {
1026            format!(":{port}")
1027        }
1028    ))
1029}
1030
1031// ─────────────────────────────────────────────────────────────────────────────
1032// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
1033// ─────────────────────────────────────────────────────────────────────────────
1034
1035/// The DER prefix that wraps a raw Ed25519 public key into a
1036/// SubjectPublicKeyInfo (RFC 8410).
1037const ED25519_SPKI_PREFIX: [u8; 12] = [
1038    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1039];
1040
1041fn bad_agent_key(message: &str) -> LinkError {
1042    LinkError::BadAgentKey {
1043        message: message.to_string(),
1044    }
1045}
1046
1047fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1048    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
1049    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
1050    // deriving the public half itself.
1051    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1052        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1053        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1054}
1055
1056/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
1057fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1058    use ring::signature::KeyPair as _;
1059    let mut spki = Vec::with_capacity(44);
1060    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1061    spki.extend_from_slice(pair.public_key().as_ref());
1062    (
1063        URL_SAFE_NO_PAD.encode(&spki),
1064        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1065    )
1066}
1067
1068/// Load and validate a signing-key file (agent or brain — same format):
1069/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
1070/// old key explicitly.
1071pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1072    load_agent_key(path)
1073}
1074
1075/// Load and validate the agent key file: one base64url line of PKCS#8.
1076fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1077    #[cfg(unix)]
1078    let file = {
1079        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1080        use std::os::unix::ffi::OsStrExt as _;
1081        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1082            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1083        let leaf = path
1084            .file_name()
1085            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1086        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1087        let fd = unsafe {
1088            libc::openat(
1089                parent.as_raw_fd(),
1090                leaf.as_ptr(),
1091                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1092            )
1093        };
1094        if fd < 0 {
1095            return Err(bad_agent_key(
1096                "the key path must be an existing regular file without symlink ancestors",
1097            ));
1098        }
1099        unsafe { std::fs::File::from_raw_fd(fd) }
1100    };
1101    #[cfg(not(unix))]
1102    let file = std::fs::File::open(path)
1103        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1104    let metadata = file
1105        .metadata()
1106        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1107    if !metadata.is_file() {
1108        return Err(bad_agent_key("the key path must be a regular file"));
1109    }
1110    #[cfg(unix)]
1111    {
1112        use std::os::unix::fs::PermissionsExt as _;
1113        if metadata.permissions().mode() & 0o077 != 0 {
1114            return Err(bad_agent_key(
1115                "the key file is accessible to group/other; set mode 0600",
1116            ));
1117        }
1118    }
1119    let mut text = String::new();
1120    file.take(1024 * 1024 + 1)
1121        .read_to_string(&mut text)
1122        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1123    if text.len() > 1024 * 1024 {
1124        return Err(bad_agent_key("the key file exceeds the size limit"));
1125    }
1126    let pkcs8 = URL_SAFE_NO_PAD
1127        .decode(text.trim())
1128        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1129    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1130    Ok(AgentSigningKey {
1131        pkcs8,
1132        multikey,
1133        public_key_spki,
1134    })
1135}
1136
1137/// Durably create a new secret file without an exists→write race and without
1138/// ever exposing a default-mode (commonly 0644) key between write and chmod.
1139/// `create_new` also refuses a planted symlink. The file and its parent
1140/// directory are synced before the caller can publish the corresponding
1141/// public identity remotely.
1142fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1143    #[cfg(unix)]
1144    let (mut file, parent, leaf) = {
1145        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1146        use std::os::unix::ffi::OsStrExt as _;
1147        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1148        let leaf_name = path
1149            .file_name()
1150            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1151        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1152        let fd = unsafe {
1153            libc::openat(
1154                parent.as_raw_fd(),
1155                leaf.as_ptr(),
1156                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1157                0o600,
1158            )
1159        };
1160        if fd < 0 {
1161            let error = std::io::Error::last_os_error();
1162            if error.kind() == std::io::ErrorKind::AlreadyExists {
1163                return Err(bad_agent_key(
1164                    "the output file already exists — refusing to overwrite a key",
1165                ));
1166            }
1167            return Err(error.into());
1168        }
1169        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1170    };
1171    #[cfg(not(unix))]
1172    let mut file = std::fs::OpenOptions::new()
1173        .write(true)
1174        .create_new(true)
1175        .open(path)
1176        .map_err(|error| {
1177            if error.kind() == std::io::ErrorKind::AlreadyExists {
1178                bad_agent_key("the output file already exists — refusing to overwrite a key")
1179            } else {
1180                LinkError::Io(error)
1181            }
1182        })?;
1183    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1184        drop(file);
1185        #[cfg(unix)]
1186        let _ =
1187            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1188        #[cfg(not(unix))]
1189        let _ = std::fs::remove_file(path);
1190        return Err(LinkError::Io(error));
1191    }
1192    drop(file);
1193    #[cfg(unix)]
1194    parent.sync_all()?;
1195    Ok(())
1196}
1197
1198/// What `dbmd key generate` returns: the public identity to register plus
1199/// where the secret landed.
1200#[derive(Debug, Serialize)]
1201pub struct GeneratedAgentKey {
1202    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1203    pub multikey: String,
1204    /// base64url SPKI DER — what a hub's register endpoint takes.
1205    #[serde(rename = "publicKeySpki")]
1206    pub public_key_spki: String,
1207    /// Where the PKCS#8 secret was written (mode 0600).
1208    #[serde(rename = "keyFile")]
1209    pub key_file: String,
1210}
1211
1212/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1213/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1214/// identity is returned. The private key never enters a store and never
1215/// travels — requests carry per-request signatures instead (link.md §8).
1216pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1217    require_hardened_filesystem("key generation")?;
1218    let rng = ring::rand::SystemRandom::new();
1219    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1220        .map_err(|_| bad_agent_key("key generation failed"))?;
1221    let pair = agent_keypair(pkcs8.as_ref())?;
1222    let (spki_b64u, multikey) = public_identity_for(&pair);
1223
1224    write_secret_new(
1225        out,
1226        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1227    )?;
1228
1229    Ok(GeneratedAgentKey {
1230        multikey,
1231        public_key_spki: spki_b64u,
1232        key_file: out.display().to_string(),
1233    })
1234}
1235
1236/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1237/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1238/// (sha256hex(body) | "-")`.
1239///
1240/// The origin is derived from the already-validated hub URL, never from
1241/// attacker-controlled request metadata. Binding it closes the v1 replay
1242/// class where a proof captured by one hub could be replayed to another hub
1243/// serving the same path inside the timestamp window.
1244fn linkmd_sig_header(
1245    key: &AgentSigningKey,
1246    origin: &str,
1247    method: &str,
1248    path: &str,
1249    body: Option<&str>,
1250) -> LinkResult<String> {
1251    let ts = std::time::SystemTime::now()
1252        .duration_since(std::time::UNIX_EPOCH)
1253        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1254        .as_secs();
1255    let body_hash = match body {
1256        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1257        None => "-".to_string(),
1258    };
1259    let canonical = format!(
1260        "v2\n{}\n{}\n{}\n{}\n{}",
1261        origin,
1262        method.to_uppercase(),
1263        path,
1264        ts,
1265        body_hash
1266    );
1267    let pair = agent_keypair(&key.pkcs8)?;
1268    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1269    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1270    Ok(format!(
1271        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1272    ))
1273}
1274
1275// ─────────────────────────────────────────────────────────────────────────────
1276// Self-custody feed entries — the client signs what the hub only verifies
1277// ─────────────────────────────────────────────────────────────────────────────
1278
1279/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1280/// exactly this order).
1281#[derive(Serialize)]
1282struct WireFeedFile {
1283    path: String,
1284    sha256: String,
1285    bytes: u64,
1286}
1287
1288/// The unsigned entry in the normative §5.1 field order — serde serializes
1289/// struct fields in declaration order, which IS the wire contract.
1290#[derive(Serialize)]
1291struct UnsignedWireEntry<'a> {
1292    v: u8,
1293    seq: u64,
1294    ts: String,
1295    brain: &'a str,
1296    public_key: &'a str,
1297    kind: &'a str,
1298    op: &'a str,
1299    pack_sha256: &'a str,
1300    files: &'a [WireFeedFile],
1301    removed: &'a [String],
1302    prev_entry_hash: Option<&'a str>,
1303}
1304
1305/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1306/// brain key: serialize the unsigned entry compactly in the normative order,
1307/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1308/// returned string is the exact serialization the hub stores verbatim (plus
1309/// one trailing newline) and every independent reader re-derives.
1310fn self_custody_entry(
1311    key: &AgentSigningKey,
1312    seq: u64,
1313    ts: String,
1314    pack_sha256: &str,
1315    files: &[WireFeedFile],
1316    prev_entry_hash: Option<&str>,
1317) -> LinkResult<String> {
1318    let removed: [String; 0] = [];
1319    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1320        v: 1,
1321        seq,
1322        ts,
1323        brain: &key.multikey,
1324        public_key: &key.public_key_spki,
1325        kind: "push",
1326        op: "snapshot",
1327        pack_sha256,
1328        files,
1329        removed: &removed,
1330        prev_entry_hash,
1331    })
1332    .expect("serialize feed entry");
1333    let pair = agent_keypair(&key.pkcs8)?;
1334    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1335    Ok(format!(
1336        "{},\"sig\":\"{}\"}}",
1337        &unsigned[..unsigned.len() - 1],
1338        sig
1339    ))
1340}
1341
1342/// An env var, treated as absent when unset or empty (an empty
1343/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1344fn env_nonempty(name: &str) -> Option<String> {
1345    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1346}
1347
1348/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1349/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1350/// ignored (forward-compatible). A missing or unreadable file is simply "not
1351/// configured here".
1352fn config_file_hub(path: &Path) -> Option<String> {
1353    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1354    #[cfg(unix)]
1355    let file = {
1356        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1357        use std::os::unix::ffi::OsStrExt as _;
1358        let parent =
1359            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1360        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1361        let fd = unsafe {
1362            libc::openat(
1363                parent.as_raw_fd(),
1364                leaf.as_ptr(),
1365                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1366            )
1367        };
1368        if fd < 0 {
1369            return None;
1370        }
1371        unsafe { std::fs::File::from_raw_fd(fd) }
1372    };
1373    #[cfg(not(unix))]
1374    let file = std::fs::File::open(path).ok()?;
1375    let metadata = file.metadata().ok()?;
1376    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1377        return None;
1378    }
1379    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1380    file.take(MAX_CONFIG_BYTES + 1)
1381        .read_to_end(&mut bytes)
1382        .ok()?;
1383    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1384        return None;
1385    }
1386    let text = String::from_utf8(bytes).ok()?;
1387    for line in text.lines() {
1388        let line = line.trim();
1389        if line.is_empty() || line.starts_with('#') {
1390            continue;
1391        }
1392        if let Some((k, v)) = line.split_once('=') {
1393            if k.trim() == "hub" {
1394                let v = v.trim();
1395                if !v.is_empty() {
1396                    return Some(v.to_string());
1397                }
1398            }
1399        }
1400    }
1401    None
1402}
1403
1404/// The bearer key must never travel in cleartext; only loopback hosts may
1405/// skip TLS (local development against a hub on localhost).
1406fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1407    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1408        hub: hub.to_string(),
1409    })?;
1410    if !(parsed.scheme().eq_ignore_ascii_case("https")
1411        || parsed.scheme().eq_ignore_ascii_case("http"))
1412        || !parsed.username().is_empty()
1413        || parsed.password().is_some()
1414        || (parsed.path() != "/" && !parsed.path().is_empty())
1415        || parsed.query().is_some()
1416        || parsed.fragment().is_some()
1417    {
1418        return Err(LinkError::UnsafeHub {
1419            hub: hub.to_string(),
1420        });
1421    }
1422    let loopback = match parsed.host() {
1423        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1424        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1425        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1426        None => false,
1427    };
1428    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1429        Ok(())
1430    } else {
1431        Err(LinkError::UnsafeHub {
1432            hub: hub.to_string(),
1433        })
1434    }
1435}
1436
1437/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1438/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1439/// value tends to echo the whole header line, credential included, so the
1440/// gate sits here instead.
1441fn clean_key(raw: &str) -> LinkResult<String> {
1442    let k = raw.trim();
1443    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1444        return Err(LinkError::BadKey);
1445    }
1446    Ok(k.to_string())
1447}
1448
1449// ─────────────────────────────────────────────────────────────────────────────
1450// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1451// ─────────────────────────────────────────────────────────────────────────────
1452
1453/// One hub response: the status plus the parsed JSON body when there was one.
1454#[derive(Debug)]
1455pub struct HubResponse {
1456    /// The HTTP status code.
1457    pub status: u16,
1458    /// The parsed JSON body, `None` when the body was empty or not JSON.
1459    pub body: Option<Value>,
1460}
1461
1462struct RawHubResponse {
1463    status: u16,
1464    body: Vec<u8>,
1465}
1466
1467/// Whether a request carries the bearer credential.
1468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1469enum Auth {
1470    /// Send `authorization: Bearer <key>`; error without a key.
1471    Required,
1472    /// Send no credential — the propose door is unauthenticated by design.
1473    None,
1474    /// Send the configured credential when one exists, otherwise nothing —
1475    /// brain-addressed propose works anonymously on public brains, and an
1476    /// authenticated caller earns a bigger actor-class budget.
1477    Optional,
1478}
1479
1480fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1481    ureq::AgentBuilder::new()
1482        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1483        // Never follow a redirect while a bearer, a store pack, or a signed
1484        // response is in flight. Callers see the 3xx as a non-success instead
1485        // of letting an origin steer sensitive material elsewhere.
1486        .redirects(0)
1487        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1488        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1489        .timeout_write(overall)
1490        .timeout(overall)
1491}
1492
1493fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1494    hub_agent_with_timeout(
1495        cfg,
1496        std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1497    )
1498}
1499
1500fn hub_agent_with_timeout(
1501    cfg: &HubConfig,
1502    overall: std::time::Duration,
1503) -> LinkResult<ureq::Agent> {
1504    if !cfg.store_selected {
1505        return Ok(agent_builder_with_timeout(overall).build());
1506    }
1507    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1508        hub: cfg.hub.clone(),
1509    })?;
1510    pinned_public_agent_pooled(
1511        &parsed,
1512        false,
1513        "store-selected hub",
1514        AgentShape {
1515            overall,
1516            ..AgentShape::default()
1517        },
1518    )
1519}
1520
1521/// Perform one hub request. `path` is the binding path (starts with `/`);
1522/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1523/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1524/// the [`HubResponse`] for [`ensure_ok`] to shape.
1525fn request_raw(
1526    cfg: &HubConfig,
1527    method: &str,
1528    path: &str,
1529    body: Option<&Value>,
1530    auth: Auth,
1531    max_response_bytes: u64,
1532) -> LinkResult<RawHubResponse> {
1533    let http = hub_agent(cfg)?;
1534    request_raw_with_agent(
1535        cfg,
1536        &http,
1537        method,
1538        path,
1539        body,
1540        RawRequestOptions {
1541            auth,
1542            max_response_bytes,
1543            request_id: None,
1544            retry_transport: false,
1545        },
1546    )
1547}
1548
1549/// A body-bearing request that reads immutable state without changing hub
1550/// authority. Some proof lists are too large for a URL, so link.md transports
1551/// them with POST even though repeating the request is side-effect free.
1552fn request_raw_retryable_read(
1553    cfg: &HubConfig,
1554    method: &str,
1555    path: &str,
1556    body: Option<&Value>,
1557    auth: Auth,
1558    max_response_bytes: u64,
1559) -> LinkResult<RawHubResponse> {
1560    let http = hub_agent(cfg)?;
1561    request_raw_with_agent(
1562        cfg,
1563        &http,
1564        method,
1565        path,
1566        body,
1567        RawRequestOptions {
1568            auth,
1569            max_response_bytes,
1570            request_id: None,
1571            retry_transport: true,
1572        },
1573    )
1574}
1575
1576struct RawRequestOptions<'a> {
1577    auth: Auth,
1578    max_response_bytes: u64,
1579    request_id: Option<&'a str>,
1580    retry_transport: bool,
1581}
1582
1583fn request_raw_with_agent(
1584    cfg: &HubConfig,
1585    http: &ureq::Agent,
1586    method: &str,
1587    path: &str,
1588    body: Option<&Value>,
1589    options: RawRequestOptions<'_>,
1590) -> LinkResult<RawHubResponse> {
1591    let url = format!("{}{}", cfg.hub, path);
1592    let encoded_body = body.map(Value::to_string);
1593    let origin = normalized_origin(&cfg.hub)?;
1594    let safe_read = (method == "GET" && encoded_body.is_none()) || options.retry_transport;
1595    let mut read_attempt = 0;
1596    loop {
1597        // An agent signing key outranks the bearer: possession proofs put
1598        // nothing reusable on the wire, so when both are configured the
1599        // stronger one wins. Regenerate its timestamp for a whole-request GET
1600        // retry; a long stalled read must not age the replacement signature
1601        // outside the hub's acceptance window. This applies only to GETs and
1602        // the explicitly marked read-only POST bindings above.
1603        let credential = match options.auth {
1604            Auth::Required => Some(match &cfg.agent_key {
1605                Some(key) => {
1606                    linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?
1607                }
1608                None => format!("Bearer {}", cfg.require_key()?),
1609            }),
1610            Auth::Optional => match &cfg.agent_key {
1611                Some(key) => Some(linkmd_sig_header(
1612                    key,
1613                    &origin,
1614                    method,
1615                    path,
1616                    encoded_body.as_deref(),
1617                )?),
1618                None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1619            },
1620            Auth::None => None,
1621        };
1622        let result = with_connect_retries(|| {
1623            let mut req = http.request(method, &url);
1624            if let Some(value) = &credential {
1625                req = req.set("authorization", value);
1626            }
1627            if let Some(value) = options.request_id {
1628                req = req.set("x-request-id", value);
1629            }
1630            match &encoded_body {
1631                Some(value) => req
1632                    .set("content-type", "application/json")
1633                    .send_string(value)
1634                    .map_err(Box::new),
1635                None => req.call().map_err(Box::new),
1636            }
1637        });
1638        let resp = match result {
1639            Ok(resp) => resp,
1640            Err(error) => match *error {
1641                ureq::Error::Status(_, resp) => resp,
1642                ureq::Error::Transport(error) => {
1643                    if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS {
1644                        std::thread::sleep(std::time::Duration::from_millis(
1645                            SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1646                        ));
1647                        read_attempt += 1;
1648                        continue;
1649                    }
1650                    return Err(LinkError::Transport {
1651                        hub: cfg.hub.clone(),
1652                        message: error.to_string(),
1653                    });
1654                }
1655            },
1656        };
1657
1658        let status = resp.status();
1659        let buf = match read_response_body(resp, options.max_response_bytes + 1, &cfg.hub) {
1660            Ok(buf) => buf,
1661            Err(LinkError::Transport { .. })
1662                if safe_read && read_attempt + 1 < SAFE_READ_ATTEMPTS =>
1663            {
1664                std::thread::sleep(std::time::Duration::from_millis(
1665                    SAFE_READ_RETRY_BACKOFF_MS[read_attempt],
1666                ));
1667                read_attempt += 1;
1668                continue;
1669            }
1670            Err(error) => return Err(error),
1671        };
1672        if buf.len() as u64 > options.max_response_bytes {
1673            return Err(LinkError::ResponseTooLarge {
1674                limit_bytes: options.max_response_bytes,
1675            });
1676        }
1677        return Ok(RawHubResponse { status, body: buf });
1678    }
1679}
1680
1681fn request_capped(
1682    cfg: &HubConfig,
1683    method: &str,
1684    path: &str,
1685    body: Option<&Value>,
1686    auth: Auth,
1687    max_response_bytes: u64,
1688) -> LinkResult<HubResponse> {
1689    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1690    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1691    Ok(HubResponse {
1692        status: raw.status,
1693        body: parsed,
1694    })
1695}
1696
1697/// One request the hub is allowed to take brain-sized time over. Used for the
1698/// commit lane, where abandoning the attempt early does not cancel the work —
1699/// it only loses the receipt for it.
1700///
1701/// Which is why a lost connection is asked again rather than reported. The
1702/// mutation coordinate is derived from the observed base and the exact
1703/// operation set precisely so a repeat is the SAME effect: the hub answers a
1704/// mutation it already applied with that commit's receipt instead of a second
1705/// commit. Measured on a 32,338-file store: the commit landed durably and the
1706/// push reported a transport failure, because nobody went back for the
1707/// receipt.
1708fn request_patient(
1709    cfg: &HubConfig,
1710    method: &str,
1711    path: &str,
1712    body: Option<&Value>,
1713    auth: Auth,
1714) -> LinkResult<HubResponse> {
1715    let http = hub_agent_with_timeout(
1716        cfg,
1717        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1718    )?;
1719    let mut attempt = 0;
1720    let validation_started = std::time::Instant::now();
1721    let mut validation_wait = std::time::Duration::from_millis(250);
1722    loop {
1723        let sent = request_raw_with_agent(
1724            cfg,
1725            &http,
1726            method,
1727            path,
1728            body,
1729            RawRequestOptions {
1730                auth,
1731                max_response_bytes: MAX_RESPONSE_BYTES,
1732                request_id: None,
1733                retry_transport: false,
1734            },
1735        );
1736        match sent {
1737            Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1738                std::thread::sleep(std::time::Duration::from_millis(
1739                    COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1740                ));
1741                attempt += 1;
1742            }
1743            Err(error) => return Err(error),
1744            Ok(raw) => {
1745                let response = HubResponse {
1746                    status: raw.status,
1747                    body: serde_json::from_slice(&raw.body).ok(),
1748                };
1749                // A patient mutation can land, lose its response, and then be
1750                // retried while validation recovery is still behind the new
1751                // source head. The hub returns the same typed lag signal as
1752                // the head endpoint. Repeating the exact content-derived
1753                // mutation id is idempotent and is the only way to recover its
1754                // receipt without guessing whether the first attempt landed.
1755                if v2_validation_catching_up(&response) {
1756                    if validation_started.elapsed() >= std::time::Duration::from_secs(15 * 60) {
1757                        return Err(LinkError::Http {
1758                            what: "v2 commit receipt",
1759                            status: response.status,
1760                            message: "validation/index recovery did not make the exact mutation receipt available within 15 minutes".to_string(),
1761                            code: Some("validation_index_catching_up".to_string()),
1762                            details: response.body,
1763                        });
1764                    }
1765                    std::thread::sleep(validation_wait);
1766                    validation_wait = validation_wait
1767                        .saturating_mul(2)
1768                        .min(std::time::Duration::from_secs(5));
1769                    continue;
1770                }
1771                return Ok(response);
1772            }
1773        }
1774    }
1775}
1776
1777fn request(
1778    cfg: &HubConfig,
1779    method: &str,
1780    path: &str,
1781    body: Option<&Value>,
1782    auth: Auth,
1783) -> LinkResult<HubResponse> {
1784    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1785}
1786
1787/// Repeat one challenge-response mutation with the exact request identity the
1788/// hub bound into its actor claim. A new request id would create a new actor
1789/// hash and therefore a different canonical commit candidate, making the
1790/// otherwise correct self-custody signature unusable.
1791fn request_with_request_id(
1792    cfg: &HubConfig,
1793    method: &str,
1794    path: &str,
1795    body: Option<&Value>,
1796    auth: Auth,
1797    request_id: &str,
1798) -> LinkResult<HubResponse> {
1799    if request_id.is_empty()
1800        || request_id.len() > 128
1801        || !request_id
1802            .bytes()
1803            .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1804    {
1805        return Err(invalid_feed("hub returned an unsafe request id"));
1806    }
1807    // Both callers are commit responses, so this shares the commit lane's
1808    // patience: the hub is finishing the same brain-sized work either way.
1809    let http = hub_agent_with_timeout(
1810        cfg,
1811        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1812    )?;
1813    let raw = request_raw_with_agent(
1814        cfg,
1815        &http,
1816        method,
1817        path,
1818        body,
1819        RawRequestOptions {
1820            auth,
1821            max_response_bytes: MAX_RESPONSE_BYTES,
1822            request_id: Some(request_id),
1823            retry_transport: false,
1824        },
1825    )?;
1826    Ok(HubResponse {
1827        status: raw.status,
1828        body: serde_json::from_slice(&raw.body).ok(),
1829    })
1830}
1831
1832fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1833    if (200..300).contains(&r.status) {
1834        return Ok(r.body);
1835    }
1836    ensure_ok(
1837        HubResponse {
1838            status: r.status,
1839            body: serde_json::from_slice(&r.body).ok(),
1840        },
1841        what,
1842    )
1843    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1844}
1845
1846/// These failures happen before any HTTP request reaches the hub, so retrying
1847/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1848/// bytes may have crossed the wire, the caller must rely on the verb's own
1849/// idempotency contract instead of guessing.
1850fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1851    matches!(
1852        kind,
1853        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1854    )
1855}
1856
1857fn with_connect_retries(
1858    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1859) -> Result<ureq::Response, Box<ureq::Error>> {
1860    let mut attempt = 0;
1861    loop {
1862        match send() {
1863            Err(error)
1864                if matches!(
1865                    error.as_ref(),
1866                    ureq::Error::Transport(transport)
1867                        if is_pre_request_transport(transport.kind())
1868                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1869            {
1870                std::thread::sleep(std::time::Duration::from_millis(
1871                    CONNECT_RETRY_BACKOFF_MS[attempt],
1872                ));
1873                attempt += 1;
1874            }
1875            result => return result,
1876        }
1877    }
1878}
1879
1880fn hub_is_loopback(hub: &str) -> bool {
1881    url::Url::parse(hub).ok().is_some_and(|parsed| {
1882        parsed.host().is_some_and(|host| match host {
1883            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1884            url::Host::Ipv4(ip) => ip.is_loopback(),
1885            url::Host::Ipv6(ip) => ip.is_loopback(),
1886        })
1887    })
1888}
1889
1890/// Every safety property a presigned object-store URL must carry, checked
1891/// before anything is sent to it. Split out from agent construction so a batch
1892/// can share one connection pool without ever skipping a per-URL check.
1893fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1894    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1895        message: "the hub returned an invalid object-store URL".to_string(),
1896    })?;
1897    let allow_private = hub_is_loopback(&cfg.hub)
1898        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1899    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1900        || !parsed.username().is_empty()
1901        || parsed.password().is_some()
1902        || parsed.fragment().is_some()
1903    {
1904        return Err(LinkError::InvalidPack {
1905            message: "the hub returned an unsafe object-store URL".to_string(),
1906        });
1907    }
1908    Ok((parsed, allow_private))
1909}
1910
1911fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1912    let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1913    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1914        LinkError::InvalidPack {
1915            message: "the hub returned an object-store URL with an unsafe network target"
1916                .to_string(),
1917        }
1918    })
1919}
1920
1921/// One pinned agent for a whole staging batch.
1922///
1923/// Every object in a batch is presigned against the same object-store
1924/// authority, so one agent can serve them all — and must: building one per
1925/// object costs a DNS resolution and a fresh TLS handshake per object, which is
1926/// the difference between a real store staging in minutes and in an hour. The
1927/// shared authority is verified rather than assumed, and a batch that ever
1928/// spans two of them falls back to a per-object agent.
1929fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1930    let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1931    let authority = (
1932        first.host_str()?.to_string(),
1933        first.port_or_known_default()?,
1934    );
1935    for raw in &urls[1..] {
1936        let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1937        if (parsed.host_str()?, parsed.port_or_known_default()?)
1938            != (authority.0.as_str(), authority.1)
1939        {
1940            return None;
1941        }
1942    }
1943    pinned_public_agent_pooled(
1944        &first,
1945        allow_private,
1946        "object-store URL",
1947        AgentShape {
1948            idle_per_host: V2_UPLOAD_CONCURRENCY,
1949            ..AgentShape::default()
1950        },
1951    )
1952    .ok()
1953}
1954
1955/// Turn an object-store transport failure into a useful but credential-safe
1956/// diagnostic. `ureq::Transport`'s Display implementation includes the full
1957/// request URL; for a presigned URL that means the short-lived signature and
1958/// every signed query coordinate. The error kind is enough to distinguish a
1959/// network failure without copying bearer material into terminals or logs.
1960fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1961    LinkError::Transport {
1962        hub: "the object store".to_string(),
1963        message: format!("network error ({:?})", error.kind()),
1964    }
1965}
1966
1967fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1968    let http = presigned_agent(cfg, raw)?;
1969    let deadline = std::time::Instant::now()
1970        .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1971        .ok_or_else(upload_deadline_error)?;
1972    let mut attempt = 0;
1973    let result = loop {
1974        // The reservation states content-length itself. This client sends the
1975        // body from memory, so it never needs to add one, and adding one under
1976        // a different capitalization would leave both on the wire.
1977        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1978        if let Some(map) = headers.as_object() {
1979            for (name, value) in map {
1980                if let Some(value) = value.as_str() {
1981                    req = req.set(name, value);
1982                }
1983            }
1984        }
1985        match req.send_bytes(bytes) {
1986            // An upload is idempotent here: fixed content address, exact
1987            // length and checksum, write-once precondition. A dropped
1988            // connection or a single overloaded refusal is worth replaying
1989            // rather than failing a whole migration on. 412 already means
1990            // "someone else stored these exact bytes".
1991            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1992                attempt += 1;
1993            }
1994            Err(ureq::Error::Status(status, _))
1995                if status != 412
1996                    && is_retryable_upload_status(status)
1997                    && wait_for_upload_retry(deadline, attempt) =>
1998            {
1999                attempt += 1;
2000            }
2001            result => break result,
2002        }
2003    };
2004    match result {
2005        Ok(resp) if (200..300).contains(&resp.status()) => {
2006            drain_presigned_response(resp);
2007            Ok(())
2008        }
2009        Ok(resp) => Err(presigned_upload_refusal(resp)),
2010        Err(error) => match error {
2011            // Immutable uploads use If-None-Match. A concurrent writer may
2012            // win the same content address; the commit/confirm endpoint reads
2013            // and hashes that object before accepting it, so 412 safely means
2014            // "continue to verification", never "trust the upload".
2015            ureq::Error::Status(412, _) => Ok(()),
2016            ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
2017            ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
2018        },
2019    }
2020}
2021
2022/// Read a response body, typed as the transport failure it is.
2023///
2024/// A connection that fails while the body is still arriving is the same class
2025/// of problem as one that fails before it arrives at all — but a bare `?` on
2026/// the read turned it into an anonymous io error that no retry path recognised
2027/// and no message attributed to a peer. On a migration making tens of
2028/// thousands of requests, one stalled body ended the whole push and reported
2029/// only "timed out reading response".
2030fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
2031    let mut buf = Vec::new();
2032    response
2033        .into_reader()
2034        .take(limit)
2035        .read_to_end(&mut buf)
2036        .map_err(|error| LinkError::Transport {
2037            hub: peer.to_string(),
2038            message: error.to_string(),
2039        })?;
2040    Ok(buf)
2041}
2042
2043/// Draining a response is what returns its connection to the pool: the client
2044/// cannot recycle a socket whose body may still have bytes pending, so a body
2045/// that is dropped instead of read costs the next object a fresh handshake.
2046/// An object-store PUT answers with almost nothing, so this is nearly free.
2047fn drain_presigned_response(response: ureq::Response) {
2048    let mut reader = response.into_reader().take(64 * 1024);
2049    let _ = std::io::copy(&mut reader, &mut std::io::sink());
2050}
2051
2052/// Carry the object store's own reason for a refusal. An unexplained status is
2053/// unactionable; the body states checksum mismatch, precondition, or expiry.
2054fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
2055    let status = response.status();
2056    let detail = response
2057        .into_string()
2058        .ok()
2059        .map(|body| body.chars().take(400).collect::<String>())
2060        .filter(|body| !body.trim().is_empty());
2061    LinkError::Http {
2062        what: "pack upload",
2063        status,
2064        message: match detail {
2065            Some(body) => format!(
2066                "object store rejected the upload: {}",
2067                body.replace('\n', " ")
2068            ),
2069            None => "object store rejected the upload".to_string(),
2070        },
2071        code: None,
2072        details: None,
2073    }
2074}
2075
2076fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
2077    max_bytes.checked_add(1)
2078}
2079
2080fn presigned_download_read_limit() -> u64 {
2081    one_past_bounded_limit(MAX_PACK_BYTES)
2082        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
2083}
2084
2085fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
2086    let http = presigned_agent(cfg, raw)?;
2087    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
2088        Ok(resp) => resp,
2089        Err(error) => match *error {
2090            ureq::Error::Status(_, resp) => {
2091                return Err(LinkError::Http {
2092                    what: "pack download",
2093                    status: resp.status(),
2094                    message: "object store rejected the download".to_string(),
2095                    code: None,
2096                    details: None,
2097                });
2098            }
2099            ureq::Error::Transport(err) => {
2100                return Err(LinkError::Transport {
2101                    hub: "the object store".to_string(),
2102                    message: err.to_string(),
2103                });
2104            }
2105        },
2106    };
2107    if !(200..300).contains(&resp.status()) {
2108        return Err(LinkError::Http {
2109            what: "pack download",
2110            status: resp.status(),
2111            message: "object store rejected the download".to_string(),
2112            code: None,
2113            details: None,
2114        });
2115    }
2116    let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2117    if bytes.len() as u64 > MAX_PACK_BYTES {
2118        return Err(LinkError::InvalidPack {
2119            message: "download exceeds the compressed-size limit".to_string(),
2120        });
2121    }
2122    Ok(bytes)
2123}
2124
2125/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
2126/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
2127/// answer.
2128fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2129    if !(200..300).contains(&r.status) {
2130        let message = r
2131            .body
2132            .as_ref()
2133            .and_then(|b| b.get("error"))
2134            .and_then(Value::as_str)
2135            .unwrap_or("unknown error")
2136            .to_string();
2137        let code = r
2138            .body
2139            .as_ref()
2140            .and_then(|b| b.get("code"))
2141            .and_then(Value::as_str)
2142            .map(str::to_string);
2143        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2144        return Err(LinkError::Http {
2145            what,
2146            status: r.status,
2147            message,
2148            code,
2149            details,
2150        });
2151    }
2152    r.body.ok_or(LinkError::NotJson {
2153        what,
2154        status: r.status,
2155    })
2156}
2157
2158// ─────────────────────────────────────────────────────────────────────────────
2159// resolve — handle → brain card; @brain/id → the record
2160// ─────────────────────────────────────────────────────────────────────────────
2161
2162/// Resolve an address. A bare `@brain` returns the brain card (metadata +
2163/// index stats — the v0 form of the card; keys arrive with the protocol's
2164/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
2165/// record, frontmatter + body.
2166fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2167    match ip {
2168        std::net::IpAddr::V4(ip) => {
2169            let [a, b, c, _] = ip.octets();
2170            !(a == 0
2171                || a == 10
2172                || a == 127
2173                || (a == 100 && (64..=127).contains(&b))
2174                || (a == 169 && b == 254)
2175                || (a == 172 && (16..=31).contains(&b))
2176                || (a == 192 && b == 0 && c == 0)
2177                || (a == 192 && b == 0 && c == 2)
2178                || (a == 192 && b == 88 && c == 99)
2179                || (a == 192 && b == 168)
2180                || (a == 198 && (b == 18 || b == 19))
2181                || (a == 198 && b == 51 && c == 100)
2182                || (a == 203 && b == 0 && c == 113)
2183                || a >= 224)
2184        }
2185        std::net::IpAddr::V6(ip) => {
2186            let segments = ip.segments();
2187            // Conservatively accept only global unicast 2000::/3, excluding
2188            // special-purpose/transition blocks. In particular, 6to4 embeds
2189            // an IPv4 destination and must not tunnel a validated public
2190            // connect to 127/8 or RFC1918.
2191            (segments[0] & 0xe000) == 0x2000
2192                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2193                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2194                && segments[0] != 0x2002
2195                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2196        }
2197    }
2198}
2199
2200#[derive(Clone)]
2201struct PinnedRegistryResolver {
2202    netloc: String,
2203    addresses: Vec<std::net::SocketAddr>,
2204}
2205
2206impl ureq::Resolver for PinnedRegistryResolver {
2207    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2208        if requested == self.netloc {
2209            Ok(self.addresses.clone())
2210        } else {
2211            Err(std::io::Error::new(
2212                std::io::ErrorKind::PermissionDenied,
2213                "registry request attempted to resolve an unvalidated authority",
2214            ))
2215        }
2216    }
2217}
2218
2219fn pinned_public_agent(
2220    url: &url::Url,
2221    allow_private: bool,
2222    label: &str,
2223) -> LinkResult<ureq::Agent> {
2224    pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2225}
2226
2227/// How one agent is built: `idle_per_host` sizes the connection pool (the
2228/// default of one is right for a lone request but throttles a concurrent batch
2229/// back to a single warm connection), and `overall` is the wall-clock budget
2230/// for one attempt.
2231struct AgentShape {
2232    idle_per_host: usize,
2233    overall: std::time::Duration,
2234}
2235
2236impl Default for AgentShape {
2237    fn default() -> Self {
2238        Self {
2239            idle_per_host: 1,
2240            overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2241        }
2242    }
2243}
2244
2245fn pinned_public_agent_pooled(
2246    url: &url::Url,
2247    allow_private: bool,
2248    label: &str,
2249    shape: AgentShape,
2250) -> LinkResult<ureq::Agent> {
2251    let host = url
2252        .host_str()
2253        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2254    let port = url
2255        .port_or_known_default()
2256        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2257    let addresses = resolve_addresses_with_deadline(
2258        host,
2259        port,
2260        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2261    )
2262    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2263    if addresses.is_empty() {
2264        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2265    }
2266    if !allow_private
2267        && addresses
2268            .iter()
2269            .any(|address| !is_public_registry_ip(address.ip()))
2270    {
2271        return Err(invalid_feed(format!(
2272            "{label} resolves to a non-public address"
2273        )));
2274    }
2275    let netloc = if host.contains(':') {
2276        format!("[{host}]:{port}")
2277    } else {
2278        format!("{host}:{port}")
2279    };
2280    Ok(agent_builder_with_timeout(shape.overall)
2281        .max_idle_connections_per_host(shape.idle_per_host.max(1))
2282        .resolver(PinnedRegistryResolver { netloc, addresses })
2283        .build())
2284}
2285
2286/// Resolve one authority without allowing libc DNS to hold the caller forever.
2287/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
2288/// worker and only its result channel is awaited. A late resolver result has no
2289/// side effects and is discarded after the deadline.
2290fn resolve_addresses_with_deadline(
2291    host: &str,
2292    port: u16,
2293    timeout: std::time::Duration,
2294) -> std::io::Result<Vec<std::net::SocketAddr>> {
2295    use std::net::ToSocketAddrs as _;
2296
2297    let host = host.to_string();
2298    let (send, receive) = std::sync::mpsc::sync_channel(1);
2299    std::thread::Builder::new()
2300        .name("dbmd-dns".to_string())
2301        .spawn(move || {
2302            let result = (host.as_str(), port)
2303                .to_socket_addrs()
2304                .map(|addresses| addresses.collect());
2305            let _ = send.send(result);
2306        })
2307        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2308    match receive.recv_timeout(timeout) {
2309        Ok(result) => result,
2310        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2311            std::io::ErrorKind::TimedOut,
2312            "resolution exceeded its deadline",
2313        )),
2314        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2315            "resolver stopped without returning a result",
2316        )),
2317    }
2318}
2319
2320fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2321    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2322    pinned_public_agent(url, allow_private, "registry home")
2323}
2324
2325/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
2326/// from a FOREIGN home during registry resolution. DNS is resolved once,
2327/// every answer is required to be public, and that exact answer set is pinned
2328/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
2329fn get_json_absolute(url: &str) -> LinkResult<Value> {
2330    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2331    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2332    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2333        || !parsed.username().is_empty()
2334        || parsed.password().is_some()
2335        || parsed.query().is_some()
2336        || parsed.fragment().is_some()
2337    {
2338        return Err(invalid_feed("unsafe registry home URL"));
2339    }
2340    let http = registry_agent(&parsed)?;
2341    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2342        Ok(resp) => resp,
2343        Err(error) => match *error {
2344            ureq::Error::Status(status, resp) => {
2345                let _ = resp;
2346                return Err(LinkError::Http {
2347                    what: "registry home fetch",
2348                    status,
2349                    message: "the home node rejected the card request".to_string(),
2350                    code: None,
2351                    details: None,
2352                });
2353            }
2354            ureq::Error::Transport(err) => {
2355                return Err(LinkError::Transport {
2356                    hub: url.to_string(),
2357                    message: err.to_string(),
2358                });
2359            }
2360        },
2361    };
2362    if !(200..300).contains(&resp.status()) {
2363        return Err(LinkError::Http {
2364            what: "registry home fetch",
2365            status: resp.status(),
2366            message: "the home node returned a redirect or error".to_string(),
2367            code: None,
2368            details: None,
2369        });
2370    }
2371    let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2372    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2373        return Err(LinkError::ResponseTooLarge {
2374            limit_bytes: MAX_REGISTRY_CARD_BYTES,
2375        });
2376    }
2377    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2378        message: "the home node returned invalid JSON".to_string(),
2379    })
2380}
2381
2382/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
2383/// E5): look the handle up in the hub's registry, fetch the brain card from
2384/// the returned HOME node, and PIN — the card's identity fingerprint must
2385/// equal the registry's, or resolution fails. Returns the card enriched with
2386/// the resolved `home`, or `Ok(None)` when the registry has no such handle
2387/// (so the caller can fall back to a direct lookup).
2388pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2389    require_safe_ref(handle)?;
2390    // Hold the validated trust-directory capability before any network I/O.
2391    // Every lock/load/save below remains relative to this exact inode even if
2392    // an attacker swaps an ancestor while the registry or home is answering.
2393    let trust_directory = open_trust_dir(cfg)?;
2394    let reg = request_capped(
2395        cfg,
2396        "GET",
2397        &format!("/api/hub/registry/{handle}"),
2398        None,
2399        Auth::None,
2400        MAX_REGISTRY_CARD_BYTES,
2401    )?;
2402    if reg.status == 404 {
2403        return Ok(None);
2404    }
2405    let body = ensure_ok(reg, "registry resolve")?;
2406    let home = body
2407        .get("home")
2408        .and_then(Value::as_str)
2409        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2410    let brain = body
2411        .get("brain")
2412        .and_then(Value::as_str)
2413        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2414    if !crate::ulid::is_ulid(brain) {
2415        return Err(invalid_feed(
2416            "registry entry brain is not a canonical lowercase ULID",
2417        ));
2418    }
2419    let want_fp = body
2420        .get("identity")
2421        .and_then(|i| i.get("fingerprint"))
2422        .and_then(Value::as_str)
2423        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2424
2425    let home = home.trim_end_matches('/');
2426    let origin = normalized_origin(home)?;
2427    if origin != home {
2428        return Err(invalid_feed(
2429            "registry home must be an origin without a path, query, or fragment",
2430        ));
2431    }
2432    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2433    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2434    if let Some(binding) = &alias_binding {
2435        if binding
2436            .home
2437            .as_deref()
2438            .is_some_and(|pinned_home| pinned_home != home)
2439        {
2440            return Err(invalid_feed(
2441                "registry relocated a pinned handle to a different home",
2442            ));
2443        }
2444    }
2445    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2446    if card.get("id").and_then(Value::as_str) != Some(brain) {
2447        return Err(invalid_feed(
2448            "the home node served a card for a different brain",
2449        ));
2450    }
2451    let identity: FeedIdentity = serde_json::from_value(
2452        card.get("identity")
2453            .cloned()
2454            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2455    )
2456    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2457    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2458    let got_fp = card
2459        .get("identity")
2460        .and_then(|i| i.get("fingerprint"))
2461        .and_then(Value::as_str)
2462        .unwrap_or_default();
2463    if got_fp != want_fp {
2464        return Err(invalid_feed(
2465            "the home node served an identity that does not match the registry — refusing",
2466        ));
2467    }
2468    let current = format!("ed25519:{}", identity.fingerprint);
2469    let advertised_seq = card
2470        .get("headSeq")
2471        .and_then(Value::as_u64)
2472        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2473    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2474    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2475        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2476    {
2477        return Err(invalid_feed(
2478            "the home node served an invalid feed head boundary",
2479        ));
2480    }
2481    // This is required even on first contact. Otherwise a syntactically valid
2482    // identity may claim that a rotation happened after a head the home has
2483    // never reached, then become the permanent TOFU anchor.
2484    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2485    let registry_alias = AliasBinding {
2486        v: 1,
2487        origin: normalized_origin(&cfg.hub)?,
2488        requested: handle.to_string(),
2489        brain: brain.to_string(),
2490        home: Some(home.to_string()),
2491    };
2492    save_canonical_pin_and_alias(
2493        cfg,
2494        &trust_directory,
2495        handle,
2496        brain,
2497        TrustState {
2498            v: 2,
2499            origin: normalized_origin(&cfg.hub)?,
2500            requested: brain.to_string(),
2501            brain: brain.to_string(),
2502            home: None,
2503            anchor,
2504            current,
2505            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2506            feed_hash: pinned
2507                .as_ref()
2508                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2509            rotations: identity.rotations.clone(),
2510            hub_signer: None,
2511            protocol_profile: None,
2512        },
2513        Some(&registry_alias),
2514    )?;
2515    let mut out = card;
2516    if let Value::Object(map) = &mut out {
2517        map.insert("home".to_string(), Value::String(home.to_string()));
2518        map.insert(
2519            "resolvedVia".to_string(),
2520            Value::String("registry".to_string()),
2521        );
2522    }
2523    Ok(Some(out))
2524}
2525
2526pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2527    // `Address::parse` refuses these shapes already, but `Address` has public
2528    // fields — re-assert at the wire so a hand-built address can never
2529    // reshape the request path.
2530    require_safe_ref(&addr.brain)?;
2531    if let Some(target) = &addr.target {
2532        let (given, ok) = match target {
2533            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2534            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2535        };
2536        if !ok {
2537            return Err(LinkError::BadAddress {
2538                given: given.clone(),
2539                reason: BAD_TARGET_REASON.to_string(),
2540            });
2541        }
2542    }
2543
2544    // A record is never accepted from the hub's mutable query/index response.
2545    // Under v2, an id lookup may suggest a path, but the client accepts only
2546    // bytes whose exact path proof reaches the verified content root and whose
2547    // parsed id still matches. Under v1, the signed snapshot pack remains the
2548    // binding. Both profiles therefore reject a forged query response.
2549    if let Some(target) = &addr.target {
2550        if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2551            let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2552                what: "resolve",
2553                status: 404,
2554                message: "record not found".to_string(),
2555                code: Some("NOT_FOUND".to_string()),
2556                details: None,
2557            })?;
2558            let (path, file) = match target {
2559                AddressTarget::Path(path) => {
2560                    let file =
2561                        v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2562                            LinkError::Http {
2563                                what: "resolve",
2564                                status: 404,
2565                                message: "record not found".to_string(),
2566                                code: Some("NOT_FOUND".to_string()),
2567                                details: None,
2568                            }
2569                        })?;
2570                    (path.clone(), file)
2571                }
2572                AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2573            };
2574            let mut downloaded =
2575                download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2576            let (_, bytes) = downloaded
2577                .pop()
2578                .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2579            let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2580            accept_v2_head(cfg, &head)?;
2581            return Ok(resolved);
2582        }
2583        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2584        if !remote.head.verified {
2585            return Err(invalid_feed(
2586                "a path-scoped feed cannot prove a record against the full signed snapshot",
2587            ));
2588        }
2589        if remote.head.seq == 0 {
2590            return Err(LinkError::Http {
2591                what: "resolve",
2592                status: 404,
2593                message: "record not found".to_string(),
2594                code: Some("NOT_FOUND".to_string()),
2595                details: None,
2596            });
2597        }
2598        let brain = remote.head.brain.clone();
2599        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2600        return resolve_from_verified_pack(&brain, target, pack);
2601    }
2602
2603    let path = format!("/api/hub/brains/{}", addr.brain);
2604    // Direct first: the caller's own slug and hub-hosted public handles resolve
2605    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2606    // (404) falls through to the federation registry — how a handle reaches a
2607    // brain on ANOTHER node (link.md §7.1, E5).
2608    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2609    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2610        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2611            return Ok(card);
2612        }
2613    }
2614    let mut resolved = ensure_ok(direct, "resolve")?;
2615    if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2616        let v2 = v2_verified_head(cfg, &addr.brain)?
2617            .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2618        if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2619            return Err(invalid_feed(
2620                "resolve card is not bound to the verified v2 brain",
2621            ));
2622        }
2623        let card_identity: FeedIdentity = serde_json::from_value(
2624            resolved
2625                .get("identity")
2626                .cloned()
2627                .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2628        )
2629        .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2630        if card_identity != v2_identity(&v2.identity) {
2631            return Err(invalid_feed(
2632                "resolve card identity differs from the verified v2 identity",
2633            ));
2634        }
2635        accept_v2_head(cfg, &v2)?;
2636        if let Value::Object(card) = &mut resolved {
2637            card.insert(
2638                "headSeq".to_string(),
2639                json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2640            );
2641            card.insert(
2642                "feedHash".to_string(),
2643                v2.pointer
2644                    .as_ref()
2645                    .map(|pointer| Value::String(pointer.feed_hash.clone()))
2646                    .unwrap_or(Value::Null),
2647            );
2648            card.insert(
2649                "storageProfile".to_string(),
2650                Value::String("v2".to_string()),
2651            );
2652            if let Some(pointer) = &v2.pointer {
2653                card.insert(
2654                    "updatedAt".to_string(),
2655                    Value::String(pointer.signed_at.clone()),
2656                );
2657            }
2658        }
2659        return Ok(resolved);
2660    }
2661    // A successful direct response is accepted only after the same centralized
2662    // identity/rotation/feed checkpoint verification used by sync and
2663    // subscribe. No verb gets a weaker ad-hoc pinning path.
2664    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2665    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2666        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2667        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2668    {
2669        return Err(invalid_feed(
2670            "resolve card is not bound to the exact verified feed checkpoint",
2671        ));
2672    }
2673    let card_identity: FeedIdentity = serde_json::from_value(
2674        resolved
2675            .get("identity")
2676            .cloned()
2677            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2678    )
2679    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2680    if remote.identity.as_ref() != Some(&card_identity) {
2681        return Err(invalid_feed(
2682            "resolve card identity differs from the verified feed identity",
2683        ));
2684    }
2685    Ok(resolved)
2686}
2687
2688/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2689/// mutable query/index result is intentionally not consulted: a signed pack
2690/// digest is the only cryptographic binding between a feed checkpoint and
2691/// record bytes in wire profile v1.
2692fn resolve_from_verified_pack(
2693    brain: &str,
2694    target: &AddressTarget,
2695    pack: Vec<u8>,
2696) -> LinkResult<Value> {
2697    let entries = parse_store_pack(pack)?;
2698    let mut matched: Option<(String, Vec<u8>)> = None;
2699
2700    for (path, bytes) in entries {
2701        let is_candidate = match target {
2702            AddressTarget::Path(want) => &path == want,
2703            AddressTarget::Id(_) => {
2704                path.ends_with(".md")
2705                    && (path.starts_with("records/") || path.starts_with("sources/"))
2706            }
2707        };
2708        if !is_candidate {
2709            continue;
2710        }
2711        let text = std::str::from_utf8(&bytes)
2712            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2713        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2714            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2715        if let AddressTarget::Id(want) = target {
2716            let frontmatter =
2717                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2718                    .map_err(|_| {
2719                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2720                    })?;
2721            if frontmatter.id.as_deref() != Some(want) {
2722                continue;
2723            }
2724        }
2725        if matched.is_some() {
2726            return Err(invalid_feed(
2727                "signed snapshot contains more than one record for the requested target",
2728            ));
2729        }
2730        matched = Some((path, bytes));
2731    }
2732
2733    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2734        what: "resolve",
2735        status: 404,
2736        message: "record not found".to_string(),
2737        code: Some("NOT_FOUND".to_string()),
2738        details: None,
2739    })?;
2740    resolve_from_verified_record_bytes(brain, target, path, bytes)
2741}
2742
2743fn resolve_from_verified_record_bytes(
2744    brain: &str,
2745    target: &AddressTarget,
2746    path: String,
2747    bytes: Vec<u8>,
2748) -> LinkResult<Value> {
2749    match target {
2750        AddressTarget::Path(expected) if expected != &path => {
2751            return Err(invalid_feed(
2752                "verified record path differs from the requested path",
2753            ));
2754        }
2755        AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2756            return Err(invalid_feed(
2757                "verified id resolved outside records or sources",
2758            ));
2759        }
2760        _ => {}
2761    }
2762    let text = std::str::from_utf8(&bytes)
2763        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2764    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2765        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2766    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2767        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2768    let Value::Object(fields) = frontmatter else {
2769        return Err(invalid_feed(format!(
2770            "signed snapshot record `{path}` frontmatter is not a mapping"
2771        )));
2772    };
2773    if let AddressTarget::Id(expected) = target {
2774        if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2775            return Err(invalid_feed(
2776                "verified record id differs from the requested id",
2777            ));
2778        }
2779    }
2780    let mut document = serde_json::Map::new();
2781    document.insert("path".to_string(), Value::String(path));
2782    for (key, value) in fields {
2783        document.insert(key, value);
2784    }
2785    document.insert("body".to_string(), Value::String(parsed.body));
2786    document.insert(
2787        "contentSha".to_string(),
2788        Value::String(content_sha256(&bytes)),
2789    );
2790    Ok(json!({
2791        "brain": brain,
2792        "document": Value::Object(document),
2793    }))
2794}
2795
2796// ─────────────────────────────────────────────────────────────────────────────
2797// sync — pull the granted slice as files; push the local store as a snapshot
2798// ─────────────────────────────────────────────────────────────────────────────
2799
2800/// What a pull materialized.
2801#[derive(Debug, Clone, serde::Serialize)]
2802pub struct PullReport {
2803    /// The brain id the hub reported.
2804    pub brain: String,
2805    /// The brain's slug.
2806    pub slug: String,
2807    /// The hub's feed head at export time.
2808    #[serde(rename = "headSeq")]
2809    pub head_seq: u64,
2810    /// How many files were written.
2811    pub files: usize,
2812    /// Where they were written (as given or derived from the slug).
2813    pub dest: String,
2814    /// Local content files that the export did not carry — present so a
2815    /// caller sees divergence; nothing is ever deleted locally.
2816    #[serde(rename = "extraLocal")]
2817    pub extra_local: Vec<String>,
2818    /// Exact convergence result from the post-install barrier.
2819    #[serde(rename = "syncStatus")]
2820    pub sync_status: String,
2821}
2822
2823struct V2PulledSnapshot {
2824    report: PullReport,
2825    head: V2VerifiedHead,
2826    files: std::collections::BTreeMap<String, V2BaselineFile>,
2827    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2828    local: V2LocalView,
2829    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2830}
2831
2832fn download_verified_snapshot_pack(
2833    cfg: &HubConfig,
2834    brain: &str,
2835    remote: &VerifiedRemote,
2836) -> LinkResult<Vec<u8>> {
2837    let feed_hash = remote
2838        .head
2839        .feed_hash
2840        .as_deref()
2841        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2842    let signed_head = remote
2843        .head_entry
2844        .as_ref()
2845        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2846    let expected = &signed_head.entry.pack_sha256;
2847    if !is_sha256(expected) {
2848        return Err(invalid_feed(
2849            "signed head carries an invalid snapshot pack digest",
2850        ));
2851    }
2852    let path = format!(
2853        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2854        remote.head.seq
2855    );
2856    let body = ensure_ok(
2857        request(cfg, "GET", &path, None, Auth::Required)?,
2858        "sync pull",
2859    )?;
2860    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2861        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2862        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2863        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2864    {
2865        return Err(invalid_feed(
2866            "export response is not bound to the exact verified snapshot",
2867        ));
2868    }
2869    let url = body
2870        .get("url")
2871        .and_then(Value::as_str)
2872        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2873    let bytes = get_presigned(cfg, url)?;
2874    if content_sha256(&bytes) != *expected {
2875        return Err(LinkError::InvalidPack {
2876            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2877        });
2878    }
2879    let entries = parse_store_pack(bytes.clone())?;
2880    if signed_head.entry.kind == "push" {
2881        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2882    }
2883    Ok(bytes)
2884}
2885
2886#[derive(Debug, Clone, Deserialize, Serialize)]
2887struct V2PointerBody {
2888    v: u8,
2889    brain: String,
2890    seq: u64,
2891    commit_hash: String,
2892    feed_hash: String,
2893    content_root: Option<String>,
2894    asset_root: Option<String>,
2895    materializer: String,
2896    signer_epoch: u64,
2897    control_revision: String,
2898    backup_preparation: String,
2899    prior_pointer_hash: Option<String>,
2900    signed_at: String,
2901}
2902
2903#[derive(Debug, Clone, Deserialize)]
2904struct V2SignedPointer {
2905    pointer: V2PointerBody,
2906    hub_public_key: String,
2907    hub_fingerprint: String,
2908    sig: String,
2909}
2910
2911#[derive(Debug, Clone, Deserialize)]
2912struct V2HeadIdentity {
2913    #[serde(default)]
2914    custody: String,
2915    fingerprint: String,
2916    public_key_spki: String,
2917    #[serde(default)]
2918    previous: Vec<V2PreviousIdentity>,
2919    #[serde(default)]
2920    rotations: Vec<String>,
2921}
2922
2923#[derive(Debug, Clone, Deserialize)]
2924struct V2PreviousIdentity {
2925    fingerprint: String,
2926    public_key_spki: String,
2927}
2928
2929#[derive(Debug, Deserialize)]
2930struct V2HeadResponse {
2931    v: u8,
2932    brain_id: String,
2933    profile: String,
2934    view: Option<V2HeadView>,
2935    pointer: Option<V2SignedPointer>,
2936    identity: Option<V2HeadIdentity>,
2937}
2938
2939#[derive(Debug, Clone, Deserialize)]
2940struct V2HeadView {
2941    kind: String,
2942    #[serde(default)]
2943    id: Option<String>,
2944    control_revision: String,
2945}
2946
2947#[derive(Debug, Clone)]
2948struct V2VerifiedHead {
2949    requested: String,
2950    brain_id: String,
2951    view_kind: String,
2952    /// Stable identity of the exact current-content path projection.
2953    view_revision: String,
2954    /// Use-time authority revision independently bound into writes/signatures.
2955    control_revision: String,
2956    identity: V2HeadIdentity,
2957    pointer: Option<V2PointerBody>,
2958    trust: TrustState,
2959    alias: Option<AliasBinding>,
2960}
2961
2962fn verify_v2_spki_signature(
2963    public_key: &str,
2964    message: &[u8],
2965    signature: &str,
2966) -> LinkResult<Vec<u8>> {
2967    let der = URL_SAFE_NO_PAD
2968        .decode(public_key)
2969        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2970    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2971        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2972    }
2973    let sig = URL_SAFE_NO_PAD
2974        .decode(signature)
2975        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2976    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2977        .verify(message, &sig)
2978        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2979    Ok(der)
2980}
2981
2982fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2983    if pointer.pointer.v != 2
2984        || pointer.pointer.brain != expected_brain
2985        || pointer.pointer.seq == 0
2986        || !is_sha256(&pointer.pointer.commit_hash)
2987        || !is_sha256(&pointer.pointer.feed_hash)
2988        || pointer
2989            .pointer
2990            .content_root
2991            .as_deref()
2992            .is_some_and(|hash| !is_sha256(hash))
2993        || !is_sha256(&pointer.pointer.backup_preparation)
2994    {
2995        return Err(invalid_feed("v2 pointer fields are invalid"));
2996    }
2997    let value = serde_json::to_value(&pointer.pointer)
2998        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2999    let message = crate::linkmd_v2::canonical_bytes(&value)
3000        .map_err(|error| invalid_feed(error.to_string()))?;
3001    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
3002    let fingerprint = format!("{:x}", Sha256::digest(&der));
3003    if fingerprint != pointer.hub_fingerprint {
3004        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
3005    }
3006    Ok(format!(
3007        "{}:{}",
3008        pointer.hub_fingerprint, pointer.hub_public_key
3009    ))
3010}
3011
3012fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
3013    FeedIdentity {
3014        fingerprint: identity.fingerprint.clone(),
3015        public_key_spki: identity.public_key_spki.clone(),
3016        previous: identity
3017            .previous
3018            .iter()
3019            .map(|previous| PreviousIdentity {
3020                fingerprint: previous.fingerprint.clone(),
3021                public_key_spki: previous.public_key_spki.clone(),
3022            })
3023            .collect(),
3024        rotations: identity.rotations.clone(),
3025    }
3026}
3027
3028fn verified_v2_commit_object(
3029    raw: &[u8],
3030    identity: &V2HeadIdentity,
3031) -> LinkResult<serde_json::Map<String, Value>> {
3032    let mut value: Value =
3033        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
3034    let canonical = crate::linkmd_v2::canonical_bytes(&value)
3035        .map_err(|error| invalid_feed(error.to_string()))?;
3036    if canonical != raw {
3037        return Err(invalid_feed("v2 commit is not canonical JSON"));
3038    }
3039    let object = value
3040        .as_object_mut()
3041        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
3042    let sig = object
3043        .remove("sig")
3044        .and_then(|value| value.as_str().map(str::to_string))
3045        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
3046    const FIELDS: [&str; 18] = [
3047        "actor_ref",
3048        "asset_root",
3049        "brain",
3050        "changes_sha256",
3051        "control_revision",
3052        "materializer",
3053        "op",
3054        "parent_asset_root",
3055        "parent_commit",
3056        "parent_root",
3057        "prev_entry_hash",
3058        "public_key",
3059        "seq",
3060        "signer_epoch",
3061        "state_root",
3062        "ts",
3063        "v",
3064        "v1_bridge",
3065    ];
3066    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
3067        return Err(invalid_feed("v2 commit has a non-normative field set"));
3068    }
3069    let seq = object
3070        .get("seq")
3071        .and_then(Value::as_u64)
3072        .filter(|seq| *seq > 0)
3073        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
3074    let signer_epoch = object
3075        .get("signer_epoch")
3076        .and_then(Value::as_u64)
3077        .filter(|epoch| *epoch > 0)
3078        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
3079    let hash_or_null = |field: &str| {
3080        object
3081            .get(field)
3082            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
3083    };
3084    if object.get("v").and_then(Value::as_u64) != Some(2)
3085        || object.get("op").and_then(Value::as_str) != Some("changeset")
3086        || !object
3087            .get("changes_sha256")
3088            .and_then(Value::as_str)
3089            .is_some_and(is_sha256)
3090        || !object
3091            .get("actor_ref")
3092            .and_then(Value::as_str)
3093            .is_some_and(is_sha256)
3094        || !object
3095            .get("control_revision")
3096            .and_then(Value::as_str)
3097            .is_some_and(is_sha256)
3098        || !object
3099            .get("state_root")
3100            .and_then(Value::as_str)
3101            .is_some_and(is_sha256)
3102        || !hash_or_null("parent_commit")
3103        || !hash_or_null("parent_root")
3104        || !hash_or_null("parent_asset_root")
3105        || !hash_or_null("asset_root")
3106        || !hash_or_null("prev_entry_hash")
3107        || !object
3108            .get("materializer")
3109            .and_then(Value::as_str)
3110            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
3111        || !object
3112            .get("ts")
3113            .and_then(Value::as_str)
3114            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3115    {
3116        return Err(invalid_feed("v2 commit fields are invalid"));
3117    }
3118    if (seq == 1
3119        && [
3120            "parent_commit",
3121            "parent_root",
3122            "parent_asset_root",
3123            "prev_entry_hash",
3124        ]
3125        .iter()
3126        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3127        || (seq > 1
3128            && ["parent_commit", "parent_root", "prev_entry_hash"]
3129                .iter()
3130                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3131    {
3132        return Err(invalid_feed("v2 commit parent shape is invalid"));
3133    }
3134    match object.get("v1_bridge") {
3135        Some(Value::Null) => {}
3136        Some(Value::Object(bridge))
3137            if seq == 1
3138                && bridge.len() == 3
3139                && bridge
3140                    .get("head_seq")
3141                    .and_then(Value::as_u64)
3142                    .is_some_and(|v| v > 0)
3143                && bridge
3144                    .get("feed_hash")
3145                    .and_then(Value::as_str)
3146                    .is_some_and(is_sha256)
3147                && bridge
3148                    .get("pack_sha256")
3149                    .and_then(Value::as_str)
3150                    .is_some_and(is_sha256) => {}
3151        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3152    }
3153    let public_key = object
3154        .get("public_key")
3155        .and_then(Value::as_str)
3156        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3157    let der = URL_SAFE_NO_PAD
3158        .decode(public_key)
3159        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3160    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3161    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3162        return Err(invalid_feed("v2 commit brain identity mismatch"));
3163    }
3164    // Verify the old-key-signed chain before trusting any prior public key.
3165    verify_identity_chain(&v2_identity(identity), None)?;
3166    // Old commits remain verifiable after brain-key rotation. `previous` is
3167    // newest-first, while signer epochs and rotation statements are oldest-first.
3168    let mut chain: Vec<(&str, &str)> = identity
3169        .previous
3170        .iter()
3171        .rev()
3172        .map(|previous| {
3173            (
3174                previous.fingerprint.as_str(),
3175                previous.public_key_spki.as_str(),
3176            )
3177        })
3178        .collect();
3179    chain.push((&identity.fingerprint, &identity.public_key_spki));
3180    let signer_index = chain.iter().position(|(fingerprint, spki)| {
3181        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3182    });
3183    let Some(signer_index) = signer_index else {
3184        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3185    };
3186    if signer_epoch != signer_index as u64 + 1 {
3187        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3188    }
3189    let lower_boundary = if signer_index == 0 {
3190        None
3191    } else {
3192        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3193            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3194        Some(prior.prior_head_seq)
3195    };
3196    let upper_boundary = if signer_index == identity.rotations.len() {
3197        None
3198    } else {
3199        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3200            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3201        Some(next.prior_head_seq)
3202    };
3203    if lower_boundary.is_some_and(|boundary| seq <= boundary)
3204        || upper_boundary.is_some_and(|boundary| seq > boundary)
3205    {
3206        return Err(invalid_feed(
3207            "v2 commit signer is outside its authenticated rotation epoch",
3208        ));
3209    }
3210    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3211        .map_err(|error| invalid_feed(error.to_string()))?;
3212    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3213    Ok(object.clone())
3214}
3215
3216#[derive(Debug, Deserialize)]
3217struct V2FeedWireEntry {
3218    seq: u64,
3219    commit_hash: String,
3220    feed_hash: String,
3221    bytes_base64: String,
3222}
3223
3224#[derive(Debug, Deserialize)]
3225struct V2FeedPage {
3226    v: u8,
3227    head_seq: u64,
3228    head_commit_hash: String,
3229    head_feed_hash: String,
3230    entries: Vec<V2FeedWireEntry>,
3231    next_after: u64,
3232    complete: bool,
3233}
3234
3235fn replay_v2_feed(
3236    cfg: &HubConfig,
3237    brain: &str,
3238    pointer: &V2PointerBody,
3239    identity: &V2HeadIdentity,
3240    start_after: u64,
3241    start_feed: Option<String>,
3242) -> LinkResult<()> {
3243    let mut after = start_after;
3244    let mut prior_feed = start_feed;
3245    let mut final_object = None;
3246    let mut replayed_entries = 0_u64;
3247    let mut replayed_bytes = 0_u64;
3248    while after < pointer.seq {
3249        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3250        let value = ensure_ok(
3251            request_capped(
3252                cfg,
3253                "GET",
3254                &path,
3255                None,
3256                Auth::Required,
3257                MAX_FEED_REPLAY_BYTES,
3258            )?,
3259            "v2 feed replay",
3260        )?;
3261        let page: V2FeedPage = serde_json::from_value(value)
3262            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3263        if page.v != 2
3264            || page.head_seq != pointer.seq
3265            || page.head_commit_hash != pointer.commit_hash
3266            || page.head_feed_hash != pointer.feed_hash
3267            || page.entries.is_empty()
3268            || page.entries.len() > FEED_PAGE_LIMIT
3269        {
3270            return Err(invalid_feed("v2 feed page differs from the signed head"));
3271        }
3272        for entry in page.entries {
3273            if entry.seq != after + 1
3274                || !is_sha256(&entry.commit_hash)
3275                || !is_sha256(&entry.feed_hash)
3276            {
3277                return Err(invalid_feed("v2 feed sequence is not contiguous"));
3278            }
3279            let raw = base64::engine::general_purpose::STANDARD
3280                .decode(&entry.bytes_base64)
3281                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3282            replayed_entries = replayed_entries
3283                .checked_add(1)
3284                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3285            replayed_bytes = replayed_bytes
3286                .checked_add(raw.len() as u64)
3287                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3288            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3289            {
3290                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3291            }
3292            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3293                .map_err(|error| invalid_feed(error.to_string()))?
3294                != entry.commit_hash
3295                || content_sha256(&raw) != entry.feed_hash
3296            {
3297                return Err(invalid_feed("v2 feed entry address mismatch"));
3298            }
3299            let object = verified_v2_commit_object(&raw, identity)?;
3300            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3301                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3302            {
3303                return Err(invalid_feed(
3304                    "v2 feed entry does not extend its predecessor",
3305                ));
3306            }
3307            after = entry.seq;
3308            prior_feed = Some(entry.feed_hash);
3309            final_object = Some((entry.commit_hash, object));
3310        }
3311        if page.next_after != after || (page.complete != (after == pointer.seq)) {
3312            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3313        }
3314    }
3315    let (final_hash, object) =
3316        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3317    if final_hash != pointer.commit_hash
3318        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3319        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3320        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3321        || object.get("control_revision").and_then(Value::as_str)
3322            != Some(pointer.control_revision.as_str())
3323        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3324    {
3325        return Err(invalid_feed(
3326            "v2 replay did not converge on the signed pointer",
3327        ));
3328    }
3329    Ok(())
3330}
3331
3332fn verify_v1_to_v2_bridge(
3333    cfg: &HubConfig,
3334    brain: &str,
3335    pointer: &V2PointerBody,
3336    identity: &V2HeadIdentity,
3337    checkpoint: &TrustState,
3338) -> LinkResult<()> {
3339    let value = ensure_ok(
3340        request_capped(
3341            cfg,
3342            "GET",
3343            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3344            None,
3345            Auth::Required,
3346            MAX_FEED_RESPONSE_BYTES,
3347        )?,
3348        "v2 genesis bridge",
3349    )?;
3350    let page: V2FeedPage = serde_json::from_value(value)
3351        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3352    if page.v != 2
3353        || page.head_seq != pointer.seq
3354        || page.head_commit_hash != pointer.commit_hash
3355        || page.head_feed_hash != pointer.feed_hash
3356        || page.entries.len() != 1
3357        || page.entries[0].seq != 1
3358        || !is_sha256(&page.entries[0].commit_hash)
3359        || !is_sha256(&page.entries[0].feed_hash)
3360    {
3361        return Err(invalid_feed(
3362            "v2 genesis bridge page differs from the signed head",
3363        ));
3364    }
3365    let first = &page.entries[0];
3366    let raw = STANDARD
3367        .decode(&first.bytes_base64)
3368        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3369    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3370        .map_err(|error| invalid_feed(error.to_string()))?
3371        != first.commit_hash
3372        || content_sha256(&raw) != first.feed_hash
3373    {
3374        return Err(invalid_feed("v2 genesis bridge address mismatch"));
3375    }
3376    let object = verified_v2_commit_object(&raw, identity)?;
3377    if checkpoint.head_seq == 0 {
3378        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3379            return Err(invalid_feed(
3380                "empty v1 checkpoint did not transition through an empty v2 genesis",
3381            ));
3382        }
3383        return Ok(());
3384    }
3385    let bridge = object
3386        .get("v1_bridge")
3387        .and_then(Value::as_object)
3388        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3389    let checkpoint_feed = checkpoint
3390        .feed_hash
3391        .as_deref()
3392        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3393    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3394        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3395    {
3396        return Err(invalid_feed(
3397            "v2 genesis bridge differs from the pinned v1 checkpoint",
3398        ));
3399    }
3400    let legacy_raw = ensure_raw_ok(
3401        request_raw(
3402            cfg,
3403            "GET",
3404            &format!(
3405                "/api/hub/brains/{brain}/feed?after={}&limit=1",
3406                checkpoint.head_seq - 1
3407            ),
3408            None,
3409            Auth::Required,
3410            MAX_FEED_RESPONSE_BYTES,
3411        )?,
3412        "v1 bridge boundary",
3413    )?;
3414    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3415        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3416    let legacy_identity = legacy
3417        .identity
3418        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3419    let item = legacy
3420        .entries
3421        .first()
3422        .filter(|_| legacy.entries.len() == 1)
3423        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3424    if legacy.scope_limited
3425        || legacy.head_seq != checkpoint.head_seq
3426        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3427        || item.entry.seq != checkpoint.head_seq
3428        || item.hash != checkpoint_feed
3429        || legacy_identity != v2_identity(identity)
3430        || bridge.get("pack_sha256").and_then(Value::as_str)
3431            != Some(item.entry.pack_sha256.as_str())
3432    {
3433        return Err(invalid_feed(
3434            "v1 bridge boundary differs from its signed legacy head",
3435        ));
3436    }
3437    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3438    if anchor != checkpoint.anchor {
3439        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3440    }
3441    verify_feed_item(item, &legacy_identity)?;
3442    verify_rotation_feed_boundaries(
3443        &legacy_identity,
3444        Some(checkpoint),
3445        std::slice::from_ref(item),
3446        checkpoint.head_seq,
3447    )?;
3448    Ok(())
3449}
3450
3451fn verify_v2_commit(
3452    cfg: &HubConfig,
3453    brain: &str,
3454    pointer: &V2PointerBody,
3455    identity: &V2HeadIdentity,
3456    pinned: Option<&TrustState>,
3457) -> LinkResult<()> {
3458    let path = format!(
3459        "/api/hub/brains/{brain}/v2/commit?commit={}",
3460        pointer.commit_hash
3461    );
3462    let raw = ensure_raw_ok(
3463        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3464        "v2 commit",
3465    )?;
3466    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3467        .map_err(|error| invalid_feed(error.to_string()))?
3468        != pointer.commit_hash
3469        || content_sha256(&raw) != pointer.feed_hash
3470    {
3471        return Err(invalid_feed("v2 commit address differs from the pointer"));
3472    }
3473    let object = verified_v2_commit_object(&raw, identity)?;
3474    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3475        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3476        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3477        || object.get("control_revision").and_then(Value::as_str)
3478            != Some(pointer.control_revision.as_str())
3479        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3480    {
3481        return Err(invalid_feed("v2 commit fields differ from the pointer"));
3482    }
3483    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3484        if pointer.seq == checkpoint.head_seq + 1
3485            && object.get("prev_entry_hash").and_then(Value::as_str)
3486                != checkpoint.feed_hash.as_deref()
3487        {
3488            return Err(invalid_feed(
3489                "v2 commit does not extend the pinned feed hash",
3490            ));
3491        }
3492        if pointer.seq > checkpoint.head_seq + 1 {
3493            return replay_v2_feed(
3494                cfg,
3495                brain,
3496                pointer,
3497                identity,
3498                checkpoint.head_seq,
3499                checkpoint.feed_hash.clone(),
3500            );
3501        }
3502    } else {
3503        if let Some(checkpoint) = pinned {
3504            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3505        }
3506        if pointer.seq > 1 {
3507            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3508        }
3509    }
3510    Ok(())
3511}
3512
3513fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3514    require_hardened_filesystem("verified link.md v2 state")?;
3515    require_safe_ref(brain)?;
3516    // Open and pin the private trust directory before a credential can leave
3517    // the process. A hostile checkout must not turn an unsafe state root into
3518    // a network oracle or receive a bearer before checkpoint safety is known.
3519    let trust_directory = open_trust_dir(cfg)?;
3520    let path = format!("/api/hub/brains/{brain}/v2/head");
3521    // A commit is durable before the hub's validation/index recovery view is
3522    // necessarily at that new source head. That is one typed transient state,
3523    // not a rejected commit. Waiting here keeps the accepted commit and the
3524    // local verification baseline in one operation; otherwise a large brain
3525    // can be forced through an expensive historical reconciliation merely
3526    // because its projection lagged for a few seconds.
3527    let started = std::time::Instant::now();
3528    let mut wait = std::time::Duration::from_millis(250);
3529    let response = loop {
3530        let response = request(cfg, "GET", &path, None, Auth::Required)?;
3531        if !v2_validation_catching_up(&response) {
3532            break response;
3533        }
3534        if started.elapsed() >= std::time::Duration::from_secs(15 * 60) {
3535            return Err(LinkError::Http {
3536                what: "v2 head",
3537                status: response.status,
3538                message: "validation/index recovery did not reach the durable source head within 15 minutes"
3539                    .to_string(),
3540                code: Some("validation_index_catching_up".to_string()),
3541                details: response.body,
3542            });
3543        }
3544        std::thread::sleep(wait);
3545        wait = wait
3546            .saturating_mul(2)
3547            .min(std::time::Duration::from_secs(5));
3548    };
3549    if response.status == 404 {
3550        if has_accepted_v2_ref(cfg, brain)? {
3551            return Err(LinkError::BrainUnavailable);
3552        }
3553        return Ok(None);
3554    }
3555    let body = ensure_ok(response, "v2 head")?;
3556    let head: V2HeadResponse = serde_json::from_value(body)
3557        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3558    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3559        return Err(invalid_feed("v2 head has no canonical brain id"));
3560    }
3561    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3562        return Err(invalid_feed("v2 head resolved a different brain id"));
3563    }
3564    if head.profile == "v1" {
3565        return Ok(None);
3566    }
3567    if head.profile != "v2" && head.profile != "v2-empty" {
3568        return Err(invalid_feed("v2 head advertised an unknown profile"));
3569    }
3570    let view = head
3571        .view
3572        .as_ref()
3573        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3574    if !matches!(view.kind.as_str(), "full" | "scoped")
3575        || !is_sha256(&view.control_revision)
3576        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3577    {
3578        return Err(invalid_feed("v2 head has an invalid permission view"));
3579    }
3580    let view_kind = view.kind.clone();
3581    // `id` is the stable materialized namespace. Older v2 hubs did not expose
3582    // it, so retain the fail-closed control-revision fallback for compatibility.
3583    let view_revision = view
3584        .id
3585        .clone()
3586        .unwrap_or_else(|| view.control_revision.clone());
3587    let control_revision = view.control_revision.clone();
3588    let identity = head
3589        .identity
3590        .as_ref()
3591        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3592    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3593    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3594    let feed_identity = v2_identity(identity);
3595    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3596    let (seq, feed_hash, hub_signer) = match &head.pointer {
3597        None => {
3598            if head.profile != "v2-empty" {
3599                return Err(invalid_feed("initialized v2 head has no pointer"));
3600            }
3601            (
3602                0,
3603                None,
3604                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3605            )
3606        }
3607        Some(signed) => {
3608            let signer = verify_v2_pointer(signed, &head.brain_id)?;
3609            if pinned
3610                .as_ref()
3611                .and_then(|state| state.hub_signer.as_ref())
3612                .is_some_and(|known| known != &signer)
3613            {
3614                return Err(invalid_feed(
3615                    "v2 hub pointer signer changed without a trust transition",
3616                ));
3617            }
3618            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3619                if signed.pointer.seq < checkpoint.head_seq
3620                    || (signed.pointer.seq == checkpoint.head_seq
3621                        && checkpoint.feed_hash.as_deref()
3622                            != Some(signed.pointer.feed_hash.as_str()))
3623                {
3624                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3625                }
3626            }
3627            verify_v2_commit(
3628                cfg,
3629                &head.brain_id,
3630                &signed.pointer,
3631                identity,
3632                pinned.as_ref(),
3633            )?;
3634            (
3635                signed.pointer.seq,
3636                Some(signed.pointer.feed_hash.clone()),
3637                Some(signer),
3638            )
3639        }
3640    };
3641    let trust = TrustState {
3642        v: 2,
3643        origin: normalized_origin(&cfg.hub)?,
3644        requested: head.brain_id.clone(),
3645        brain: head.brain_id.clone(),
3646        home: None,
3647        anchor,
3648        current: format!("ed25519:{}", identity.fingerprint),
3649        head_seq: seq,
3650        feed_hash,
3651        rotations: identity.rotations.clone(),
3652        hub_signer,
3653        protocol_profile: Some("link-v2".to_string()),
3654    };
3655    Ok(Some(V2VerifiedHead {
3656        requested: brain.to_string(),
3657        brain_id: head.brain_id,
3658        view_kind,
3659        view_revision,
3660        control_revision,
3661        identity: identity.clone(),
3662        pointer: head.pointer.map(|signed| signed.pointer),
3663        trust,
3664        alias: alias_binding,
3665    }))
3666}
3667
3668fn v2_validation_catching_up(response: &HubResponse) -> bool {
3669    response.status == 422
3670        && response.body.as_ref().is_some_and(|body| {
3671            body.get("code").and_then(Value::as_str) == Some("validation_index_catching_up")
3672                || body
3673                    .get("details")
3674                    .and_then(|details| details.get("code"))
3675                    .and_then(Value::as_str)
3676                    == Some("validation_index_catching_up")
3677        })
3678}
3679
3680fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3681    let directory = open_trust_dir(cfg)?;
3682    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3683    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3684    if let Some(current) = current {
3685        let common_invalid = head.trust.anchor != current.anchor
3686            || !head.trust.rotations.starts_with(&current.rotations);
3687        let profile_invalid = if accepted_as_v2(&current) {
3688            head.trust.head_seq < current.head_seq
3689                || (head.trust.head_seq == current.head_seq
3690                    && head.trust.feed_hash != current.feed_hash)
3691                || current
3692                    .hub_signer
3693                    .as_ref()
3694                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3695        } else {
3696            head.trust.protocol_profile.as_deref() != Some("link-v2")
3697                || head.trust.hub_signer.is_none()
3698        };
3699        if common_invalid || profile_invalid {
3700            return Err(invalid_feed(
3701                "v2 head cannot advance the currently accepted trust checkpoint",
3702            ));
3703        }
3704    }
3705    save_canonical_pin_and_alias(
3706        cfg,
3707        &directory,
3708        &head.requested,
3709        &head.brain_id,
3710        head.trust.clone(),
3711        alias.as_ref().or(head.alias.as_ref()),
3712    )
3713}
3714
3715#[derive(Debug, Clone, Deserialize, Serialize)]
3716struct V2BaselineFile {
3717    sha256: String,
3718    bytes: u64,
3719    #[serde(skip)]
3720    proof: Option<Vec<V2ProofStep>>,
3721}
3722
3723/// Private checkout acceleration, analogous to Git's stat cache. A cached
3724/// hash is reusable only when the same held regular file still has the exact
3725/// Unix identity/timestamps/length and those timestamps were already outside
3726/// the racy-clean window when observed. `withheld_targets` is policy-specific;
3727/// the complete cache is ignored when `.sevralocal`'s digest changes.
3728#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
3729struct V2ScanCacheFile {
3730    fingerprint: String,
3731    sha256: String,
3732    bytes: u64,
3733    #[serde(default)]
3734    withheld_targets: Vec<String>,
3735}
3736
3737#[derive(Debug, Clone, Deserialize, Serialize)]
3738struct V2SyncBaseline {
3739    v: u8,
3740    origin: String,
3741    brain: String,
3742    #[serde(default)]
3743    checkout_id: Option<String>,
3744    #[serde(default)]
3745    head_seq: Option<u64>,
3746    commit_hash: Option<String>,
3747    content_root: Option<String>,
3748    #[serde(default)]
3749    asset_root: Option<String>,
3750    #[serde(default)]
3751    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3752    #[serde(default)]
3753    view_kind: Option<String>,
3754    #[serde(default)]
3755    view_revision: Option<String>,
3756    /// The authority revision observed with this materialized view. Older
3757    /// baselines omit it and deliberately take one full verified refresh
3758    /// before they become eligible for unchanged-head reuse.
3759    #[serde(default)]
3760    control_revision: Option<String>,
3761    #[serde(default)]
3762    projection_sha256: Option<String>,
3763    files: std::collections::BTreeMap<String, V2BaselineFile>,
3764    #[serde(default)]
3765    scan_cache: std::collections::BTreeMap<String, V2ScanCacheFile>,
3766    #[serde(default)]
3767    local_policy_digest: Option<String>,
3768    #[serde(default)]
3769    local_eligibility: std::collections::BTreeMap<String, bool>,
3770    #[serde(default)]
3771    remote_copy_remains: std::collections::BTreeMap<String, String>,
3772}
3773
3774#[derive(Clone)]
3775struct V2LocalView {
3776    riding: std::collections::BTreeMap<String, (String, u64)>,
3777    scan_cache: std::collections::BTreeMap<String, V2ScanCacheFile>,
3778    eligibility: std::collections::BTreeMap<String, bool>,
3779    policy: crate::linkmd_sync_policy::SyncPolicy,
3780    withheld_links: Vec<V2WithheldLink>,
3781}
3782
3783#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3784struct V2WithheldLink {
3785    source: String,
3786    target: String,
3787}
3788
3789#[derive(Debug, Clone, Deserialize, Serialize)]
3790struct V2ProofStep {
3791    directory_root: String,
3792    component: String,
3793    proof: crate::linkmd_v2::HamtProof,
3794}
3795
3796#[derive(Debug, Deserialize)]
3797struct V2ManifestFile {
3798    path: String,
3799    sha256: String,
3800    bytes: u64,
3801    proof: Vec<V2ProofStep>,
3802}
3803
3804#[derive(Debug, Deserialize)]
3805struct V2ManifestPage {
3806    v: u8,
3807    commit: String,
3808    content_root: Option<String>,
3809    files: Vec<V2ManifestFile>,
3810    next_cursor: Option<String>,
3811}
3812
3813#[derive(Debug, Clone, Deserialize, Serialize)]
3814struct V2BaselineAsset {
3815    blob_sha256: String,
3816    bytes: u64,
3817    media_type: String,
3818    wrappers: Vec<String>,
3819    required: bool,
3820    disposition: String,
3821    leaf_hash: String,
3822}
3823
3824#[derive(Debug, Deserialize)]
3825struct V2AssetManifestItem {
3826    path: String,
3827    blob_sha256: String,
3828    bytes: u64,
3829    media_type: String,
3830    wrappers: Vec<String>,
3831    required: bool,
3832    disposition: String,
3833    leaf_hash: String,
3834    proof: crate::linkmd_v2::HamtProof,
3835}
3836
3837#[derive(Debug, Deserialize)]
3838struct V2AssetManifestPage {
3839    v: u8,
3840    commit: String,
3841    asset_root: Option<String>,
3842    assets: Vec<V2AssetManifestItem>,
3843    next_cursor: Option<String>,
3844}
3845
3846#[derive(Debug, Deserialize)]
3847struct V2SigningCandidate {
3848    seq: u64,
3849    content_root: Option<String>,
3850    asset_root: Option<String>,
3851    signing_bytes_base64: String,
3852    changes_base64: String,
3853    actor_claim_base64: String,
3854}
3855
3856#[derive(Debug, Deserialize)]
3857struct V2SigningCandidatePage {
3858    v: u8,
3859    challenge_id: String,
3860    mutation_id: String,
3861    request_hash: String,
3862    parent: V2SigningParent,
3863    candidate: V2SigningCandidate,
3864    files: Vec<V2ManifestFile>,
3865    #[serde(default)]
3866    assets: Vec<V2AssetManifestItem>,
3867    next_cursor: Option<String>,
3868    expires_at: String,
3869}
3870
3871#[derive(Debug, Deserialize)]
3872struct V2SigningParent {
3873    seq: u64,
3874    commit_hash: Option<String>,
3875}
3876
3877fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3878    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3879        .map_err(|error| invalid_feed(error.to_string()))?;
3880    let components = normalized.split('/').collect::<Vec<_>>();
3881    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3882        return Err(invalid_feed("v2 file proof has the wrong shape"));
3883    }
3884    let mut directory_root = root.to_string();
3885    for (index, step) in file.proof.iter().enumerate() {
3886        if step.directory_root != directory_root || step.component != components[index] {
3887            return Err(invalid_feed(
3888                "v2 file proof path chain differs from its manifest",
3889            ));
3890        }
3891        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3892            .map_err(|error| invalid_feed(error.to_string()))?
3893        {
3894            return Err(invalid_feed("v2 file proof failed verification"));
3895        }
3896        let entry = match &step.proof {
3897            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3898            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3899                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3900            }
3901        };
3902        if index + 1 == components.len() {
3903            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3904                || entry.child_hash != file.sha256
3905                || entry.bytes != Some(file.bytes)
3906            {
3907                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3908            }
3909        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3910            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3911        } else {
3912            directory_root = entry.child_hash.clone();
3913        }
3914    }
3915    Ok(())
3916}
3917
3918fn v2_manifest(
3919    cfg: &HubConfig,
3920    brain: &str,
3921    pointer: Option<&V2PointerBody>,
3922) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3923    let Some(pointer) = pointer else {
3924        return Ok(std::collections::BTreeMap::new());
3925    };
3926    let Some(root) = pointer.content_root.as_deref() else {
3927        return Ok(std::collections::BTreeMap::new());
3928    };
3929    let mut files = std::collections::BTreeMap::new();
3930    let mut after = String::new();
3931    loop {
3932        let encoded_after: String =
3933            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3934        let path = format!(
3935            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3936            pointer.commit_hash
3937        );
3938        let value = ensure_ok(
3939            request_capped(
3940                cfg,
3941                "GET",
3942                &path,
3943                None,
3944                Auth::Required,
3945                MAX_FEED_RESPONSE_BYTES,
3946            )?,
3947            "v2 file manifest",
3948        )?;
3949        let page: V2ManifestPage = serde_json::from_value(value)
3950            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3951        if page.v != 2
3952            || page.commit != pointer.commit_hash
3953            || page.content_root.as_deref() != Some(root)
3954            || page.files.len() > 500
3955        {
3956            return Err(invalid_feed(
3957                "v2 file manifest is not bound to the verified head",
3958            ));
3959        }
3960        for file in page.files {
3961            verify_v2_file_proof(root, &file)?;
3962            if files
3963                .insert(
3964                    file.path.clone(),
3965                    V2BaselineFile {
3966                        sha256: file.sha256,
3967                        bytes: file.bytes,
3968                        proof: Some(file.proof),
3969                    },
3970                )
3971                .is_some()
3972            {
3973                return Err(invalid_feed("v2 file manifest repeats a path"));
3974            }
3975            if files.len() > MAX_PUSH_FILES {
3976                return Err(invalid_feed(
3977                    "v2 file manifest exceeds the file-count bound",
3978                ));
3979            }
3980        }
3981        match page.next_cursor {
3982            None => break,
3983            Some(next) if next > after => after = next,
3984            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3985        }
3986    }
3987    Ok(files)
3988}
3989
3990/// Fetch one exact path proof instead of enumerating the whole permission
3991/// view. This keeps `resolve @brain/path` proportional to path depth even for
3992/// a very large brain. The returned leaf is accepted only after its complete
3993/// proof chain reaches the content root signed by `pointer`.
3994fn v2_manifest_file(
3995    cfg: &HubConfig,
3996    brain: &str,
3997    pointer: &V2PointerBody,
3998    path: &str,
3999) -> LinkResult<Option<V2BaselineFile>> {
4000    let Some(root) = pointer.content_root.as_deref() else {
4001        return Ok(None);
4002    };
4003    crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
4004        path: error.to_string(),
4005    })?;
4006    let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
4007    let response = request_capped(
4008        cfg,
4009        "GET",
4010        &format!(
4011            "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
4012            pointer.commit_hash
4013        ),
4014        None,
4015        Auth::Required,
4016        MAX_FEED_RESPONSE_BYTES,
4017    )?;
4018    // Exact v2 file reads deliberately collapse absent and unreadable paths
4019    // into the same 404. Preserve that non-oracle as a normal `None`; callers
4020    // such as `resolve` then produce the stable, local `NOT_FOUND` contract.
4021    if response.status == 404 {
4022        return Ok(None);
4023    }
4024    let value = ensure_ok(response, "v2 exact file proof")?;
4025    let mut page: V2ManifestPage = serde_json::from_value(value)
4026        .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
4027    if page.v != 2
4028        || page.commit != pointer.commit_hash
4029        || page.content_root.as_deref() != Some(root)
4030        || page.next_cursor.is_some()
4031        || page.files.len() != 1
4032        || page.files[0].path != path
4033    {
4034        return Err(invalid_feed(
4035            "v2 exact file proof is not bound to the requested signed path",
4036        ));
4037    }
4038    let file = page.files.pop().expect("exactly one file was checked");
4039    verify_v2_file_proof(root, &file)?;
4040    Ok(Some(V2BaselineFile {
4041        sha256: file.sha256,
4042        bytes: file.bytes,
4043        proof: Some(file.proof),
4044    }))
4045}
4046
4047/// Resolve an id through the hub's synchronously maintained, content-light
4048/// validation certificate, then require the same exact signed path proof as a
4049/// path lookup. The certificate is only an efficient locator: the downloaded
4050/// bytes are parsed again and their id is checked by the caller.
4051fn v2_manifest_file_by_id(
4052    cfg: &HubConfig,
4053    brain: &str,
4054    pointer: &V2PointerBody,
4055    id: &str,
4056) -> LinkResult<(String, V2BaselineFile)> {
4057    let root = pointer
4058        .content_root
4059        .as_deref()
4060        .ok_or_else(|| LinkError::Http {
4061            what: "resolve",
4062            status: 404,
4063            message: "record not found".to_string(),
4064            code: Some("NOT_FOUND".to_string()),
4065            details: None,
4066        })?;
4067    if !crate::ulid::is_ulid(id) {
4068        return Err(LinkError::BadAddress {
4069            given: id.to_string(),
4070            reason: BAD_TARGET_REASON.to_string(),
4071        });
4072    }
4073    let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
4074    let value = ensure_ok(
4075        request_capped(
4076            cfg,
4077            "GET",
4078            &format!(
4079                "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
4080                pointer.commit_hash
4081            ),
4082            None,
4083            Auth::Required,
4084            MAX_FEED_RESPONSE_BYTES,
4085        )?,
4086        "v2 exact id proof",
4087    )?;
4088    let mut page: V2ManifestPage = serde_json::from_value(value)
4089        .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
4090    if page.v != 2
4091        || page.commit != pointer.commit_hash
4092        || page.content_root.as_deref() != Some(root)
4093        || page.next_cursor.is_some()
4094        || page.files.len() != 1
4095    {
4096        return Err(invalid_feed(
4097            "v2 exact id proof is not bound to one signed path",
4098        ));
4099    }
4100    let file = page.files.pop().expect("exactly one file was checked");
4101    if !safe_store_rel_path(&file.path)
4102        || !file.path.ends_with(".md")
4103        || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
4104    {
4105        return Err(invalid_feed("v2 exact id proof has an invalid record path"));
4106    }
4107    verify_v2_file_proof(root, &file)?;
4108    Ok((
4109        file.path,
4110        V2BaselineFile {
4111            sha256: file.sha256,
4112            bytes: file.bytes,
4113            proof: Some(file.proof),
4114        },
4115    ))
4116}
4117
4118fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
4119    crate::linkmd_v2::normalize_path(&item.path)
4120        .map_err(|error| invalid_feed(error.to_string()))?;
4121    if !is_sha256(&item.blob_sha256)
4122        || !is_sha256(&item.leaf_hash)
4123        || item.bytes > MAX_ASSET_BYTES
4124        || item.wrappers.is_empty()
4125        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
4126        || item
4127            .wrappers
4128            .iter()
4129            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4130    {
4131        return Err(invalid_feed("v2 asset manifest item is invalid"));
4132    }
4133    let leaf = json!({
4134        "blob_sha256": item.blob_sha256,
4135        "bytes": item.bytes,
4136        "disposition": item.disposition,
4137        "media_type": item.media_type,
4138        "path": item.path,
4139        "required": item.required,
4140        "v": 2,
4141        "wrappers": item.wrappers,
4142    });
4143    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
4144        .map_err(|error| invalid_feed(error.to_string()))?
4145        != item.leaf_hash
4146        || !crate::linkmd_v2::verify_proof_with_domain(
4147            root,
4148            &item.path,
4149            &item.proof,
4150            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
4151        )
4152        .map_err(|error| invalid_feed(error.to_string()))?
4153    {
4154        return Err(invalid_feed("v2 asset inclusion proof failed"));
4155    }
4156    match &item.proof {
4157        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
4158            if entry.name == item.path
4159                && entry.kind == crate::linkmd_v2::EntryKind::Blob
4160                && entry.child_hash == item.leaf_hash
4161                && entry.bytes == Some(item.bytes) =>
4162        {
4163            Ok(())
4164        }
4165        _ => Err(invalid_feed(
4166            "v2 asset proof leaf differs from its manifest",
4167        )),
4168    }
4169}
4170
4171fn v2_asset_manifest(
4172    cfg: &HubConfig,
4173    brain: &str,
4174    pointer: Option<&V2PointerBody>,
4175) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
4176    let Some(pointer) = pointer else {
4177        return Ok(std::collections::BTreeMap::new());
4178    };
4179    let Some(root) = pointer.asset_root.as_deref() else {
4180        return Ok(std::collections::BTreeMap::new());
4181    };
4182    let mut assets = std::collections::BTreeMap::new();
4183    let mut after = String::new();
4184    loop {
4185        let encoded_after: String =
4186            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4187        let path = format!(
4188            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4189            pointer.commit_hash
4190        );
4191        let value = ensure_ok(
4192            request_capped(
4193                cfg,
4194                "GET",
4195                &path,
4196                None,
4197                Auth::Required,
4198                MAX_FEED_RESPONSE_BYTES,
4199            )?,
4200            "v2 asset manifest",
4201        )?;
4202        let page: V2AssetManifestPage = serde_json::from_value(value)
4203            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4204        if page.v != 2
4205            || page.commit != pointer.commit_hash
4206            || page.asset_root.as_deref() != Some(root)
4207            || page.assets.len() > 500
4208        {
4209            return Err(invalid_feed(
4210                "v2 asset manifest is not bound to the verified head",
4211            ));
4212        }
4213        for item in page.assets {
4214            verify_v2_asset_proof(root, &item)?;
4215            let path = item.path.clone();
4216            if assets
4217                .insert(
4218                    path,
4219                    V2BaselineAsset {
4220                        blob_sha256: item.blob_sha256,
4221                        bytes: item.bytes,
4222                        media_type: item.media_type,
4223                        wrappers: item.wrappers,
4224                        required: item.required,
4225                        disposition: item.disposition,
4226                        leaf_hash: item.leaf_hash,
4227                    },
4228                )
4229                .is_some()
4230            {
4231                return Err(invalid_feed("v2 asset manifest repeats a path"));
4232            }
4233            if assets.len() > MAX_PUSH_FILES {
4234                return Err(invalid_feed(
4235                    "v2 asset manifest exceeds the item-count bound",
4236                ));
4237            }
4238        }
4239        match page.next_cursor {
4240            None => break,
4241            Some(next) if next > after => after = next,
4242            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4243        }
4244    }
4245    Ok(assets)
4246}
4247
4248fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4249    crate::AssetRecord {
4250        path: path.to_string(),
4251        sha256: asset.blob_sha256.clone(),
4252        bytes: asset.bytes,
4253        media_type: asset.media_type.clone(),
4254        wrappers: asset.wrappers.clone(),
4255        required: asset.required,
4256    }
4257}
4258
4259fn v2_asset_resumes_hosting(
4260    remote: Option<&V2BaselineAsset>,
4261    path: &str,
4262    record: &crate::AssetRecord,
4263    disposition: &str,
4264) -> bool {
4265    remote.is_some_and(|asset| {
4266        asset.disposition == "withheld"
4267            && disposition == "hosted"
4268            && v2_asset_record(asset, path) == *record
4269    })
4270}
4271
4272fn v2_asset_inherits_withheld_absence(
4273    base: Option<&V2BaselineAsset>,
4274    base_record: Option<&crate::AssetRecord>,
4275    local_record: Option<&crate::AssetRecord>,
4276    raw_present: bool,
4277) -> bool {
4278    !raw_present
4279        && base.is_some_and(|asset| asset.disposition == "withheld")
4280        && local_record == base_record
4281}
4282
4283fn v2_asset_record_manifest_bytes(
4284    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4285) -> LinkResult<Vec<u8>> {
4286    let mut bytes = Vec::new();
4287    for (path, asset) in assets {
4288        if asset.path != *path {
4289            return Err(invalid_feed(
4290                "local asset manifest key differs from its record path",
4291            ));
4292        }
4293        serde_json::to_writer(&mut bytes, asset)
4294            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4295        bytes.push(b'\n');
4296    }
4297    Ok(bytes)
4298}
4299
4300fn v2_local_asset_records(
4301    store: &Store,
4302) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4303    let assets = crate::assets::read_manifest(store)
4304        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4305    if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4306        return Err(LinkError::InvalidPack {
4307            message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4308        });
4309    }
4310    Ok(assets
4311        .into_iter()
4312        .map(|asset| (asset.path.clone(), asset))
4313        .collect())
4314}
4315
4316fn v2_asset_records_match_remote(
4317    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4318    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4319) -> bool {
4320    local.len() == remote.len()
4321        && remote
4322            .iter()
4323            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4324}
4325
4326#[derive(Debug, Clone, PartialEq, Eq)]
4327struct V2PulledMerge<T> {
4328    records: std::collections::BTreeMap<String, T>,
4329    accept_remote: std::collections::BTreeSet<String>,
4330    conflicts: Vec<String>,
4331}
4332
4333/// Compute the local checkout that a pull may install without discarding a
4334/// local-only edit. Remote-only changes are accepted, identical concurrent
4335/// changes converge, and divergent concurrent changes are reported. The
4336/// caller can exempt kept-home coordinates from both remote installation and
4337/// conflict disclosure.
4338fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4339    base: &std::collections::BTreeMap<String, Base>,
4340    remote: &std::collections::BTreeMap<String, Remote>,
4341    local: &std::collections::BTreeMap<String, Record>,
4342    base_record: BaseRecord,
4343    remote_record: RemoteRecord,
4344    keep_local: KeepLocal,
4345) -> V2PulledMerge<Record>
4346where
4347    Record: Clone + Eq,
4348    BaseRecord: Fn(&Base, &str) -> Record,
4349    RemoteRecord: Fn(&Remote, &str) -> Record,
4350    KeepLocal: Fn(&str) -> bool,
4351{
4352    let paths = base
4353        .keys()
4354        .chain(remote.keys())
4355        .chain(local.keys())
4356        .cloned()
4357        .collect::<std::collections::BTreeSet<_>>();
4358    let mut records = local.clone();
4359    let mut accept_remote = std::collections::BTreeSet::new();
4360    let mut conflicts = Vec::new();
4361    for path in paths {
4362        if keep_local(&path) {
4363            continue;
4364        }
4365        let base_value = base.get(&path).map(|value| base_record(value, &path));
4366        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4367        let local_value = local.get(&path).cloned();
4368        if local_value != base_value && remote_value != base_value && local_value != remote_value {
4369            conflicts.push(path);
4370            continue;
4371        }
4372        if local_value == base_value || local_value == remote_value {
4373            accept_remote.insert(path.clone());
4374            match remote_value {
4375                Some(value) => {
4376                    records.insert(path, value);
4377                }
4378                None => {
4379                    records.remove(&path);
4380                }
4381            }
4382        }
4383    }
4384    V2PulledMerge {
4385        records,
4386        accept_remote,
4387        conflicts,
4388    }
4389}
4390
4391fn sign_verified_v2_candidate(
4392    cfg: &HubConfig,
4393    head: &V2VerifiedHead,
4394    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4395    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4396    mutation_id: &str,
4397    request_body: &Value,
4398    challenge_value: &Value,
4399) -> LinkResult<(String, String, String)> {
4400    if head.view_kind != "full" {
4401        return Err(invalid_feed(
4402            "a scoped self-custody writer must use the proposal workflow",
4403        ));
4404    }
4405    if head.identity.custody != "self" {
4406        return Err(invalid_feed(
4407            "a hub-custodied brain unexpectedly requested an external signature",
4408        ));
4409    }
4410    let key = cfg
4411        .brain_key
4412        .as_ref()
4413        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4414    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4415        || key.public_key_spki != head.identity.public_key_spki
4416    {
4417        return Err(bad_agent_key(
4418            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4419        ));
4420    }
4421    let challenge_id = challenge_value
4422        .get("id")
4423        .and_then(Value::as_str)
4424        .filter(|id| crate::ulid::is_ulid(id))
4425        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4426    let expected_endpoint = format!(
4427        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4428        head.brain_id
4429    );
4430    if challenge_value
4431        .get("candidate_endpoint")
4432        .and_then(Value::as_str)
4433        != Some(expected_endpoint.as_str())
4434    {
4435        return Err(invalid_feed(
4436            "self-custody challenge candidate endpoint is not origin-bound",
4437        ));
4438    }
4439
4440    let mut files = std::collections::BTreeMap::new();
4441    let mut after = String::new();
4442    type CandidateCoordinate = (
4443        String,
4444        String,
4445        String,
4446        String,
4447        Option<String>,
4448        Option<String>,
4449        u64,
4450        Option<String>,
4451    );
4452    let mut pinned: Option<CandidateCoordinate> = None;
4453    loop {
4454        let encoded_after: String =
4455            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4456        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4457        let value = ensure_ok(
4458            request_capped(
4459                cfg,
4460                "GET",
4461                &path,
4462                None,
4463                Auth::Required,
4464                MAX_FEED_RESPONSE_BYTES,
4465            )?,
4466            "v2 self-custody candidate",
4467        )?;
4468        let page: V2SigningCandidatePage = serde_json::from_value(value)
4469            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4470        if page.v != 2
4471            || page.challenge_id != challenge_id
4472            || page.mutation_id != mutation_id
4473            || page.candidate.seq != page.parent.seq + 1
4474            || page.files.len() > 500
4475            || page.expires_at.is_empty()
4476        {
4477            return Err(invalid_feed(
4478                "self-custody candidate is not bound to this mutation",
4479            ));
4480        }
4481        let coordinate = (
4482            page.request_hash.clone(),
4483            page.candidate.signing_bytes_base64.clone(),
4484            page.candidate.changes_base64.clone(),
4485            page.candidate.actor_claim_base64.clone(),
4486            page.candidate.content_root.clone(),
4487            page.candidate.asset_root.clone(),
4488            page.parent.seq,
4489            page.parent.commit_hash.clone(),
4490        );
4491        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4492            return Err(invalid_feed(
4493                "self-custody candidate changed between manifest pages",
4494            ));
4495        }
4496        pinned = Some(coordinate);
4497        let root = page
4498            .candidate
4499            .content_root
4500            .as_deref()
4501            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4502        for file in page.files {
4503            verify_v2_file_proof(root, &file)?;
4504            if files
4505                .insert(
4506                    file.path.clone(),
4507                    V2BaselineFile {
4508                        sha256: file.sha256,
4509                        bytes: file.bytes,
4510                        proof: Some(file.proof),
4511                    },
4512                )
4513                .is_some()
4514            {
4515                return Err(invalid_feed(
4516                    "self-custody candidate repeats a manifest path",
4517                ));
4518            }
4519            if files.len() > MAX_PUSH_FILES {
4520                return Err(invalid_feed(
4521                    "self-custody candidate exceeds the file-count bound",
4522                ));
4523            }
4524        }
4525        match page.next_cursor {
4526            None => break,
4527            Some(next) if next > after => after = next,
4528            Some(_) => {
4529                return Err(invalid_feed(
4530                    "self-custody candidate cursor did not advance",
4531                ))
4532            }
4533        }
4534    }
4535    if files.len() != expected.len()
4536        || files.iter().any(|(path, file)| {
4537            expected.get(path).is_none_or(|expected| {
4538                expected.sha256 != file.sha256 || expected.bytes != file.bytes
4539            })
4540        })
4541    {
4542        return Err(invalid_feed(
4543            "self-custody candidate contains an unexpected file mutation",
4544        ));
4545    }
4546    let mut assets = std::collections::BTreeMap::new();
4547    after.clear();
4548    loop {
4549        let encoded_after: String =
4550            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4551        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4552        let value = ensure_ok(
4553            request_capped(
4554                cfg,
4555                "GET",
4556                &path,
4557                None,
4558                Auth::Required,
4559                MAX_FEED_RESPONSE_BYTES,
4560            )?,
4561            "v2 self-custody asset candidate",
4562        )?;
4563        let page: V2SigningCandidatePage = serde_json::from_value(value)
4564            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4565        let coordinate = (
4566            page.request_hash.clone(),
4567            page.candidate.signing_bytes_base64.clone(),
4568            page.candidate.changes_base64.clone(),
4569            page.candidate.actor_claim_base64.clone(),
4570            page.candidate.content_root.clone(),
4571            page.candidate.asset_root.clone(),
4572            page.parent.seq,
4573            page.parent.commit_hash.clone(),
4574        );
4575        if page.v != 2
4576            || page.challenge_id != challenge_id
4577            || page.mutation_id != mutation_id
4578            || page.assets.len() > 500
4579            || pinned.as_ref() != Some(&coordinate)
4580        {
4581            return Err(invalid_feed(
4582                "self-custody asset candidate changed or is not bound",
4583            ));
4584        }
4585        let root = page.candidate.asset_root.as_deref();
4586        if !page.assets.is_empty() && root.is_none() {
4587            return Err(invalid_feed("asset candidate has no asset root"));
4588        }
4589        for item in page.assets {
4590            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4591            if assets
4592                .insert(
4593                    item.path.clone(),
4594                    V2BaselineAsset {
4595                        blob_sha256: item.blob_sha256,
4596                        bytes: item.bytes,
4597                        media_type: item.media_type,
4598                        wrappers: item.wrappers,
4599                        required: item.required,
4600                        disposition: item.disposition,
4601                        leaf_hash: item.leaf_hash,
4602                    },
4603                )
4604                .is_some()
4605            {
4606                return Err(invalid_feed("self-custody candidate repeats an asset"));
4607            }
4608        }
4609        match page.next_cursor {
4610            None => break,
4611            Some(next) if next > after => after = next,
4612            Some(_) => {
4613                return Err(invalid_feed(
4614                    "self-custody asset candidate cursor did not advance",
4615                ))
4616            }
4617        }
4618    }
4619    if assets.len() != expected_assets.len()
4620        || assets.iter().any(|(path, asset)| {
4621            expected_assets.get(path).is_none_or(|expected| {
4622                asset.blob_sha256 != expected.blob_sha256
4623                    || asset.bytes != expected.bytes
4624                    || asset.media_type != expected.media_type
4625                    || asset.wrappers != expected.wrappers
4626                    || asset.required != expected.required
4627                    || asset.disposition != expected.disposition
4628            })
4629        })
4630    {
4631        return Err(invalid_feed(
4632            "self-custody candidate contains an unexpected asset mutation",
4633        ));
4634    }
4635    let Some((
4636        request_hash,
4637        signing_b64,
4638        changes_b64,
4639        actor_b64,
4640        root,
4641        asset_root,
4642        parent_seq,
4643        parent,
4644    )) = pinned
4645    else {
4646        return Err(invalid_feed("self-custody candidate has no manifest"));
4647    };
4648    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4649    let current_commit = head
4650        .pointer
4651        .as_ref()
4652        .map(|pointer| pointer.commit_hash.clone());
4653    if parent_seq != current_seq || parent != current_commit {
4654        return Err(LinkError::RemoteAdvancedDuringSync);
4655    }
4656    let changes = STANDARD
4657        .decode(changes_b64)
4658        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4659    let mut expected_changes = json!({
4660        "mutation_id": mutation_id,
4661        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4662        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4663        "v": 2,
4664    });
4665    if let Some(withheld_links) = request_body.get("withheld_links") {
4666        expected_changes["withheld_links"] = withheld_links.clone();
4667    }
4668    if let Some(checkout_id) = request_body.get("checkout_id") {
4669        expected_changes["checkout_id"] = checkout_id.clone();
4670    }
4671    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4672        .map_err(|error| invalid_feed(error.to_string()))?;
4673    if changes != expected_changes_bytes {
4674        return Err(invalid_feed(
4675            "self-custody changeset differs from the requested mutation",
4676        ));
4677    }
4678    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4679        .map_err(|error| invalid_feed(error.to_string()))?;
4680    let request_value = json!({
4681        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4682        "brain": head.brain_id,
4683        "changes_sha256": changes_hash,
4684        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4685        "v": 2,
4686        "v1_bridge": Value::Null,
4687    });
4688    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4689        .map_err(|error| invalid_feed(error.to_string()))?;
4690    if request_hash != expected_request_hash {
4691        return Err(invalid_feed(
4692            "self-custody request hash differs from the requested mutation",
4693        ));
4694    }
4695    let actor = STANDARD
4696        .decode(actor_b64)
4697        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4698    let actor_value: Value = serde_json::from_slice(&actor)
4699        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4700    if crate::linkmd_v2::canonical_bytes(&actor_value)
4701        .map_err(|error| invalid_feed(error.to_string()))?
4702        != actor
4703    {
4704        return Err(invalid_feed("self-custody actor claim is not canonical"));
4705    }
4706    let actor_object = actor_value
4707        .as_object()
4708        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4709    let actor_claim = actor_object
4710        .get("claim")
4711        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4712    let actor_public_key = actor_object
4713        .get("public_key")
4714        .and_then(Value::as_str)
4715        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4716    let actor_fingerprint = actor_object
4717        .get("fingerprint")
4718        .and_then(Value::as_str)
4719        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4720    let actor_signature = actor_object
4721        .get("sig")
4722        .and_then(Value::as_str)
4723        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4724    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4725        .map_err(|error| invalid_feed(error.to_string()))?;
4726    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4727    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4728    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4729    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4730    let impact = actor_claim
4731        .get("result")
4732        .and_then(|result| result.get("impact"))
4733        .and_then(Value::as_object);
4734    let impact_fields = [
4735        "creates",
4736        "updates",
4737        "deletes",
4738        "withdrawals",
4739        "renames",
4740        "restores",
4741        "asset_changes",
4742        "public_expansions",
4743        "executable_activations",
4744    ];
4745    let impact_is_valid = impact.is_some_and(|impact| {
4746        impact.len() == impact_fields.len() + 1
4747            && impact.get("v").and_then(Value::as_u64) == Some(1)
4748            && impact_fields
4749                .iter()
4750                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4751    });
4752    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4753        || head
4754            .trust
4755            .hub_signer
4756            .as_ref()
4757            .is_some_and(|known| known != &expected_actor_signer)
4758        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4759        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4760        || actor_claim
4761            .get("candidate")
4762            .and_then(|candidate| candidate.get("changes_sha256"))
4763            .and_then(Value::as_str)
4764            != Some(changes_hash.as_str())
4765        || actor_claim
4766            .get("candidate")
4767            .and_then(|candidate| candidate.get("state_root"))
4768            != Some(&expected_actor_root)
4769        || actor_claim
4770            .get("candidate")
4771            .and_then(|candidate| candidate.get("asset_root"))
4772            != Some(&expected_actor_asset_root)
4773        || actor_claim
4774            .get("candidate")
4775            .and_then(|candidate| candidate.get("control_revision"))
4776            .and_then(Value::as_str)
4777            != Some(head.control_revision.as_str())
4778        || !impact_is_valid
4779    {
4780        return Err(invalid_feed(
4781            "self-custody actor claim does not bind the verified authority",
4782        ));
4783    }
4784    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4785        .map_err(|error| invalid_feed(error.to_string()))?;
4786    let signing = STANDARD
4787        .decode(signing_b64)
4788        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4789    let signing_value: Value = serde_json::from_slice(&signing)
4790        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4791    if crate::linkmd_v2::canonical_bytes(&signing_value)
4792        .map_err(|error| invalid_feed(error.to_string()))?
4793        != signing
4794    {
4795        return Err(invalid_feed("self-custody signing bytes are not canonical"));
4796    }
4797    let pointer = head.pointer.as_ref();
4798    let expected_materializer = pointer
4799        .map(|value| value.materializer.as_str())
4800        .unwrap_or("dbmd-projection-v1");
4801    let expected_parent_commit = request_body
4802        .get("base")
4803        .and_then(|base| base.get("commit_hash"))
4804        .cloned()
4805        .unwrap_or(Value::Null);
4806    let expected_parent_root = request_body
4807        .get("base")
4808        .and_then(|base| base.get("content_root"))
4809        .cloned()
4810        .unwrap_or(Value::Null);
4811    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4812    let expected_parent_asset_root = request_body
4813        .get("base")
4814        .and_then(|base| base.get("asset_root"))
4815        .cloned()
4816        .unwrap_or(Value::Null);
4817    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4818    let expected_prev_entry = pointer
4819        .map(|value| Value::String(value.feed_hash.clone()))
4820        .unwrap_or(Value::Null);
4821    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4822        .map_err(|_| invalid_feed("brain identity history is too large"))?
4823        + 1;
4824    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4825        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4826        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4827        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4828        || signing_value.get("public_key").and_then(Value::as_str)
4829            != Some(key.public_key_spki.as_str())
4830        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4831        || signing_value.get("parent_root") != Some(&expected_parent_root)
4832        || signing_value.get("state_root") != Some(&expected_state_root)
4833        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4834        || signing_value.get("asset_root") != Some(&expected_asset_root)
4835        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4836        || signing_value.get("changes_sha256").and_then(Value::as_str)
4837            != Some(changes_hash.as_str())
4838        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4839        || signing_value
4840            .get("control_revision")
4841            .and_then(Value::as_str)
4842            != Some(head.control_revision.as_str())
4843        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4844        || signing_value.get("v1_bridge") != Some(&Value::Null)
4845        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4846    {
4847        return Err(invalid_feed(
4848            "self-custody signing bytes do not bind the verified candidate",
4849        ));
4850    }
4851    let pair = agent_keypair(&key.pkcs8)?;
4852    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4853    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4854}
4855
4856fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4857    let origin = normalized_origin(&cfg.hub)?;
4858    let absolute = if checkout.is_absolute() {
4859        checkout.to_path_buf()
4860    } else {
4861        std::env::current_dir()?.join(checkout)
4862    };
4863    Ok(format!(
4864        "sync-{}.json",
4865        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4866    ))
4867}
4868
4869fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4870    if let Some(value) = existing {
4871        if !is_sha256(value) {
4872            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4873        }
4874        return Ok(value.to_string());
4875    }
4876    use ring::rand::SecureRandom as _;
4877    let mut random = [0_u8; 32];
4878    ring::rand::SystemRandom::new()
4879        .fill(&mut random)
4880        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4881    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4882}
4883
4884#[cfg(any(unix, windows))]
4885fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4886    let directory = open_trust_dir(cfg)?;
4887    let origin = normalized_origin(&cfg.hub)?;
4888    let name = format!(
4889        "operation-{}.lock",
4890        content_sha256(format!("{origin}\0{brain}").as_bytes())
4891    );
4892    lock_trust_name(&directory, &name)
4893}
4894
4895#[cfg(not(any(unix, windows)))]
4896fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4897    Err(LinkError::UnsupportedPlatform {
4898        operation: "serialized link.md v2 sync",
4899    })
4900}
4901
4902fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4903    left.brain_id == right.brain_id
4904        && left.view_kind == right.view_kind
4905        && left.view_revision == right.view_revision
4906        && left.control_revision == right.control_revision
4907        && match (&left.pointer, &right.pointer) {
4908            (None, None) => true,
4909            (Some(left), Some(right)) => {
4910                left.seq == right.seq
4911                    && left.commit_hash == right.commit_hash
4912                    && left.content_root == right.content_root
4913                    && left.asset_root == right.asset_root
4914                    && left.feed_hash == right.feed_hash
4915            }
4916            _ => false,
4917        }
4918}
4919
4920/// A private sync baseline is an already verified materialization of the
4921/// remote manifest. It may replace another paginated manifest download only
4922/// when a freshly verified signed head names the exact same content, assets,
4923/// view, and authority revision. Any remote or permission movement falls back
4924/// to proof-bearing hub reads.
4925fn v2_baseline_matches_head(head: &V2VerifiedHead, baseline: &V2SyncBaseline) -> bool {
4926    let pointer = head.pointer.as_ref();
4927    baseline.head_seq == Some(pointer.map_or(0, |value| value.seq))
4928        && baseline.commit_hash.as_deref() == pointer.map(|value| value.commit_hash.as_str())
4929        && baseline.content_root.as_deref()
4930            == pointer.and_then(|value| value.content_root.as_deref())
4931        && baseline.asset_root.as_deref() == pointer.and_then(|value| value.asset_root.as_deref())
4932        && baseline.view_kind.as_deref() == Some(head.view_kind.as_str())
4933        && baseline.view_revision.as_deref() == Some(head.view_revision.as_str())
4934        && baseline.control_revision.as_deref() == Some(head.control_revision.as_str())
4935}
4936
4937fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4938    format!(
4939        "---\ntype: db-md\nscope: company\nowner: link.md scoped view\n---\n\n# Scoped brain view\n\nThis DB.md is generated locally by dbmd. It is not the brain's canonical contract and is never uploaded.\n\nCanonical brain: @{brain}\n"
4940    )
4941    .into_bytes()
4942}
4943
4944fn scoped_projection_sha256(brain: &str) -> String {
4945    content_sha256(&scoped_projection_bytes(brain))
4946}
4947
4948#[derive(Deserialize)]
4949struct LocalScopedViewMarker {
4950    v: u8,
4951    kind: String,
4952    authoritative: bool,
4953    brain: String,
4954    projection_sha256: String,
4955}
4956
4957/// True only when this store carries the exact generated marker for a local
4958/// link.md scoped view. This is presentation context, never authorization;
4959/// the hub remains the only authority for reads and writes.
4960pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4961    let marker = store
4962        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4963        .ok()
4964        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4965    let Some(marker) = marker else {
4966        return false;
4967    };
4968    if marker.v != 1
4969        || marker.kind != "link.md-scoped-view"
4970        || marker.authoritative
4971        || !crate::ulid::is_ulid(&marker.brain)
4972        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4973    {
4974        return false;
4975    }
4976    store
4977        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4978        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4979}
4980
4981fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4982    let mut bytes = serde_json::to_vec_pretty(&json!({
4983        "v": 1,
4984        "kind": "link.md-scoped-view",
4985        "authoritative": false,
4986        "brain": head.brain_id,
4987        "view_revision": head.view_revision,
4988        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4989        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4990        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4991        "visible_files": files,
4992        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4993    }))
4994    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4995    bytes.push(b'\n');
4996    Ok(bytes)
4997}
4998
4999fn refresh_scoped_view_marker(
5000    store: &Store,
5001    head: &V2VerifiedHead,
5002    files: usize,
5003) -> LinkResult<()> {
5004    if head.view_kind == "scoped" {
5005        store.write_atomic(
5006            Path::new(".dbmd/view.json"),
5007            &scoped_view_metadata(head, files)?,
5008        )?;
5009    }
5010    Ok(())
5011}
5012
5013fn ensure_v2_view_compatible(
5014    head: &V2VerifiedHead,
5015    baseline: Option<&V2SyncBaseline>,
5016) -> LinkResult<()> {
5017    let Some(baseline) = baseline else {
5018        return Ok(());
5019    };
5020    match (
5021        baseline.view_kind.as_deref(),
5022        baseline.view_revision.as_deref(),
5023    ) {
5024        (None, None) if head.view_kind == "full" => Ok(()),
5025        (Some(kind), Some(revision))
5026            if kind == head.view_kind && revision == head.view_revision =>
5027        {
5028            Ok(())
5029        }
5030        _ => Err(LinkError::ScopedViewChanged),
5031    }
5032}
5033
5034fn ensure_established_v2_checkout_opened(
5035    head: &V2VerifiedHead,
5036    baseline: Option<&V2SyncBaseline>,
5037    opened: bool,
5038) -> LinkResult<()> {
5039    if baseline.is_none() || opened {
5040        return Ok(());
5041    }
5042    if head.view_kind == "scoped" {
5043        return Err(LinkError::ScopedProjectionModified);
5044    }
5045    Err(LinkError::InvalidPack {
5046        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
5047    })
5048}
5049
5050fn remove_scoped_projection(
5051    head: &V2VerifiedHead,
5052    baseline: Option<&V2SyncBaseline>,
5053    view: &mut V2LocalView,
5054) -> LinkResult<()> {
5055    if head.view_kind != "scoped" {
5056        return Ok(());
5057    }
5058    let expected = scoped_projection_sha256(&head.brain_id);
5059    if baseline
5060        .and_then(|state| state.projection_sha256.as_deref())
5061        .is_some_and(|pinned| pinned != expected)
5062    {
5063        return Err(LinkError::ScopedViewChanged);
5064    }
5065    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
5066        return Err(LinkError::ScopedProjectionModified);
5067    }
5068    view.riding.remove("DB.md");
5069    view.scan_cache.remove("DB.md");
5070    view.eligibility.remove("DB.md");
5071    Ok(())
5072}
5073
5074fn local_view_for_v2_push(
5075    store: &Store,
5076    head: &V2VerifiedHead,
5077    baseline: Option<&V2SyncBaseline>,
5078    carried: Option<V2LocalView>,
5079) -> LinkResult<V2LocalView> {
5080    match carried {
5081        // The pull half already verified and removed the generated scoped
5082        // projection before constructing its in-memory handoff. Requiring it
5083        // a second time would turn every scoped bidirectional write into a
5084        // false tamper refusal.
5085        Some(view) => Ok(view),
5086        None => {
5087            let hint = baseline.and_then(|state| {
5088                state
5089                    .local_policy_digest
5090                    .as_deref()
5091                    .map(|digest| (digest, &state.scan_cache))
5092            });
5093            let mut view = v2_local_files_cached(store, hint)?;
5094            remove_scoped_projection(head, baseline, &mut view)?;
5095            Ok(view)
5096        }
5097    }
5098}
5099
5100fn files_for_v2_view(
5101    head: &V2VerifiedHead,
5102    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
5103) -> std::collections::BTreeMap<String, V2BaselineFile> {
5104    if head.view_kind == "scoped" {
5105        // Even when a grant explicitly includes canonical DB.md, a partial
5106        // checkout uses a generated marker. Canonical validation remains a
5107        // server-side operation over the complete brain.
5108        files.remove("DB.md");
5109    }
5110    files
5111}
5112
5113fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
5114    let baseline: V2SyncBaseline =
5115        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
5116    if baseline.v != 2
5117        || baseline.origin != normalized_origin(&cfg.hub)?
5118        || baseline.brain != brain
5119        || baseline
5120            .commit_hash
5121            .as_deref()
5122            .is_some_and(|hash| !is_sha256(hash))
5123        || baseline
5124            .content_root
5125            .as_deref()
5126            .is_some_and(|hash| !is_sha256(hash))
5127        || baseline
5128            .asset_root
5129            .as_deref()
5130            .is_some_and(|hash| !is_sha256(hash))
5131        || baseline
5132            .local_policy_digest
5133            .as_deref()
5134            .is_some_and(|hash| !is_sha256(hash))
5135        || baseline
5136            .view_kind
5137            .as_deref()
5138            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
5139        || baseline
5140            .view_revision
5141            .as_deref()
5142            .is_some_and(|hash| !is_sha256(hash))
5143        || baseline
5144            .control_revision
5145            .as_deref()
5146            .is_some_and(|hash| !is_sha256(hash))
5147        || baseline
5148            .projection_sha256
5149            .as_deref()
5150            .is_some_and(|hash| !is_sha256(hash))
5151        || (baseline.view_kind.as_deref() == Some("scoped")
5152            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
5153        || baseline.files.len() > MAX_PUSH_FILES
5154        || baseline.scan_cache.len() > MAX_PUSH_FILES
5155        || baseline.assets.len() > MAX_PUSH_FILES
5156        || baseline.local_eligibility.len() > MAX_PUSH_FILES
5157        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
5158        || baseline.files.iter().any(|(path, file)| {
5159            crate::linkmd_v2::normalize_path(path).is_err()
5160                || !is_sha256(&file.sha256)
5161                || file.bytes > MAX_STORE_BYTES
5162        })
5163        || baseline.scan_cache.iter().any(|(path, file)| {
5164            crate::linkmd_v2::normalize_path(path).is_err()
5165                || !file.fingerprint.starts_with("unix-v1:")
5166                || file.fingerprint.len() > 256
5167                || !file.fingerprint.is_ascii()
5168                || !is_sha256(&file.sha256)
5169                || file.bytes > MAX_STORE_BYTES
5170                || file.withheld_targets.len() > MAX_PUSH_FILES
5171                || file.withheld_targets.iter().any(|target| {
5172                    target.len() > MAX_STORE_PATH_BYTES
5173                        || crate::linkmd_v2::normalize_path(target).is_err()
5174                })
5175                || baseline.files.get(path).is_none_or(|baseline_file| {
5176                    baseline_file.sha256 != file.sha256 || baseline_file.bytes != file.bytes
5177                })
5178        })
5179        || baseline.assets.iter().any(|(path, asset)| {
5180            crate::linkmd_v2::normalize_path(path).is_err()
5181                || !is_sha256(&asset.blob_sha256)
5182                || !is_sha256(&asset.leaf_hash)
5183                || asset.bytes > MAX_ASSET_BYTES
5184                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
5185                || asset.wrappers.is_empty()
5186                || asset
5187                    .wrappers
5188                    .iter()
5189                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
5190        })
5191        || baseline
5192            .local_eligibility
5193            .keys()
5194            .chain(baseline.remote_copy_remains.keys())
5195            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
5196        || baseline
5197            .remote_copy_remains
5198            .values()
5199            .any(|hash| !is_sha256(hash))
5200        || baseline
5201            .checkout_id
5202            .as_deref()
5203            .is_some_and(|checkout_id| !is_sha256(checkout_id))
5204    {
5205        return Err(invalid_feed("v2 sync baseline failed validation"));
5206    }
5207    Ok(baseline)
5208}
5209
5210#[cfg(unix)]
5211fn load_v2_baseline_in(
5212    cfg: &HubConfig,
5213    brain: &str,
5214    directory: &TrustDirectory,
5215    name_string: &str,
5216) -> LinkResult<Option<V2SyncBaseline>> {
5217    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5218    let name = c_name(name_string.as_bytes(), name_string)?;
5219    let fd = unsafe {
5220        libc::openat(
5221            directory.as_raw_fd(),
5222            name.as_ptr(),
5223            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5224        )
5225    };
5226    if fd < 0 {
5227        let error = std::io::Error::last_os_error();
5228        return if error.kind() == std::io::ErrorKind::NotFound {
5229            Ok(None)
5230        } else {
5231            Err(LinkError::UnsafePath {
5232                path: name_string.to_string(),
5233            })
5234        };
5235    }
5236    let file = unsafe { std::fs::File::from_raw_fd(fd) };
5237    let mut bytes = Vec::new();
5238    file.take(MAX_V2_BASELINE_BYTES + 1)
5239        .read_to_end(&mut bytes)?;
5240    if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5241        return Err(invalid_feed("v2 sync baseline is oversized"));
5242    }
5243    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5244}
5245
5246#[cfg(unix)]
5247fn load_v2_baseline(
5248    cfg: &HubConfig,
5249    brain: &str,
5250    checkout: &Path,
5251) -> LinkResult<Option<V2SyncBaseline>> {
5252    let directory = open_trust_dir(cfg)?;
5253    let name = v2_baseline_name(cfg, brain, checkout)?;
5254    let _lock = lock_trust_name(&directory, &name)?;
5255    load_v2_baseline_in(cfg, brain, &directory, &name)
5256}
5257
5258#[cfg(windows)]
5259fn load_v2_baseline_in(
5260    cfg: &HubConfig,
5261    brain: &str,
5262    directory: &TrustDirectory,
5263    name: &str,
5264) -> LinkResult<Option<V2SyncBaseline>> {
5265    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
5266    match reader.read(Path::new(&name), MAX_V2_BASELINE_BYTES) {
5267        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5268        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5269        Err(_) => Err(LinkError::UnsafePath {
5270            path: name.to_string(),
5271        }),
5272    }
5273}
5274
5275#[cfg(windows)]
5276fn load_v2_baseline(
5277    cfg: &HubConfig,
5278    brain: &str,
5279    checkout: &Path,
5280) -> LinkResult<Option<V2SyncBaseline>> {
5281    let directory = open_trust_dir(cfg)?;
5282    let name = v2_baseline_name(cfg, brain, checkout)?;
5283    let _lock = lock_trust_name(&directory, &name)?;
5284    load_v2_baseline_in(cfg, brain, &directory, &name)
5285}
5286
5287#[cfg(not(any(unix, windows)))]
5288fn load_v2_baseline(
5289    _cfg: &HubConfig,
5290    _brain: &str,
5291    _checkout: &Path,
5292) -> LinkResult<Option<V2SyncBaseline>> {
5293    Err(LinkError::UnsupportedPlatform {
5294        operation: "verified link.md v2 baseline",
5295    })
5296}
5297
5298#[cfg(unix)]
5299fn save_v2_baseline(
5300    cfg: &HubConfig,
5301    brain: &str,
5302    checkout: &Path,
5303    baseline: &V2SyncBaseline,
5304) -> LinkResult<()> {
5305    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5306    let directory = open_trust_dir(cfg)?;
5307    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5308    let _lock = lock_trust_name(&directory, &name_string)?;
5309    let name = c_name(name_string.as_bytes(), &name_string)?;
5310    let mut bytes = serde_json::to_vec(baseline)
5311        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5312    bytes.push(b'\n');
5313    if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5314        return Err(invalid_feed("v2 sync baseline is oversized"));
5315    }
5316    let temp_string = format!(
5317        ".{name_string}.tmp.{}-{}",
5318        std::process::id(),
5319        std::time::SystemTime::now()
5320            .duration_since(std::time::UNIX_EPOCH)
5321            .unwrap_or_default()
5322            .as_nanos()
5323    );
5324    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5325    let fd = unsafe {
5326        libc::openat(
5327            directory.as_raw_fd(),
5328            temp.as_ptr(),
5329            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5330            0o600,
5331        )
5332    };
5333    if fd < 0 {
5334        return Err(std::io::Error::last_os_error().into());
5335    }
5336    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5337    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5338        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5339        return Err(error.into());
5340    }
5341    drop(file);
5342    if unsafe {
5343        libc::renameat(
5344            directory.as_raw_fd(),
5345            temp.as_ptr(),
5346            directory.as_raw_fd(),
5347            name.as_ptr(),
5348        )
5349    } != 0
5350    {
5351        let error = std::io::Error::last_os_error();
5352        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5353        return Err(error.into());
5354    }
5355    directory.sync_all()?;
5356    Ok(())
5357}
5358
5359#[cfg(windows)]
5360fn save_v2_baseline(
5361    cfg: &HubConfig,
5362    brain: &str,
5363    checkout: &Path,
5364    baseline: &V2SyncBaseline,
5365) -> LinkResult<()> {
5366    let directory = open_trust_dir(cfg)?;
5367    let name = v2_baseline_name(cfg, brain, checkout)?;
5368    let _lock = lock_trust_name(&directory, &name)?;
5369    let mut bytes = serde_json::to_vec(baseline)
5370        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5371    bytes.push(b'\n');
5372    if bytes.len() as u64 > MAX_V2_BASELINE_BYTES {
5373        return Err(invalid_feed("v2 sync baseline is oversized"));
5374    }
5375    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5376    Ok(())
5377}
5378
5379#[cfg(not(any(unix, windows)))]
5380fn save_v2_baseline(
5381    _cfg: &HubConfig,
5382    _brain: &str,
5383    _checkout: &Path,
5384    _baseline: &V2SyncBaseline,
5385) -> LinkResult<()> {
5386    Err(LinkError::UnsupportedPlatform {
5387        operation: "verified link.md v2 baseline",
5388    })
5389}
5390
5391fn v2_baseline_from_head(
5392    cfg: &HubConfig,
5393    head: &V2VerifiedHead,
5394    files: std::collections::BTreeMap<String, V2BaselineFile>,
5395    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5396    local: Option<&V2LocalView>,
5397    checkout_id: Option<&str>,
5398) -> LinkResult<V2SyncBaseline> {
5399    let mut local_eligibility = local
5400        .map(|view| view.eligibility.clone())
5401        .unwrap_or_default();
5402    if let Some(view) = local {
5403        for path in files.keys() {
5404            local_eligibility
5405                .entry(path.clone())
5406                .or_insert_with(|| !view.policy.keeps_home(path));
5407        }
5408    }
5409    let remote_copy_remains = local_eligibility
5410        .iter()
5411        .filter(|(_, riding)| !**riding)
5412        .filter_map(|(path, _)| {
5413            files
5414                .get(path)
5415                .map(|file| (path.clone(), file.sha256.clone()))
5416        })
5417        .collect();
5418    // The accepted remote tree is the new comparison base even if a local
5419    // writer changed a few files while the commit was in flight. Preserve
5420    // acceleration only for exact local/remote matches; divergent paths are
5421    // intentionally absent and take the full hash path next run.
5422    let scan_cache = local
5423        .map(|view| {
5424            view.scan_cache
5425                .iter()
5426                .filter(|(path, cached)| {
5427                    files.get(*path).is_some_and(|remote| {
5428                        remote.sha256 == cached.sha256 && remote.bytes == cached.bytes
5429                    })
5430                })
5431                .map(|(path, cached)| (path.clone(), cached.clone()))
5432                .collect()
5433        })
5434        .unwrap_or_default();
5435    Ok(V2SyncBaseline {
5436        v: 2,
5437        origin: normalized_origin(&cfg.hub)?,
5438        brain: head.brain_id.clone(),
5439        checkout_id: Some(v2_checkout_id(checkout_id)?),
5440        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5441        commit_hash: head
5442            .pointer
5443            .as_ref()
5444            .map(|pointer| pointer.commit_hash.clone()),
5445        content_root: head
5446            .pointer
5447            .as_ref()
5448            .and_then(|pointer| pointer.content_root.clone()),
5449        asset_root: head
5450            .pointer
5451            .as_ref()
5452            .and_then(|pointer| pointer.asset_root.clone()),
5453        assets,
5454        view_kind: Some(head.view_kind.clone()),
5455        view_revision: Some(head.view_revision.clone()),
5456        control_revision: Some(head.control_revision.clone()),
5457        projection_sha256: (head.view_kind == "scoped")
5458            .then(|| scoped_projection_sha256(&head.brain_id)),
5459        files,
5460        scan_cache,
5461        local_policy_digest: local.map(|view| view.policy.digest.clone()),
5462        local_eligibility,
5463        remote_copy_remains,
5464    })
5465}
5466
5467#[cfg(unix)]
5468fn v2_scan_fingerprint_at(metadata: &std::fs::Metadata, now_ns: i128) -> Option<String> {
5469    use std::os::unix::fs::MetadataExt as _;
5470
5471    // Git's "racy clean" lesson applies here: a same-size edit inside a
5472    // filesystem timestamp tick can otherwise preserve the whole stat tuple.
5473    // Do not cache a file until its mtime and ctime are at least two seconds
5474    // behind the observer. Future/negative timestamps also stay on the full
5475    // hash path. ctime cannot be restored by an unprivileged writer, so after
5476    // that window an in-place edit, chmod, or atomic replacement invalidates
5477    // the tuple even when a tool deliberately restores mtime.
5478    let mtime_ns = i128::from(metadata.mtime()) * 1_000_000_000 + i128::from(metadata.mtime_nsec());
5479    let ctime_ns = i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
5480    let newest = mtime_ns.max(ctime_ns);
5481    if newest < 0 || now_ns.checked_sub(newest)? < 2_000_000_000 {
5482        return None;
5483    }
5484    Some(format!(
5485        "unix-v1:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}",
5486        metadata.dev(),
5487        metadata.ino(),
5488        metadata.len(),
5489        metadata.mtime(),
5490        metadata.mtime_nsec(),
5491        metadata.ctime(),
5492        metadata.ctime_nsec(),
5493    ))
5494}
5495
5496#[cfg(unix)]
5497fn v2_scan_fingerprint(metadata: &std::fs::Metadata) -> Option<String> {
5498    let now = SystemTime::now().duration_since(UNIX_EPOCH).ok()?;
5499    let now_ns = i128::from(now.as_secs()) * 1_000_000_000 + i128::from(now.subsec_nanos());
5500    v2_scan_fingerprint_at(metadata, now_ns)
5501}
5502
5503#[cfg(not(unix))]
5504fn v2_scan_fingerprint(_metadata: &std::fs::Metadata) -> Option<String> {
5505    // Full hashing remains the honest portable fallback until another
5506    // platform has an equally strong file-identity + change-time tuple.
5507    None
5508}
5509
5510fn v2_local_files_cached(
5511    store: &Store,
5512    prior_cache: Option<(&str, &std::collections::BTreeMap<String, V2ScanCacheFile>)>,
5513) -> LinkResult<V2LocalView> {
5514    let policy = crate::linkmd_sync_policy::load(store)
5515        .map_err(|message| LinkError::InvalidPack { message })?;
5516    let prior_cache = prior_cache
5517        .filter(|(digest, _)| *digest == policy.digest.as_str())
5518        .map(|(_, cache)| cache);
5519    let non_markdown_asset_paths = crate::assets::read_manifest(store)
5520        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5521        .into_iter()
5522        .filter(|asset| !is_markdown_asset_path(&asset.path))
5523        .map(|asset| asset.path)
5524        .collect::<std::collections::BTreeSet<_>>();
5525    let mut result = std::collections::BTreeMap::new();
5526    let mut scan_cache = std::collections::BTreeMap::new();
5527    let mut eligibility = std::collections::BTreeMap::new();
5528    let mut withheld_links = Vec::<V2WithheldLink>::new();
5529    let mut total = 0_u64;
5530    let mut paths = vec![PathBuf::from("DB.md")];
5531    paths.extend(store.walk()?);
5532    for relative in paths {
5533        let path = relative.to_string_lossy().replace('\\', "/");
5534        // v2 catalogs and asset inventory are materialized/signed separately.
5535        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5536            continue;
5537        }
5538        if non_markdown_asset_paths.contains(&path) {
5539            continue;
5540        }
5541        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5542            path: error.to_string(),
5543        })?;
5544        let riding = !policy.keeps_home(&path);
5545        eligibility.insert(path.clone(), riding);
5546        if !riding {
5547            continue;
5548        }
5549        let remaining = MAX_STORE_BYTES.saturating_sub(total);
5550        let mut file = store.open_regular(&relative)?;
5551        let before = file.metadata()?;
5552        if before.len() > remaining {
5553            return Err(LinkError::PushTooLarge {
5554                detail: format!("more than {MAX_STORE_BYTES} uncompressed bytes"),
5555            });
5556        }
5557        let fingerprint = v2_scan_fingerprint(&before);
5558        if let (Some(fingerprint), Some(cached)) = (
5559            fingerprint.as_deref(),
5560            prior_cache.and_then(|cache| cache.get(&path)),
5561        ) {
5562            if cached.fingerprint == fingerprint && cached.bytes == before.len() {
5563                total = total
5564                    .checked_add(cached.bytes)
5565                    .ok_or_else(|| LinkError::PushTooLarge {
5566                        detail: "v2 local byte count overflow".to_string(),
5567                    })?;
5568                result.insert(path.clone(), (cached.sha256.clone(), cached.bytes));
5569                for target in &cached.withheld_targets {
5570                    withheld_links.push(V2WithheldLink {
5571                        source: path.clone(),
5572                        target: target.clone(),
5573                    });
5574                }
5575                scan_cache.insert(path, cached.clone());
5576                continue;
5577            }
5578        }
5579        let mut bytes = Vec::with_capacity(before.len().min(8 * 1024 * 1024) as usize);
5580        Read::by_ref(&mut file)
5581            .take(remaining.saturating_add(1))
5582            .read_to_end(&mut bytes)?;
5583        if bytes.len() as u64 > remaining {
5584            return Err(LinkError::PushTooLarge {
5585                detail: format!("more than {MAX_STORE_BYTES} uncompressed bytes"),
5586            });
5587        }
5588        total = total
5589            .checked_add(bytes.len() as u64)
5590            .ok_or_else(|| LinkError::PushTooLarge {
5591                detail: "v2 local byte count overflow".to_string(),
5592            })?;
5593        if total > MAX_STORE_BYTES {
5594            return Err(LinkError::PushTooLarge {
5595                detail: format!("{total} uncompressed bytes"),
5596            });
5597        }
5598        if std::str::from_utf8(&bytes).is_err() {
5599            return Err(LinkError::NotUtf8 { path });
5600        }
5601        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5602        // A kept-home target need not be present on THIS machine. The policy
5603        // is the owner's durable statement about where a path lives. Cache
5604        // only the resulting withheld targets (rather than every wiki-link),
5605        // and bind reuse to the exact policy digest above.
5606        let targets = crate::store::extract_edge_targets(text)
5607            .into_iter()
5608            .map(|target| format!("{target}.md"))
5609            .filter(|target| policy.keeps_home(target))
5610            .collect::<Vec<_>>();
5611        for target in &targets {
5612            withheld_links.push(V2WithheldLink {
5613                source: path.clone(),
5614                target: target.clone(),
5615            });
5616        }
5617        let sha256 = content_sha256(&bytes);
5618        result.insert(path.clone(), (sha256.clone(), bytes.len() as u64));
5619        let after = file.metadata()?;
5620        if before.len() == bytes.len() as u64
5621            && v2_scan_fingerprint(&before) == v2_scan_fingerprint(&after)
5622        {
5623            if let Some(fingerprint) = v2_scan_fingerprint(&after) {
5624                scan_cache.insert(
5625                    path,
5626                    V2ScanCacheFile {
5627                        fingerprint,
5628                        sha256,
5629                        bytes: bytes.len() as u64,
5630                        withheld_targets: targets,
5631                    },
5632                );
5633            }
5634        }
5635    }
5636    withheld_links.sort();
5637    withheld_links.dedup();
5638    Ok(V2LocalView {
5639        riding: result,
5640        scan_cache,
5641        eligibility,
5642        policy,
5643        withheld_links,
5644    })
5645}
5646
5647fn is_markdown_asset_path(path: &str) -> bool {
5648    path.to_ascii_lowercase().ends_with(".md")
5649}
5650
5651fn v2_content_put_operation_kind(
5652    path: &str,
5653    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
5654) -> &'static str {
5655    if local_assets.contains_key(path) && is_markdown_asset_path(path) {
5656        "put_asset_content"
5657    } else {
5658        "put"
5659    }
5660}
5661
5662fn v2_withdrawal_includes_content(
5663    path: &str,
5664    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
5665) -> bool {
5666    local_assets
5667        .get(path)
5668        .is_none_or(|asset| is_markdown_asset_path(&asset.path))
5669}
5670
5671fn verify_v2_markdown_asset_content_bindings(
5672    content: &std::collections::BTreeMap<String, V2BaselineFile>,
5673    assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
5674) -> LinkResult<()> {
5675    for (path, asset) in assets {
5676        if !is_markdown_asset_path(path) {
5677            continue;
5678        }
5679        let content_file = content.get(path);
5680        let exact = content_file
5681            .is_some_and(|file| file.sha256 == asset.blob_sha256 && file.bytes == asset.bytes);
5682        if (asset.disposition == "hosted" && !exact)
5683            || (asset.disposition == "withheld" && content_file.is_some())
5684        {
5685            return Err(invalid_feed(format!(
5686                "markdown asset `{path}` is not exactly bound across the signed content and asset views"
5687            )));
5688        }
5689    }
5690    Ok(())
5691}
5692
5693fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5694    v2_local_files_cached(store, None)
5695}
5696
5697#[derive(Debug, Clone, Deserialize)]
5698struct V2DownloadItem {
5699    path: String,
5700    sha256: String,
5701    bytes: u64,
5702    url: String,
5703    method: String,
5704}
5705
5706#[derive(Debug, Deserialize)]
5707struct V2DownloadWindow {
5708    v: u8,
5709    commit: String,
5710    downloads: Vec<V2DownloadItem>,
5711}
5712
5713#[derive(Debug, Deserialize)]
5714struct V2BulkStreamHeader {
5715    v: u8,
5716    path: String,
5717    sha256: String,
5718    bytes: u64,
5719}
5720
5721fn parse_v2_bulk_stream(
5722    bytes: &[u8],
5723    expected: &[(&String, &V2BaselineFile)],
5724) -> LinkResult<Vec<(String, Vec<u8>)>> {
5725    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5726        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5727    }
5728    let mut cursor = V2_BULK_STREAM_MAGIC.len();
5729    let mut result = Vec::with_capacity(expected.len());
5730    for (expected_path, expected_file) in expected {
5731        let length_bytes = bytes
5732            .get(cursor..cursor + 4)
5733            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5734        cursor += 4;
5735        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5736        if header_len == 0 || header_len > 4 * 1024 {
5737            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5738        }
5739        let header_bytes = bytes
5740            .get(cursor..cursor + header_len)
5741            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5742        cursor += header_len;
5743        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5744            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5745        if header.v != 2
5746            || &header.path != *expected_path
5747            || header.sha256 != expected_file.sha256
5748            || header.bytes != expected_file.bytes
5749            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5750        {
5751            return Err(invalid_feed(
5752                "v2 bulk stream frame differs from its proven manifest entry",
5753            ));
5754        }
5755        let body_len = usize::try_from(header.bytes)
5756            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5757        let body = bytes
5758            .get(cursor..cursor + body_len)
5759            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5760        cursor += body_len;
5761        if content_sha256(body) != header.sha256 {
5762            return Err(invalid_feed(
5763                "v2 bulk stream file differs from its proven manifest entry",
5764            ));
5765        }
5766        result.push((header.path, body.to_vec()));
5767    }
5768    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5769        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5770    }
5771    cursor += 4;
5772    if cursor != bytes.len() {
5773        return Err(invalid_feed("v2 bulk stream carries trailing data"));
5774    }
5775    Ok(result)
5776}
5777
5778fn download_v2_bulk_stream(
5779    cfg: &HubConfig,
5780    brain: &str,
5781    pointer: &V2PointerBody,
5782    pending: &[(&String, &V2BaselineFile)],
5783) -> LinkResult<Vec<(String, Vec<u8>)>> {
5784    let claims = pending
5785        .iter()
5786        .map(|(path, file)| {
5787            Ok(json!({
5788                "path": path,
5789                "sha256": file.sha256,
5790                "bytes": file.bytes,
5791                "proof": file.proof.as_ref().ok_or_else(|| {
5792                    invalid_feed("v2 manifest omitted a bulk-stream proof")
5793                })?,
5794            }))
5795        })
5796        .collect::<LinkResult<Vec<_>>>()?;
5797    let raw = request_raw_retryable_read(
5798        cfg,
5799        "POST",
5800        &format!("/api/hub/brains/{brain}/v2/stream"),
5801        Some(&json!({
5802            "commit": pointer.commit_hash,
5803            "files": claims,
5804        })),
5805        Auth::Required,
5806        V2_BULK_STREAM_RESPONSE_BYTES,
5807    )?;
5808    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5809    parse_v2_bulk_stream(&body, pending)
5810}
5811
5812fn request_capped_retryable_read(
5813    cfg: &HubConfig,
5814    method: &str,
5815    path: &str,
5816    body: Option<&Value>,
5817    auth: Auth,
5818    max_response_bytes: u64,
5819) -> LinkResult<HubResponse> {
5820    let raw = request_raw_retryable_read(cfg, method, path, body, auth, max_response_bytes)?;
5821    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
5822    Ok(HubResponse {
5823        status: raw.status,
5824        body: parsed,
5825    })
5826}
5827
5828fn prepare_v2_downloads(
5829    cfg: &HubConfig,
5830    brain: &str,
5831    pointer: &V2PointerBody,
5832    pending: &[(&String, &V2BaselineFile)],
5833) -> LinkResult<Vec<V2DownloadItem>> {
5834    let mut result = Vec::with_capacity(pending.len());
5835    for chunk in pending.chunks(128) {
5836        let claims = chunk
5837            .iter()
5838            .map(|(path, file)| {
5839                Ok(json!({
5840                    "path": path,
5841                    "sha256": file.sha256,
5842                    "bytes": file.bytes,
5843                    "proof": file.proof.as_ref().ok_or_else(|| {
5844                        invalid_feed("v2 manifest omitted a download proof")
5845                    })?,
5846                }))
5847            })
5848            .collect::<LinkResult<Vec<_>>>()?;
5849        let value = ensure_ok(
5850            request_capped_retryable_read(
5851                cfg,
5852                "POST",
5853                &format!("/api/hub/brains/{brain}/v2/downloads"),
5854                Some(&json!({
5855                    "commit": pointer.commit_hash,
5856                    "files": claims,
5857                })),
5858                Auth::Required,
5859                MAX_FEED_RESPONSE_BYTES,
5860            )?,
5861            "prepare v2 blob downloads",
5862        )?;
5863        let window: V2DownloadWindow = serde_json::from_value(value)
5864            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5865        if window.v != 2
5866            || window.commit != pointer.commit_hash
5867            || window.downloads.len() != chunk.len()
5868        {
5869            return Err(invalid_feed(
5870                "v2 download window is not bound to the requested files",
5871            ));
5872        }
5873        let mut by_path = window
5874            .downloads
5875            .into_iter()
5876            .map(|item| (item.path.clone(), item))
5877            .collect::<std::collections::BTreeMap<_, _>>();
5878        if by_path.len() != chunk.len() {
5879            return Err(invalid_feed("v2 download window repeats a path"));
5880        }
5881        for (path, file) in chunk {
5882            let item = by_path
5883                .remove(*path)
5884                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5885            if item.method != "GET"
5886                || item.sha256 != file.sha256
5887                || item.bytes != file.bytes
5888                || item.url.is_empty()
5889            {
5890                return Err(invalid_feed(
5891                    "v2 download capability differs from its proven file",
5892                ));
5893            }
5894            result.push(item);
5895        }
5896    }
5897    Ok(result)
5898}
5899
5900fn prepare_v2_asset_downloads(
5901    cfg: &HubConfig,
5902    brain: &str,
5903    pointer: &V2PointerBody,
5904    pending: &[(&String, &V2BaselineAsset)],
5905) -> LinkResult<Vec<V2DownloadItem>> {
5906    let mut result = Vec::with_capacity(pending.len());
5907    for chunk in pending.chunks(128) {
5908        let claims = chunk
5909            .iter()
5910            .map(|(path, asset)| {
5911                json!({
5912                    "path": path,
5913                    "sha256": asset.blob_sha256,
5914                    "bytes": asset.bytes,
5915                    "leaf_hash": asset.leaf_hash,
5916                })
5917            })
5918            .collect::<Vec<_>>();
5919        let value = ensure_ok(
5920            request_capped_retryable_read(
5921                cfg,
5922                "POST",
5923                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5924                Some(&json!({
5925                    "commit": pointer.commit_hash,
5926                    "assets": claims,
5927                })),
5928                Auth::Required,
5929                MAX_FEED_RESPONSE_BYTES,
5930            )?,
5931            "prepare v2 asset downloads",
5932        )?;
5933        let window: V2DownloadWindow = serde_json::from_value(value)
5934            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5935        if window.v != 2
5936            || window.commit != pointer.commit_hash
5937            || window.downloads.len() != chunk.len()
5938        {
5939            return Err(invalid_feed(
5940                "v2 asset download window is not bound to the requested assets",
5941            ));
5942        }
5943        let mut by_path = window
5944            .downloads
5945            .into_iter()
5946            .map(|item| (item.path.clone(), item))
5947            .collect::<std::collections::BTreeMap<_, _>>();
5948        if by_path.len() != chunk.len() {
5949            return Err(invalid_feed("v2 asset download window repeats a path"));
5950        }
5951        for (path, asset) in chunk {
5952            let item = by_path
5953                .remove(*path)
5954                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5955            if item.method != "GET"
5956                || item.sha256 != asset.blob_sha256
5957                || item.bytes != asset.bytes
5958                || item.url.is_empty()
5959            {
5960                return Err(invalid_feed(
5961                    "v2 asset download capability differs from its signed leaf",
5962                ));
5963            }
5964            result.push(item);
5965        }
5966    }
5967    Ok(result)
5968}
5969
5970#[cfg(any(unix, windows))]
5971fn stage_v2_asset_download_window(
5972    cfg: &HubConfig,
5973    brain: &str,
5974    pointer: &V2PointerBody,
5975    cache_dir: &Path,
5976    pending: &[(&String, &V2BaselineAsset)],
5977) -> LinkResult<Vec<V2StagedFile>> {
5978    if pending.is_empty() {
5979        return Ok(Vec::new());
5980    }
5981    if pending.len() > V2_DOWNLOAD_CAPABILITY_FILES {
5982        return Err(invalid_feed("v2 asset capability window is oversized"));
5983    }
5984
5985    let mut last_error = None;
5986    for retry_delay in V2_DOWNLOAD_CAPABILITY_BACKOFF_MS
5987        .iter()
5988        .copied()
5989        .map(Some)
5990        .chain(std::iter::once(None))
5991    {
5992        // This POST rechecks the exact head and current path authority before
5993        // issuing fresh, immutable GET capabilities. Successful prior workers
5994        // are already durable in the content-addressed cache, so retrying the
5995        // complete small window neither redownloads them nor widens access.
5996        let downloads = prepare_v2_asset_downloads(cfg, brain, pointer, pending)?;
5997        let mut unique = std::collections::BTreeMap::<String, V2DownloadItem>::new();
5998        for item in downloads {
5999            match unique.get(&item.sha256) {
6000                Some(prior) if prior.bytes != item.bytes => {
6001                    return Err(invalid_feed(
6002                        "one v2 asset hash has conflicting byte lengths",
6003                    ));
6004                }
6005                Some(_) => {}
6006                None => {
6007                    unique.insert(item.sha256.clone(), item);
6008                }
6009            }
6010        }
6011        let downloads = unique.into_values().collect::<Vec<_>>();
6012        let next = std::sync::atomic::AtomicUsize::new(0);
6013        let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6014        let mut results = std::iter::repeat_with(|| None)
6015            .take(downloads.len())
6016            .collect::<Vec<Option<LinkResult<PathBuf>>>>();
6017        std::thread::scope(|scope| {
6018            let (sender, receiver) = std::sync::mpsc::channel();
6019            for _ in 0..worker_count {
6020                let sender = sender.clone();
6021                let downloads = &downloads;
6022                let next = &next;
6023                scope.spawn(move || loop {
6024                    let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6025                    let Some(item) = downloads.get(index) else {
6026                        break;
6027                    };
6028                    let result = download_presigned_to_cache(
6029                        cfg,
6030                        &item.url,
6031                        cache_dir,
6032                        &item.sha256,
6033                        item.bytes,
6034                    );
6035                    if sender.send((index, result)).is_err() {
6036                        break;
6037                    }
6038                });
6039            }
6040            drop(sender);
6041            for (index, result) in receiver {
6042                results[index] = Some(result);
6043            }
6044        });
6045
6046        let mut failed = None;
6047        for result in results {
6048            match result {
6049                Some(Ok(_)) => {}
6050                Some(Err(error)) if failed.is_none() => failed = Some(error),
6051                Some(Err(_)) => {}
6052                None if failed.is_none() => {
6053                    failed = Some(LinkError::Transport {
6054                        hub: cfg.hub.clone(),
6055                        message: "a bounded v2 asset worker stopped before reporting its result"
6056                            .to_string(),
6057                    });
6058                }
6059                None => {}
6060            }
6061        }
6062        if let Some(error) = failed {
6063            last_error = Some(error);
6064            if let Some(milliseconds) = retry_delay {
6065                std::thread::sleep(std::time::Duration::from_millis(milliseconds));
6066                continue;
6067            }
6068            break;
6069        }
6070
6071        return pending
6072            .iter()
6073            .map(|(path, asset)| {
6074                let source = cache_dir.join(&asset.blob_sha256);
6075                if !cached_blob_is_exact(&source, &asset.blob_sha256, asset.bytes)? {
6076                    return Err(invalid_feed(
6077                        "v2 asset download cache omitted a proven blob",
6078                    ));
6079                }
6080                Ok(V2StagedFile {
6081                    path: (*path).clone(),
6082                    source,
6083                    sha256: asset.blob_sha256.clone(),
6084                    bytes: asset.bytes,
6085                })
6086            })
6087            .collect();
6088    }
6089    Err(last_error
6090        .unwrap_or_else(|| invalid_feed("v2 asset capability window made no download attempt")))
6091}
6092
6093fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
6094    let bytes = get_presigned(cfg, &item.url)?;
6095    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
6096        return Err(invalid_feed("v2 blob differs from its proven path entry"));
6097    }
6098    Ok(bytes)
6099}
6100
6101#[derive(Debug, Clone)]
6102struct V2StagedFile {
6103    path: String,
6104    source: PathBuf,
6105    sha256: String,
6106    bytes: u64,
6107}
6108
6109#[cfg(unix)]
6110fn v2_download_cache_dir(
6111    cfg: &HubConfig,
6112    brain: &str,
6113    pointer: &V2PointerBody,
6114) -> LinkResult<PathBuf> {
6115    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
6116}
6117
6118#[cfg(unix)]
6119fn v2_download_cache_dir_for(
6120    cfg: &HubConfig,
6121    brain: &str,
6122    transaction: &str,
6123) -> LinkResult<PathBuf> {
6124    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6125        return Err(invalid_feed("v2 download cache address is invalid"));
6126    }
6127    let path = cfg
6128        .state_dir
6129        .join("downloads")
6130        .join(brain)
6131        .join(transaction);
6132    let directory = open_or_create_dir_nofollow(&path)?;
6133    use std::os::fd::AsRawFd as _;
6134    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
6135        return Err(std::io::Error::last_os_error().into());
6136    }
6137    directory.sync_all()?;
6138    Ok(path)
6139}
6140
6141#[cfg(windows)]
6142fn v2_download_cache_dir(
6143    cfg: &HubConfig,
6144    brain: &str,
6145    pointer: &V2PointerBody,
6146) -> LinkResult<PathBuf> {
6147    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
6148}
6149
6150#[cfg(windows)]
6151fn v2_download_cache_dir_for(
6152    cfg: &HubConfig,
6153    brain: &str,
6154    transaction: &str,
6155) -> LinkResult<PathBuf> {
6156    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6157        return Err(invalid_feed("v2 download cache address is invalid"));
6158    }
6159    let path = cfg
6160        .state_dir
6161        .join("downloads")
6162        .join(brain)
6163        .join(transaction);
6164    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
6165    crate::fsx::open_directory_nofollow(&path)?;
6166    Ok(path)
6167}
6168
6169#[cfg(unix)]
6170fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
6171    use std::os::fd::AsRawFd as _;
6172    let parent = cfg.state_dir.join("downloads").join(brain);
6173    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
6174        return;
6175    };
6176    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
6177        return;
6178    };
6179    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
6180    let _ = directory.sync_all();
6181}
6182
6183#[cfg(windows)]
6184fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
6185    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
6186        return;
6187    }
6188    let parent = cfg.state_dir.join("downloads").join(brain);
6189    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
6190        return;
6191    };
6192    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
6193}
6194
6195#[cfg(not(any(unix, windows)))]
6196fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
6197
6198#[cfg(not(any(unix, windows)))]
6199fn v2_download_cache_dir_for(
6200    _cfg: &HubConfig,
6201    _brain: &str,
6202    _transaction: &str,
6203) -> LinkResult<PathBuf> {
6204    Err(LinkError::UnsupportedPlatform {
6205        operation: "resumable v2 download staging",
6206    })
6207}
6208
6209#[cfg(any(unix, windows))]
6210fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
6211    let file = match crate::fsx::open_regular_nofollow(path) {
6212        Ok(file) => file,
6213        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
6214        Err(error) => return Err(error.into()),
6215    };
6216    if file.metadata()?.len() != bytes {
6217        return Ok(false);
6218    }
6219    Ok(content_sha256_reader(file)? == sha256)
6220}
6221
6222#[cfg(any(unix, windows))]
6223fn cache_v2_blob_bytes(
6224    cache_dir: &Path,
6225    sha256: &str,
6226    expected_bytes: u64,
6227    bytes: &[u8],
6228) -> LinkResult<PathBuf> {
6229    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
6230        return Err(invalid_feed("v2 cached blob differs from its declaration"));
6231    }
6232    let path = cache_dir.join(sha256);
6233    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
6234        crate::fsx::write_atomic(&path, bytes)?;
6235    }
6236    Ok(path)
6237}
6238
6239#[cfg(not(any(unix, windows)))]
6240fn cache_v2_blob_bytes(
6241    _cache_dir: &Path,
6242    _sha256: &str,
6243    _expected_bytes: u64,
6244    _bytes: &[u8],
6245) -> LinkResult<PathBuf> {
6246    Err(LinkError::UnsupportedPlatform {
6247        operation: "resumable v2 download staging",
6248    })
6249}
6250
6251#[cfg(unix)]
6252fn download_presigned_to_cache(
6253    cfg: &HubConfig,
6254    url: &str,
6255    cache_dir: &Path,
6256    sha256: &str,
6257    expected_bytes: u64,
6258) -> LinkResult<PathBuf> {
6259    use std::os::fd::{AsRawFd as _, FromRawFd as _};
6260
6261    let target = cache_dir.join(sha256);
6262    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6263        return Ok(target);
6264    }
6265    let directory = open_existing_dir_nofollow(cache_dir)?;
6266    let mut nonce = [0_u8; 16];
6267    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
6268        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
6269    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
6270    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
6271    let fd = unsafe {
6272        libc::openat(
6273            directory.as_raw_fd(),
6274            temp.as_ptr(),
6275            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
6276            0o600,
6277        )
6278    };
6279    if fd < 0 {
6280        return Err(std::io::Error::last_os_error().into());
6281    }
6282    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
6283    let response = match presigned_agent(cfg, url)?.get(url).call() {
6284        Ok(response) => response,
6285        Err(ureq::Error::Status(_, response)) => {
6286            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6287            return Err(LinkError::Http {
6288                what: "v2 direct download",
6289                status: response.status(),
6290                message: "object store rejected the download".to_string(),
6291                code: None,
6292                details: None,
6293            });
6294        }
6295        Err(ureq::Error::Transport(error)) => {
6296            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6297            return Err(LinkError::Transport {
6298                hub: cfg.hub.clone(),
6299                message: error.to_string(),
6300            });
6301        }
6302    };
6303    let mut reader = response
6304        .into_reader()
6305        .take(expected_bytes.saturating_add(1));
6306    let mut digest = Sha256::new();
6307    let mut total = 0_u64;
6308    let mut buffer = [0_u8; 64 * 1024];
6309    // Reading the socket and writing the file are different failures wearing
6310    // the same io::Error: a stalled download is the peer's problem and a full
6311    // disk is this machine's, and an operator who is told only "timed out"
6312    // cannot tell which one to act on.
6313    let write_result = (|| -> LinkResult<()> {
6314        loop {
6315            let read = reader
6316                .read(&mut buffer)
6317                .map_err(|error| LinkError::Transport {
6318                    hub: cfg.hub.clone(),
6319                    message: error.to_string(),
6320                })?;
6321            if read == 0 {
6322                break;
6323            }
6324            total = total.saturating_add(read as u64);
6325            digest.update(&buffer[..read]);
6326            output.write_all(&buffer[..read])?;
6327        }
6328        output.sync_all().map_err(LinkError::from)
6329    })();
6330    if let Err(error) = write_result {
6331        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6332        return Err(error);
6333    }
6334    drop(output);
6335    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6336        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6337        return Err(invalid_feed(
6338            "v2 direct download failed integrity verification",
6339        ));
6340    }
6341    let target_name = c_name(sha256.as_bytes(), sha256)?;
6342    // Replace only a previously inspected cache leaf. Cache bytes are private
6343    // and content-addressed; final install re-verifies them again.
6344    if unsafe {
6345        libc::renameat(
6346            directory.as_raw_fd(),
6347            temp.as_ptr(),
6348            directory.as_raw_fd(),
6349            target_name.as_ptr(),
6350        )
6351    } != 0
6352    {
6353        let error = std::io::Error::last_os_error();
6354        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
6355        return Err(error.into());
6356    }
6357    directory.sync_all()?;
6358    Ok(target)
6359}
6360
6361#[cfg(windows)]
6362fn download_presigned_to_cache(
6363    cfg: &HubConfig,
6364    url: &str,
6365    cache_dir: &Path,
6366    sha256: &str,
6367    expected_bytes: u64,
6368) -> LinkResult<PathBuf> {
6369    use std::fs::OpenOptions;
6370
6371    let target = cache_dir.join(sha256);
6372    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
6373        return Ok(target);
6374    }
6375    // Keep the checked directory handle alive while the private cache path is
6376    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
6377    // an ancestor can be swapped after validation.
6378    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
6379    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
6380    let mut output = OpenOptions::new()
6381        .write(true)
6382        .create_new(true)
6383        .open(&temp)?;
6384    let response = match presigned_agent(cfg, url)?.get(url).call() {
6385        Ok(response) => response,
6386        Err(ureq::Error::Status(_, response)) => {
6387            let _ = std::fs::remove_file(&temp);
6388            return Err(LinkError::Http {
6389                what: "v2 direct download",
6390                status: response.status(),
6391                message: "object store rejected the download".to_string(),
6392                code: None,
6393                details: None,
6394            });
6395        }
6396        Err(ureq::Error::Transport(error)) => {
6397            let _ = std::fs::remove_file(&temp);
6398            return Err(LinkError::Transport {
6399                hub: cfg.hub.clone(),
6400                message: error.to_string(),
6401            });
6402        }
6403    };
6404    let mut reader = response
6405        .into_reader()
6406        .take(expected_bytes.saturating_add(1));
6407    let mut digest = Sha256::new();
6408    let mut total = 0_u64;
6409    let mut buffer = [0_u8; 64 * 1024];
6410    // Same split as the cache-fill path: a stalled socket is not a disk error.
6411    let copied = (|| -> LinkResult<()> {
6412        loop {
6413            let read = reader
6414                .read(&mut buffer)
6415                .map_err(|error| LinkError::Transport {
6416                    hub: cfg.hub.clone(),
6417                    message: error.to_string(),
6418                })?;
6419            if read == 0 {
6420                break;
6421            }
6422            total = total.saturating_add(read as u64);
6423            digest.update(&buffer[..read]);
6424            output.write_all(&buffer[..read])?;
6425        }
6426        output.sync_all()?;
6427        Ok(())
6428    })();
6429    if let Err(error) = copied {
6430        let _ = std::fs::remove_file(&temp);
6431        return Err(error);
6432    }
6433    drop(output);
6434    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
6435        let _ = std::fs::remove_file(&temp);
6436        return Err(invalid_feed(
6437            "v2 direct download failed integrity verification",
6438        ));
6439    }
6440    if target.exists() {
6441        std::fs::remove_file(&target)?;
6442    }
6443    if let Err(error) = std::fs::rename(&temp, &target) {
6444        let _ = std::fs::remove_file(&temp);
6445        return Err(error.into());
6446    }
6447    Ok(target)
6448}
6449
6450#[cfg(not(any(unix, windows)))]
6451fn download_presigned_to_cache(
6452    _cfg: &HubConfig,
6453    _url: &str,
6454    _cache_dir: &Path,
6455    _sha256: &str,
6456    _expected_bytes: u64,
6457) -> LinkResult<PathBuf> {
6458    Err(LinkError::UnsupportedPlatform {
6459        operation: "resumable v2 download staging",
6460    })
6461}
6462
6463fn download_v2_blobs(
6464    cfg: &HubConfig,
6465    brain: &str,
6466    pointer: &V2PointerBody,
6467    pending: Vec<(&String, &V2BaselineFile)>,
6468) -> LinkResult<Vec<(String, Vec<u8>)>> {
6469    if pending.is_empty() {
6470        return Ok(Vec::new());
6471    }
6472    let expected_order = pending
6473        .iter()
6474        .map(|(path, _)| (*path).clone())
6475        .collect::<Vec<_>>();
6476    let mut streamed = std::collections::BTreeMap::new();
6477    let mut direct = Vec::new();
6478    let mut window = Vec::new();
6479    let mut window_bytes = 0_u64;
6480    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6481                 window_bytes: &mut u64,
6482                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
6483     -> LinkResult<()> {
6484        if window.is_empty() {
6485            return Ok(());
6486        }
6487        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
6488            if streamed.insert(path, bytes).is_some() {
6489                return Err(invalid_feed("v2 bulk streams repeated a path"));
6490            }
6491        }
6492        window.clear();
6493        *window_bytes = 0;
6494        Ok(())
6495    };
6496    for &(path, file) in &pending {
6497        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6498            flush(&mut window, &mut window_bytes, &mut streamed)?;
6499            direct.push((path, file));
6500            continue;
6501        }
6502        if window.len() == V2_BULK_STREAM_FILES
6503            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6504        {
6505            flush(&mut window, &mut window_bytes, &mut streamed)?;
6506        }
6507        window.push((path, file));
6508        window_bytes += file.bytes;
6509    }
6510    flush(&mut window, &mut window_bytes, &mut streamed)?;
6511
6512    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
6513    let next = std::sync::atomic::AtomicUsize::new(0);
6514    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6515    let mut results = std::iter::repeat_with(|| None)
6516        .take(downloads.len())
6517        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
6518    std::thread::scope(|scope| {
6519        let (sender, receiver) = std::sync::mpsc::channel();
6520        for _ in 0..worker_count {
6521            let sender = sender.clone();
6522            let downloads = &downloads;
6523            let next = &next;
6524            scope.spawn(move || loop {
6525                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6526                let Some(item) = downloads.get(index) else {
6527                    break;
6528                };
6529                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
6530                if sender.send((index, result)).is_err() {
6531                    break;
6532                }
6533            });
6534        }
6535        drop(sender);
6536        for (index, result) in receiver {
6537            results[index] = Some(result);
6538        }
6539    });
6540    for result in results.into_iter().map(|result| {
6541        result.ok_or_else(|| LinkError::Transport {
6542            hub: cfg.hub.clone(),
6543            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
6544        })?
6545    }) {
6546        let (path, bytes) = result?;
6547        if streamed.insert(path, bytes).is_some() {
6548            return Err(invalid_feed("v2 download lanes repeated a path"));
6549        }
6550    }
6551    expected_order
6552        .into_iter()
6553        .map(|path| {
6554            streamed
6555                .remove(&path)
6556                .map(|bytes| (path, bytes))
6557                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
6558        })
6559        .collect()
6560}
6561
6562/// Resume an exact-head materialization into the private global cache. Each
6563/// verified frame is durable before the next network window begins, so a
6564/// retry skips it and memory never grows with total changed bytes.
6565#[cfg(any(unix, windows))]
6566fn queue_v2_bulk_window<'a>(
6567    cache_dir: &Path,
6568    window: &mut Vec<(&'a String, &'a V2BaselineFile)>,
6569    window_bytes: &mut u64,
6570    staged: &mut std::collections::BTreeMap<String, V2StagedFile>,
6571    missing_windows: &mut Vec<Vec<(&'a String, &'a V2BaselineFile)>>,
6572) -> LinkResult<()> {
6573    if window.is_empty() {
6574        return Ok(());
6575    }
6576    let missing = window
6577        .iter()
6578        .filter_map(|(path, file)| {
6579            let target = cache_dir.join(&file.sha256);
6580            match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6581                Ok(true) => {
6582                    staged.insert(
6583                        (*path).clone(),
6584                        V2StagedFile {
6585                            path: (*path).clone(),
6586                            source: target,
6587                            sha256: file.sha256.clone(),
6588                            bytes: file.bytes,
6589                        },
6590                    );
6591                    None
6592                }
6593                Ok(false) => Some(Ok((*path, *file))),
6594                Err(error) => Some(Err(error)),
6595            }
6596        })
6597        .collect::<LinkResult<Vec<_>>>()?;
6598    if !missing.is_empty() {
6599        missing_windows.push(missing);
6600    }
6601    window.clear();
6602    *window_bytes = 0;
6603    Ok(())
6604}
6605
6606#[cfg(any(unix, windows))]
6607fn stage_v2_bulk_windows<'a>(
6608    cfg: &HubConfig,
6609    brain: &str,
6610    pointer: &V2PointerBody,
6611    cache_dir: &Path,
6612    windows: Vec<Vec<(&'a String, &'a V2BaselineFile)>>,
6613    staged: &mut std::collections::BTreeMap<String, V2StagedFile>,
6614) -> LinkResult<()> {
6615    let next = std::sync::atomic::AtomicUsize::new(0);
6616    let worker_count = windows.len().min(V2_BLOB_DOWNLOAD_WORKERS);
6617    let mut first_error = None;
6618    std::thread::scope(|scope| {
6619        let (sender, receiver) = std::sync::mpsc::channel();
6620        for _ in 0..worker_count {
6621            let sender = sender.clone();
6622            let windows = &windows;
6623            let next = &next;
6624            scope.spawn(move || loop {
6625                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
6626                let Some(window) = windows.get(index) else {
6627                    break;
6628                };
6629                let result = download_v2_bulk_stream(cfg, brain, pointer, window);
6630                if sender.send((index, result)).is_err() {
6631                    break;
6632                }
6633            });
6634        }
6635        drop(sender);
6636        for (index, result) in receiver {
6637            match result {
6638                Ok(files) => {
6639                    for (path, bytes) in files {
6640                        let Some(file) = windows[index].iter().find_map(|(expected_path, file)| {
6641                            (*expected_path == &path).then_some(*file)
6642                        }) else {
6643                            first_error.get_or_insert_with(|| {
6644                                invalid_feed("v2 stream returned an unrequested cache path")
6645                            });
6646                            continue;
6647                        };
6648                        match cache_v2_blob_bytes(cache_dir, &file.sha256, file.bytes, &bytes) {
6649                            Ok(source) => {
6650                                if staged
6651                                    .insert(
6652                                        path.clone(),
6653                                        V2StagedFile {
6654                                            path,
6655                                            source,
6656                                            sha256: file.sha256.clone(),
6657                                            bytes: file.bytes,
6658                                        },
6659                                    )
6660                                    .is_some()
6661                                {
6662                                    first_error.get_or_insert_with(|| {
6663                                        invalid_feed("v2 bulk streams repeated a path")
6664                                    });
6665                                }
6666                            }
6667                            Err(error) => {
6668                                first_error.get_or_insert(error);
6669                            }
6670                        }
6671                    }
6672                }
6673                Err(error) => {
6674                    first_error.get_or_insert(error);
6675                }
6676            }
6677        }
6678    });
6679    match first_error {
6680        Some(error) => Err(error),
6681        None => Ok(()),
6682    }
6683}
6684
6685#[cfg(any(unix, windows))]
6686fn stage_v2_blobs(
6687    cfg: &HubConfig,
6688    brain: &str,
6689    pointer: &V2PointerBody,
6690    pending: Vec<(&String, &V2BaselineFile)>,
6691) -> LinkResult<Vec<V2StagedFile>> {
6692    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6693    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6694    let mut direct = Vec::new();
6695    let mut window = Vec::new();
6696    let mut missing_windows = Vec::new();
6697    let mut window_bytes = 0_u64;
6698    for &(path, file) in &pending {
6699        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6700            queue_v2_bulk_window(
6701                &cache_dir,
6702                &mut window,
6703                &mut window_bytes,
6704                &mut staged,
6705                &mut missing_windows,
6706            )?;
6707            direct.push((path, file));
6708            continue;
6709        }
6710        if window.len() == V2_BULK_STREAM_FILES
6711            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6712        {
6713            queue_v2_bulk_window(
6714                &cache_dir,
6715                &mut window,
6716                &mut window_bytes,
6717                &mut staged,
6718                &mut missing_windows,
6719            )?;
6720        }
6721        window.push((path, file));
6722        window_bytes += file.bytes;
6723    }
6724    queue_v2_bulk_window(
6725        &cache_dir,
6726        &mut window,
6727        &mut window_bytes,
6728        &mut staged,
6729        &mut missing_windows,
6730    )?;
6731    stage_v2_bulk_windows(
6732        cfg,
6733        brain,
6734        pointer,
6735        &cache_dir,
6736        missing_windows,
6737        &mut staged,
6738    )?;
6739    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6740        let source =
6741            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6742        staged.insert(
6743            item.path.clone(),
6744            V2StagedFile {
6745                path: item.path,
6746                source,
6747                sha256: item.sha256,
6748                bytes: item.bytes,
6749            },
6750        );
6751    }
6752    pending
6753        .into_iter()
6754        .map(|(path, _)| {
6755            staged
6756                .remove(path)
6757                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6758        })
6759        .collect()
6760}
6761
6762#[cfg(not(any(unix, windows)))]
6763fn stage_v2_blobs(
6764    _cfg: &HubConfig,
6765    _brain: &str,
6766    _pointer: &V2PointerBody,
6767    _pending: Vec<(&String, &V2BaselineFile)>,
6768) -> LinkResult<Vec<V2StagedFile>> {
6769    Err(LinkError::UnsupportedPlatform {
6770        operation: "resumable v2 download staging",
6771    })
6772}
6773
6774const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6775const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6776const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6777
6778#[derive(Debug, Clone, Deserialize, Serialize)]
6779struct V2ConflictCoordinate {
6780    sha256: Option<String>,
6781    bytes: Option<u64>,
6782    file: Option<String>,
6783}
6784
6785#[derive(Debug, Clone, Deserialize, Serialize)]
6786struct V2ConflictFile {
6787    path: String,
6788    base: V2ConflictCoordinate,
6789    local: V2ConflictCoordinate,
6790    remote: V2ConflictCoordinate,
6791}
6792
6793#[derive(Debug, Clone, Deserialize, Serialize)]
6794struct V2ConflictPlan {
6795    v: u8,
6796    class: String,
6797    bundle: String,
6798    brain: String,
6799    origin: String,
6800    created_unix: u64,
6801    expires_unix: u64,
6802    base_seq: Option<u64>,
6803    base_commit: Option<String>,
6804    remote_seq: u64,
6805    remote_commit: Option<String>,
6806    remote_content_root: Option<String>,
6807    view_kind: String,
6808    view_revision: String,
6809    files: Vec<V2ConflictFile>,
6810}
6811
6812fn v2_take_remote_selection(
6813    files: &[V2ConflictFile],
6814    current: &std::collections::BTreeMap<String, V2BaselineFile>,
6815) -> LinkResult<(
6816    std::collections::BTreeMap<String, V2BaselineFile>,
6817    Vec<String>,
6818)> {
6819    let mut selected = std::collections::BTreeMap::new();
6820    let mut deleted = Vec::new();
6821    for file in files {
6822        match (&file.remote.sha256, file.remote.bytes) {
6823            (Some(sha256), Some(bytes)) => {
6824                let proven = current.get(&file.path).ok_or_else(|| {
6825                    invalid_feed("conflict remote coordinate disappeared from the exact head")
6826                })?;
6827                if proven.sha256 != *sha256 || proven.bytes != bytes {
6828                    return Err(invalid_feed(
6829                        "conflict remote coordinate differs from the exact head",
6830                    ));
6831                }
6832                if selected.insert(file.path.clone(), proven.clone()).is_some() {
6833                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6834                }
6835            }
6836            (None, None) => {
6837                if current.contains_key(&file.path) {
6838                    return Err(invalid_feed(
6839                        "conflict remote deletion differs from the exact head",
6840                    ));
6841                }
6842                deleted.push(file.path.clone());
6843            }
6844            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6845        }
6846    }
6847    Ok((selected, deleted))
6848}
6849
6850fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6851    PathBuf::from(".dbmd")
6852        .join("conflicts")
6853        .join(bundle)
6854        .join(suffix)
6855}
6856
6857fn read_historical_conflict_blob(
6858    cfg: &HubConfig,
6859    brain: &str,
6860    baseline: &V2SyncBaseline,
6861    path: &str,
6862    file: &V2BaselineFile,
6863) -> LinkResult<Option<Vec<u8>>> {
6864    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6865        return Ok(None);
6866    };
6867    if seq == 0 {
6868        return Ok(None);
6869    }
6870    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6871    let endpoint = format!(
6872        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6873        file.sha256
6874    );
6875    let history_http = hub_agent_with_timeout(cfg, std::time::Duration::from_secs(15))?;
6876    let raw = match request_raw_with_agent(
6877        cfg,
6878        &history_http,
6879        "GET",
6880        &endpoint,
6881        None,
6882        RawRequestOptions {
6883            auth: Auth::Required,
6884            max_response_bytes: file.bytes,
6885            request_id: None,
6886            retry_transport: false,
6887        },
6888    ) {
6889        Ok(raw) => raw,
6890        // The base body is diagnostic convenience only. The conflict plan
6891        // already carries its verified hash/length, plus exact local and
6892        // current-remote bodies. A slow retained-object read must not prevent
6893        // the operator from choosing keep-local or take-remote safely.
6894        Err(LinkError::Transport { .. }) => return Ok(None),
6895        Err(error) => return Err(error),
6896    };
6897    if raw.status == 404 || raw.status == 403 {
6898        return Ok(None);
6899    }
6900    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6901    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6902        return Err(invalid_feed(
6903            "v2 conflict base failed integrity verification",
6904        ));
6905    }
6906    Ok(Some(bytes))
6907}
6908
6909/// Persist a readable content conflict without modifying either side. The
6910/// plan is written last, so an interrupted bundle build is never actionable.
6911fn create_v2_conflict_bundle(
6912    cfg: &HubConfig,
6913    store: &Store,
6914    head: &V2VerifiedHead,
6915    baseline: Option<&V2SyncBaseline>,
6916    local: &std::collections::BTreeMap<String, (String, u64)>,
6917    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6918    paths: &[String],
6919) -> LinkResult<(String, Vec<String>)> {
6920    let conflicts_root = Path::new(".dbmd/conflicts");
6921    store.create_dir_all(conflicts_root)?;
6922    let completed = store
6923        .directory_names(conflicts_root)?
6924        .into_iter()
6925        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6926        .count();
6927    if completed >= V2_CONFLICT_BUNDLE_MAX {
6928        return Err(LinkError::InvalidPack {
6929            message: format!(
6930                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6931            ),
6932        });
6933    }
6934
6935    // A conflict bundle is diagnostic state, not a second whole-brain cache.
6936    // Materialize a deterministic prefix whose remote artifacts fit one
6937    // bounded window. Resolving it exposes the next prefix on the next sync.
6938    let mut selected_paths = Vec::new();
6939    let mut selected_remote_bytes = 0_u64;
6940    for path in paths {
6941        let bytes = remote.get(path).map_or(0, |file| file.bytes);
6942        if !selected_paths.is_empty()
6943            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6944        {
6945            break;
6946        }
6947        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6948        selected_paths.push(path.clone());
6949        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6950            break;
6951        }
6952    }
6953    if selected_paths.is_empty() {
6954        return Err(invalid_feed("content conflict set is empty"));
6955    }
6956    let bundle = crate::ulid::mint();
6957    let bundle_root = v2_conflict_relative(&bundle, "");
6958    store.create_dir_all(&bundle_root.join("files"))?;
6959    let pointer = head.pointer.as_ref();
6960    let remote_bytes = match pointer {
6961        Some(pointer) => download_v2_blobs(
6962            cfg,
6963            &head.brain_id,
6964            pointer,
6965            selected_paths
6966                .iter()
6967                .filter_map(|path| {
6968                    remote
6969                        .get(path)
6970                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6971                        .map(|file| (path, file))
6972                })
6973                .collect(),
6974        )?
6975        .into_iter()
6976        .collect::<std::collections::BTreeMap<_, _>>(),
6977        None => std::collections::BTreeMap::new(),
6978    };
6979
6980    let mut files = Vec::with_capacity(selected_paths.len());
6981    let mut historical_body_available = true;
6982    for (index, path) in selected_paths.iter().enumerate() {
6983        let base_file = baseline.and_then(|state| state.files.get(path));
6984        let base_bytes = match (historical_body_available, baseline, base_file) {
6985            (true, Some(state), Some(file)) => {
6986                let bytes = read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?;
6987                if bytes.is_none() {
6988                    // Historical content is optional diagnostic context. Once
6989                    // the retained-object lane proves unavailable, preserve
6990                    // the exact base coordinates but spend no more network
6991                    // time on this bundle.
6992                    historical_body_available = false;
6993                }
6994                bytes
6995            }
6996            _ => None,
6997        };
6998        let local_file = local.get(path);
6999        let remote_file = remote.get(path);
7000        let remote_content = remote_bytes.get(path);
7001        let prefix = format!("files/{index:04}");
7002        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
7003        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
7004        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
7005        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
7006            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
7007        }
7008        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
7009            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
7010            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
7011                return Err(LinkError::InvalidPack {
7012                    message: format!("local conflict path `{path}` changed while bundling"),
7013                });
7014            }
7015            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
7016        }
7017        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
7018            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
7019        }
7020        files.push(V2ConflictFile {
7021            path: path.clone(),
7022            base: V2ConflictCoordinate {
7023                sha256: base_file.map(|file| file.sha256.clone()),
7024                bytes: base_file.map(|file| file.bytes),
7025                file: base_name,
7026            },
7027            local: V2ConflictCoordinate {
7028                sha256: local_file.map(|(sha256, _)| sha256.clone()),
7029                bytes: local_file.map(|(_, bytes)| *bytes),
7030                file: local_name,
7031            },
7032            remote: V2ConflictCoordinate {
7033                sha256: remote_file.map(|file| file.sha256.clone()),
7034                bytes: remote_file.map(|file| file.bytes),
7035                file: remote_name,
7036            },
7037        });
7038    }
7039    let now = SystemTime::now()
7040        .duration_since(UNIX_EPOCH)
7041        .unwrap_or_default()
7042        .as_secs();
7043    let plan = V2ConflictPlan {
7044        v: 2,
7045        class: "content_resolution_required".to_string(),
7046        bundle: bundle.clone(),
7047        brain: head.brain_id.clone(),
7048        origin: normalized_origin(&cfg.hub)?,
7049        created_unix: now,
7050        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
7051        base_seq: baseline.and_then(|state| state.head_seq),
7052        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
7053        remote_seq: pointer.map_or(0, |value| value.seq),
7054        remote_commit: pointer.map(|value| value.commit_hash.clone()),
7055        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
7056        view_kind: head.view_kind.clone(),
7057        view_revision: head.view_revision.clone(),
7058        files,
7059    };
7060    let mut bytes = serde_json::to_vec_pretty(&plan)
7061        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
7062    bytes.push(b'\n');
7063    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
7064    Ok((bundle, selected_paths))
7065}
7066
7067fn v2_sync_pull_with_resolution(
7068    cfg: &HubConfig,
7069    requested_brain: &str,
7070    expected_head: V2VerifiedHead,
7071    out: Option<&Path>,
7072    take_remote: Option<&std::collections::BTreeSet<String>>,
7073) -> LinkResult<V2PulledSnapshot> {
7074    let dest = out
7075        .map(Path::to_path_buf)
7076        .unwrap_or_else(|| PathBuf::from(requested_brain));
7077    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
7078    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
7079    let head = v2_verified_head(cfg, requested_brain)?
7080        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7081    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
7082        return Err(LinkError::RemoteAdvancedDuringSync);
7083    }
7084    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
7085    ensure_v2_view_compatible(&head, baseline.as_ref())?;
7086    let (remote, remote_assets) = match baseline
7087        .as_ref()
7088        .filter(|state| v2_baseline_matches_head(&head, state))
7089    {
7090        Some(state) => (state.files.clone(), state.assets.clone()),
7091        None => (
7092            files_for_v2_view(
7093                &head,
7094                v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7095            ),
7096            v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7097        ),
7098    };
7099    verify_v2_markdown_asset_content_bindings(&remote, &remote_assets)?;
7100    let local_store = Store::open_strict(&dest).ok();
7101    // A baseline proves this path is an established checkout. Never reinterpret
7102    // a damaged/edited store marker as an empty clone destination: doing so
7103    // would let the pull half erase the local evidence before the push half can
7104    // reject it (most importantly for the generated scoped-view DB.md).
7105    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
7106    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
7107    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
7108        return Err(LinkError::ScopedViewChanged);
7109    }
7110    if let Some(view) = local_view.as_mut() {
7111        remove_scoped_projection(&head, baseline.as_ref(), view)?;
7112    }
7113    let empty_local = std::collections::BTreeMap::new();
7114    let local = local_view
7115        .as_ref()
7116        .map_or(&empty_local, |view| &view.riding);
7117    let kept_home = |path: &str| {
7118        local_view
7119            .as_ref()
7120            .is_some_and(|view| view.policy.keeps_home(path))
7121    };
7122    let empty_base = std::collections::BTreeMap::new();
7123    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
7124    let empty_base_assets = std::collections::BTreeMap::new();
7125    let base_assets = baseline
7126        .as_ref()
7127        .map_or(&empty_base_assets, |state| &state.assets);
7128    let mut local_assets = local_store
7129        .as_ref()
7130        .map(v2_local_asset_records)
7131        .transpose()?
7132        .unwrap_or_default();
7133    let mut content_merge = merge_v2_pulled_records(
7134        base,
7135        &remote,
7136        local,
7137        |file, _| (file.sha256.clone(), file.bytes),
7138        |file, _| (file.sha256.clone(), file.bytes),
7139        kept_home,
7140    );
7141    if let Some(selected) = take_remote {
7142        for path in selected {
7143            if let Some(position) = content_merge
7144                .conflicts
7145                .iter()
7146                .position(|conflict| conflict == path)
7147            {
7148                content_merge.conflicts.remove(position);
7149                content_merge.accept_remote.insert(path.clone());
7150                match remote.get(path) {
7151                    Some(file) => {
7152                        content_merge
7153                            .records
7154                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
7155                    }
7156                    None => {
7157                        content_merge.records.remove(path);
7158                    }
7159                }
7160            } else if !content_merge.accept_remote.contains(path) {
7161                return Err(LinkError::InvalidPack {
7162                    message: format!(
7163                        "take-remote path `{path}` is no longer at its conflict coordinate"
7164                    ),
7165                });
7166            }
7167        }
7168    }
7169    if !content_merge.conflicts.is_empty() {
7170        let mut conflicts = content_merge.conflicts.clone();
7171        conflicts.truncate(100);
7172        if let Some(store) = local_store.as_ref() {
7173            let (bundle, paths) = create_v2_conflict_bundle(
7174                cfg,
7175                store,
7176                &head,
7177                baseline.as_ref(),
7178                local,
7179                &remote,
7180                &conflicts,
7181            )?;
7182            return Err(LinkError::ConflictBundle { bundle, paths });
7183        }
7184        return Err(LinkError::Conflict { paths: conflicts });
7185    }
7186    let asset_merge = merge_v2_pulled_records(
7187        base_assets,
7188        &remote_assets,
7189        &local_assets,
7190        v2_asset_record,
7191        v2_asset_record,
7192        |_| false,
7193    );
7194    if !asset_merge.conflicts.is_empty() {
7195        let mut conflicts = asset_merge.conflicts.clone();
7196        conflicts.truncate(100);
7197        return Err(LinkError::Conflict { paths: conflicts });
7198    }
7199    let pointer = head.pointer.as_ref();
7200    let cache_transaction = pointer.map_or_else(
7201        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
7202        |value| value.commit_hash.clone(),
7203    );
7204    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
7205    let mut changed = match pointer {
7206        Some(pointer) => stage_v2_blobs(
7207            cfg,
7208            &head.brain_id,
7209            pointer,
7210            remote
7211                .iter()
7212                .filter(|(path, file)| {
7213                    content_merge.accept_remote.contains(*path)
7214                        && local.get(*path).map(|value| value.0.as_str())
7215                            != Some(file.sha256.as_str())
7216                })
7217                .collect(),
7218        )?,
7219        None => Vec::new(),
7220    };
7221    let mut deleted = content_merge
7222        .accept_remote
7223        .iter()
7224        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
7225        .cloned()
7226        .collect::<Vec<_>>();
7227    if local_assets != asset_merge.records {
7228        if asset_merge.records.is_empty() {
7229            deleted.push("assets.jsonl".to_string());
7230        } else {
7231            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
7232            let sha256 = content_sha256(&bytes);
7233            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
7234            changed.push(V2StagedFile {
7235                path: "assets.jsonl".to_string(),
7236                source,
7237                sha256,
7238                bytes: bytes.len() as u64,
7239            });
7240        }
7241    }
7242    if let Some(pointer) = pointer {
7243        let mut pending_assets = Vec::new();
7244        for (path, asset) in &remote_assets {
7245            if asset.disposition != "hosted"
7246                || kept_home(path)
7247                || !asset_merge.accept_remote.contains(path)
7248            {
7249                continue;
7250            }
7251            if is_markdown_asset_path(path) {
7252                // The signed content plane carries the same bytes and is
7253                // already part of this install transaction.
7254                continue;
7255            }
7256            let already_current = local_store.as_ref().is_some_and(|store| {
7257                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7258                    && store
7259                        .read_bounded(Path::new(path), asset.bytes)
7260                        .ok()
7261                        .is_some_and(|bytes| {
7262                            bytes.len() as u64 == asset.bytes
7263                                && content_sha256(&bytes) == asset.blob_sha256
7264                        })
7265            });
7266            if !already_current {
7267                pending_assets.push((path, asset));
7268            }
7269        }
7270        let mut window = Vec::new();
7271        let mut window_bytes = 0_u64;
7272        let flush = |window: &mut Vec<(&String, &V2BaselineAsset)>,
7273                     window_bytes: &mut u64,
7274                     changed: &mut Vec<V2StagedFile>|
7275         -> LinkResult<()> {
7276            changed.extend(stage_v2_asset_download_window(
7277                cfg,
7278                &head.brain_id,
7279                pointer,
7280                &cache_dir,
7281                window,
7282            )?);
7283            window.clear();
7284            *window_bytes = 0;
7285            Ok(())
7286        };
7287        for item @ (_, asset) in pending_assets {
7288            if !window.is_empty()
7289                && (window.len() == V2_DOWNLOAD_CAPABILITY_FILES
7290                    || window_bytes.saturating_add(asset.bytes) > V2_DOWNLOAD_CAPABILITY_BYTES)
7291            {
7292                flush(&mut window, &mut window_bytes, &mut changed)?;
7293            }
7294            window.push(item);
7295            window_bytes = window_bytes.saturating_add(asset.bytes);
7296            if window_bytes >= V2_DOWNLOAD_CAPABILITY_BYTES {
7297                flush(&mut window, &mut window_bytes, &mut changed)?;
7298            }
7299        }
7300        flush(&mut window, &mut window_bytes, &mut changed)?;
7301    }
7302    for (path, prior) in base_assets {
7303        if remote_assets.contains_key(path)
7304            || kept_home(path)
7305            || !asset_merge.accept_remote.contains(path)
7306        {
7307            continue;
7308        }
7309        let unchanged = local_store.as_ref().is_some_and(|store| {
7310            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7311                && store
7312                    .read_bounded(Path::new(path), prior.bytes)
7313                    .ok()
7314                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
7315        });
7316        if unchanged {
7317            deleted.push(path.clone());
7318        }
7319    }
7320    let extra_local = content_merge
7321        .records
7322        .keys()
7323        .filter(|path| !remote.contains_key(*path))
7324        .cloned()
7325        .collect::<Vec<_>>();
7326    if head.view_kind == "scoped" {
7327        for (path, bytes) in [
7328            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
7329            (
7330                ".dbmd/view.json".to_string(),
7331                scoped_view_metadata(&head, remote.len())?,
7332            ),
7333        ] {
7334            let sha256 = content_sha256(&bytes);
7335            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
7336            changed.push(V2StagedFile {
7337                path,
7338                source,
7339                sha256,
7340                bytes: bytes.len() as u64,
7341            });
7342        }
7343    }
7344    let install_changed = !changed.is_empty() || !deleted.is_empty();
7345    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
7346    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
7347        let installed_store =
7348            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
7349                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
7350            })?;
7351        let installed_local = if install_changed {
7352            let hint = baseline.as_ref().and_then(|state| {
7353                state
7354                    .local_policy_digest
7355                    .as_deref()
7356                    .map(|digest| (digest, &state.scan_cache))
7357            });
7358            let mut scanned = v2_local_files_cached(&installed_store, hint)?;
7359            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
7360            scanned
7361        } else {
7362            local_view
7363                .take()
7364                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
7365        };
7366        if installed_local.riding != content_merge.records {
7367            return Err(LinkError::InvalidPack {
7368                message: "local content changed while installing the v2 pull".to_string(),
7369            });
7370        }
7371        let installed_assets = if install_changed {
7372            v2_local_asset_records(&installed_store)?
7373        } else {
7374            std::mem::take(&mut local_assets)
7375        };
7376        if installed_assets != asset_merge.records {
7377            return Err(LinkError::InvalidPack {
7378                message: "local assets changed while installing the v2 pull".to_string(),
7379            });
7380        }
7381        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
7382            installed_local.policy.keeps_home(path)
7383        })
7384            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
7385        let final_head = v2_verified_head(cfg, requested_brain)?
7386            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
7387        if !same_v2_head(&head, &final_head) {
7388            return Err(LinkError::RemoteAdvancedDuringSync);
7389        }
7390        accept_v2_head(cfg, &final_head)?;
7391        save_v2_baseline(
7392            cfg,
7393            &head.brain_id,
7394            &dest,
7395            &v2_baseline_from_head(
7396                cfg,
7397                &head,
7398                remote.clone(),
7399                remote_assets.clone(),
7400                Some(&installed_local),
7401                baseline
7402                    .as_ref()
7403                    .and_then(|current| current.checkout_id.as_deref()),
7404            )?,
7405        )?;
7406        complete_v2_pull(&dest)?;
7407        Ok((local_dirty, installed_local, installed_assets))
7408    })();
7409    let (local_dirty, installed_local, installed_assets) = match finalized {
7410        Ok(value) => value,
7411        Err(error) => {
7412            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
7413                return Err(LinkError::InvalidPack {
7414                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
7415                });
7416            }
7417            return Err(error);
7418        }
7419    };
7420    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
7421    let report = PullReport {
7422        brain: head.brain_id.clone(),
7423        slug: requested_brain.to_string(),
7424        head_seq: pointer.map_or(0, |value| value.seq),
7425        files: remote.len() + remote_assets.len(),
7426        dest: dest.to_string_lossy().into_owned(),
7427        extra_local,
7428        sync_status: if local_dirty {
7429            "local_dirty_after_install".to_string()
7430        } else {
7431            "synced".to_string()
7432        },
7433    };
7434    Ok(V2PulledSnapshot {
7435        report,
7436        head,
7437        files: remote,
7438        assets: remote_assets,
7439        local: installed_local,
7440        local_assets: installed_assets,
7441    })
7442}
7443
7444fn v2_sync_pull(
7445    cfg: &HubConfig,
7446    requested_brain: &str,
7447    head: V2VerifiedHead,
7448    out: Option<&Path>,
7449) -> LinkResult<PullReport> {
7450    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
7451}
7452
7453fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
7454    match remote {
7455        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
7456        None => json!({ "kind": "absent" }),
7457    }
7458}
7459
7460fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
7461    match remote {
7462        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
7463        None => json!({ "kind": "absent" }),
7464    }
7465}
7466
7467fn v2_content_withdrawal_operation(
7468    store: &Store,
7469    local_view: &V2LocalView,
7470    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7471    path: &str,
7472    reason: &str,
7473) -> LinkResult<Value> {
7474    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
7475        || path == "DB.md"
7476    {
7477        return Err(LinkError::InvalidPack {
7478            message: format!("content withdrawal path `{path}` is not a record or source"),
7479        });
7480    }
7481    if !local_view.policy.keeps_home(path)
7482        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7483    {
7484        return Err(LinkError::InvalidPack {
7485            message: format!(
7486                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7487            ),
7488        });
7489    }
7490    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
7491        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
7492    })?;
7493    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
7494    Ok(json!({
7495        "op": "withdraw_from_hosting",
7496        "path": path,
7497        "expected": { "kind": "blob", "hash": current.sha256 },
7498        "reason": reason,
7499    }))
7500}
7501
7502fn v2_asset_withdrawal_operation(
7503    store: &Store,
7504    local_view: &V2LocalView,
7505    path: &str,
7506    local: &crate::AssetRecord,
7507    current: &V2BaselineAsset,
7508    reason: &str,
7509) -> LinkResult<Value> {
7510    if !local_view.policy.keeps_home(path)
7511        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
7512    {
7513        return Err(LinkError::InvalidPack {
7514            message: format!(
7515                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
7516            ),
7517        });
7518    }
7519    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
7520    if current.disposition != "hosted"
7521        || current.blob_sha256 != local.sha256
7522        || current.bytes != local.bytes
7523        || current.media_type != local.media_type
7524    {
7525        return Err(LinkError::InvalidPack {
7526            message: format!(
7527                "asset withdrawal path `{path}` must preserve the currently hosted blob identity, byte count, and media type"
7528            ),
7529        });
7530    }
7531    Ok(json!({
7532        "op": "asset_withdraw",
7533        "path": path,
7534        "expected": v2_asset_expected(Some(current)),
7535        "asset": v2_asset_value(local, "withheld"),
7536        "reason": reason,
7537    }))
7538}
7539
7540// A source is immutable, but its exact bytes may move from an intake namespace
7541// to an archive namespace under the distinct `promote_source` capability. A
7542// plain filesystem move appears to sync as one absent old path plus one new
7543// path. Preserve that intent as a protocol rename only when the hash has one
7544// unambiguous source delete and one unambiguous absent-destination source put.
7545// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
7546fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
7547    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
7548    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
7549    for (index, operation) in operations.iter().enumerate() {
7550        match operation.get("op").and_then(Value::as_str) {
7551            Some("delete") => {
7552                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7553                    continue;
7554                };
7555                let Some(hash) = operation
7556                    .get("expected")
7557                    .and_then(|value| value.get("hash"))
7558                    .and_then(Value::as_str)
7559                else {
7560                    continue;
7561                };
7562                if path.starts_with("sources/") {
7563                    deletes
7564                        .entry(hash.to_string())
7565                        .or_default()
7566                        .push((index, path.to_string()));
7567                }
7568            }
7569            Some("put" | "put_asset_content") => {
7570                let Some(path) = operation.get("path").and_then(Value::as_str) else {
7571                    continue;
7572                };
7573                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
7574                    continue;
7575                };
7576                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
7577                    continue;
7578                };
7579                let destination_absent = operation
7580                    .get("expected")
7581                    .and_then(|value| value.get("kind"))
7582                    .and_then(Value::as_str)
7583                    == Some("absent");
7584                if path.starts_with("sources/") && destination_absent {
7585                    puts.entry(hash.to_string()).or_default().push((
7586                        index,
7587                        path.to_string(),
7588                        bytes,
7589                    ));
7590                }
7591            }
7592            _ => {}
7593        }
7594    }
7595    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
7596    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
7597    for (hash, source) in deletes {
7598        let Some(destination) = puts.get(&hash) else {
7599            continue;
7600        };
7601        if source.len() != 1 || destination.len() != 1 {
7602            continue;
7603        }
7604        let (delete_index, from) = &source[0];
7605        let (put_index, to, bytes) = &destination[0];
7606        if from == to {
7607            continue;
7608        }
7609        rename_at.insert(
7610            *delete_index,
7611            json!({
7612                "op": "rename",
7613                "from": from,
7614                "to": to,
7615                "expected_from": { "kind": "blob", "hash": hash },
7616                "expected_to": { "kind": "absent" },
7617                "blob": hash,
7618                "bytes": bytes,
7619            }),
7620        );
7621        consumed_puts.insert(*put_index);
7622    }
7623    operations
7624        .into_iter()
7625        .enumerate()
7626        .filter_map(|(index, operation)| {
7627            if let Some(rename) = rename_at.remove(&index) {
7628                Some(rename)
7629            } else if consumed_puts.contains(&index) {
7630                None
7631            } else {
7632                Some(operation)
7633            }
7634        })
7635        .collect()
7636}
7637
7638fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
7639    json!({
7640        "blob_sha256": record.sha256,
7641        "bytes": record.bytes,
7642        "media_type": record.media_type,
7643        "wrappers": record.wrappers,
7644        "required": record.required,
7645        "disposition": disposition,
7646    })
7647}
7648
7649/// Apply the exact operation subset generated by this client to an already
7650/// verified manifest. This avoids downloading the same 50k-path listing again
7651/// after an unrebased commit; a rebased response still re-reads the manifest.
7652fn apply_generated_v2_operations(
7653    operations: &[Value],
7654    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
7655    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
7656    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
7657) -> LinkResult<bool> {
7658    let mut asset_changed = false;
7659    for operation in operations {
7660        match operation.get("op").and_then(Value::as_str) {
7661            Some("put" | "put_asset_content") => {
7662                let path = operation
7663                    .get("path")
7664                    .and_then(Value::as_str)
7665                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
7666                let sha256 = operation
7667                    .get("blob")
7668                    .and_then(Value::as_str)
7669                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
7670                let bytes = operation
7671                    .get("bytes")
7672                    .and_then(Value::as_u64)
7673                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
7674                candidate.insert(
7675                    path.to_string(),
7676                    V2BaselineFile {
7677                        sha256: sha256.to_string(),
7678                        bytes,
7679                        proof: None,
7680                    },
7681                );
7682            }
7683            Some("rename") => {
7684                let from = operation
7685                    .get("from")
7686                    .and_then(Value::as_str)
7687                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
7688                let to = operation
7689                    .get("to")
7690                    .and_then(Value::as_str)
7691                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
7692                let sha256 = operation
7693                    .get("blob")
7694                    .and_then(Value::as_str)
7695                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
7696                let bytes = operation
7697                    .get("bytes")
7698                    .and_then(Value::as_u64)
7699                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
7700                let expected_from = operation
7701                    .get("expected_from")
7702                    .and_then(|expected| expected.get("hash"))
7703                    .and_then(Value::as_str);
7704                let expected_to_absent = operation
7705                    .get("expected_to")
7706                    .and_then(|expected| expected.get("kind"))
7707                    .and_then(Value::as_str)
7708                    == Some("absent");
7709                if from == to
7710                    || !from.starts_with("sources/")
7711                    || !to.starts_with("sources/")
7712                    || expected_from != Some(sha256)
7713                    || !expected_to_absent
7714                    || candidate.contains_key(to)
7715                {
7716                    return Err(invalid_feed("generated v2 source rename is malformed"));
7717                }
7718                let source = candidate
7719                    .remove(from)
7720                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
7721                if source.sha256 != sha256 || source.bytes != bytes {
7722                    return Err(invalid_feed(
7723                        "v2 rename source differs from its exact-byte claim",
7724                    ));
7725                }
7726                candidate.insert(
7727                    to.to_string(),
7728                    V2BaselineFile {
7729                        sha256: sha256.to_string(),
7730                        bytes,
7731                        proof: None,
7732                    },
7733                );
7734            }
7735            Some("delete" | "withdraw_from_hosting") => {
7736                let path = operation
7737                    .get("path")
7738                    .and_then(Value::as_str)
7739                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7740                candidate.remove(path);
7741            }
7742            Some("asset_delete") => {
7743                let path = operation
7744                    .get("path")
7745                    .and_then(Value::as_str)
7746                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7747                candidate_assets.remove(path);
7748                asset_changed = true;
7749            }
7750            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7751                let path = operation
7752                    .get("path")
7753                    .and_then(Value::as_str)
7754                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7755                let record = local_assets
7756                    .get(path)
7757                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7758                let disposition =
7759                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7760                        "withheld"
7761                    } else {
7762                        operation
7763                            .get("asset")
7764                            .and_then(|asset| asset.get("disposition"))
7765                            .and_then(Value::as_str)
7766                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7767                    };
7768                candidate_assets.insert(
7769                    path.to_string(),
7770                    V2BaselineAsset {
7771                        blob_sha256: record.sha256.clone(),
7772                        bytes: record.bytes,
7773                        media_type: record.media_type.clone(),
7774                        wrappers: record.wrappers.clone(),
7775                        required: record.required,
7776                        disposition: disposition.to_string(),
7777                        // The server assigns the signed leaf address. Callers
7778                        // that need an exact asset manifest re-read it below.
7779                        leaf_hash: String::new(),
7780                    },
7781                );
7782                asset_changed = true;
7783            }
7784            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7785        }
7786    }
7787    Ok(asset_changed)
7788}
7789
7790fn v2_riding_matches_remote(
7791    local: &std::collections::BTreeMap<String, (String, u64)>,
7792    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7793    keeps_home: impl Fn(&str) -> bool,
7794) -> bool {
7795    remote.iter().all(|(path, file)| {
7796        keeps_home(path)
7797            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7798    }) && local.iter().all(|(path, (hash, _))| {
7799        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7800    })
7801}
7802
7803fn v2_initial_content_conflicts(
7804    local: &std::collections::BTreeMap<String, (String, u64)>,
7805    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7806    resolving: bool,
7807) -> Vec<String> {
7808    if resolving {
7809        // `sync resolve` already pins the exact remote head/view and rechecks
7810        // every bundled local coordinate before it enters the push planner.
7811        // Treating this as an ordinary baseline-less push would merely mint an
7812        // identical conflict bundle forever. The operation loop below permits
7813        // only those explicit remote replacements plus safe local-only
7814        // additions, and verifies each selected local/remote pair again before
7815        // authoring the commit.
7816        return Vec::new();
7817    }
7818    remote
7819        .iter()
7820        .filter(|(path, file)| {
7821            local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7822        })
7823        .map(|(path, _)| path.clone())
7824        .collect()
7825}
7826
7827fn v2_resolution_allows_path(
7828    resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7829    path: &str,
7830    remote_present: bool,
7831) -> bool {
7832    resolution.is_none_or(|allowed| allowed.contains_key(path) || !remote_present)
7833}
7834
7835fn v2_resolution_allows_asset(
7836    resolution: Option<&std::collections::BTreeMap<String, V2ResolutionOverride>>,
7837    base: Option<&V2BaselineAsset>,
7838    remote: Option<&V2BaselineAsset>,
7839    local: Option<&crate::AssetRecord>,
7840) -> bool {
7841    let Some(allowed) = resolution else {
7842        return false;
7843    };
7844    let wrappers = base
7845        .into_iter()
7846        .flat_map(|asset| asset.wrappers.iter())
7847        .chain(remote.into_iter().flat_map(|asset| asset.wrappers.iter()))
7848        .chain(local.into_iter().flat_map(|asset| asset.wrappers.iter()))
7849        .collect::<BTreeSet<_>>();
7850    !wrappers.is_empty()
7851        && wrappers
7852            .iter()
7853            .all(|wrapper| allowed.contains_key(*wrapper))
7854}
7855
7856#[derive(Debug, Clone)]
7857struct V2ResolutionOverride {
7858    expected_remote: Option<String>,
7859    selected_local: Option<String>,
7860}
7861
7862#[derive(Debug, Clone)]
7863struct V2UploadSource {
7864    path: String,
7865    bytes: u64,
7866}
7867
7868struct V2SyncPushOptions<'a> {
7869    resume_local_policy: bool,
7870    bulk_confirmation: Option<&'a V2BulkConfirmation>,
7871    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7872    pulled: Option<V2PulledSnapshot>,
7873    withdrawal_paths: &'a [String],
7874    withdrawal_reason: Option<&'a str>,
7875    /// Permit one automatic DB.md-only commit before the remaining exact
7876    /// content/asset operations. The recursive second phase disables this so
7877    /// a concurrently edited contract fails for a clean retry instead of
7878    /// creating an unbounded commit chain.
7879    allow_contract_phase: bool,
7880}
7881
7882fn verify_v2_upload_source(
7883    store: &Store,
7884    path: &str,
7885    sha256: &str,
7886    expected_bytes: u64,
7887) -> LinkResult<()> {
7888    let file = store.open_regular(Path::new(path))?;
7889    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7890        return Err(LinkError::InvalidPack {
7891            message: format!("local path `{path}` changed during sync planning"),
7892        });
7893    }
7894    Ok(())
7895}
7896
7897/// One reserved object waiting to be staged, held until its whole batch can be
7898/// uploaded together.
7899struct V2PendingUpload<'a> {
7900    url: String,
7901    headers: Value,
7902    sha256: String,
7903    source: &'a V2UploadSource,
7904}
7905
7906/// How many objects a migration stages at once. Bounded so a first push does
7907/// not behave like a burst against the object store, while still being far
7908/// faster than one-at-a-time.
7909/// Workers staging one batch. With a shared pooled connection the cost of an
7910/// object is the PUT itself rather than a handshake, so a wider pool converts
7911/// directly into throughput on a migration.
7912const V2_UPLOAD_CONCURRENCY: usize = 16;
7913
7914/// Stage a reserved batch, uploading up to `V2_UPLOAD_CONCURRENCY` objects at a
7915/// time and verifying each against the store after it lands. The first failure
7916/// is returned and the remaining work is abandoned.
7917fn upload_v2_batch_concurrently(
7918    cfg: &HubConfig,
7919    store: &Store,
7920    pending: &[V2PendingUpload<'_>],
7921) -> LinkResult<()> {
7922    if pending.is_empty() {
7923        return Ok(());
7924    }
7925    let urls = pending
7926        .iter()
7927        .map(|task| task.url.as_str())
7928        .collect::<Vec<_>>();
7929    let shared = shared_staging_agent(cfg, &urls);
7930    if pending.len() == 1 {
7931        let task = &pending[0];
7932        put_presigned_source(
7933            cfg,
7934            &task.url,
7935            &task.headers,
7936            store,
7937            task.source,
7938            shared.as_ref(),
7939        )?;
7940        return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7941    }
7942    let next = std::sync::atomic::AtomicUsize::new(0);
7943    let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7944    let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7945    std::thread::scope(|scope| {
7946        for _ in 0..workers {
7947            scope.spawn(|| loop {
7948                if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7949                    return;
7950                }
7951                let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7952                let Some(task) = pending.get(index) else {
7953                    return;
7954                };
7955                let outcome = put_presigned_source(
7956                    cfg,
7957                    &task.url,
7958                    &task.headers,
7959                    store,
7960                    task.source,
7961                    shared.as_ref(),
7962                )
7963                .and_then(|()| {
7964                    verify_v2_upload_source(
7965                        store,
7966                        &task.source.path,
7967                        &task.sha256,
7968                        task.source.bytes,
7969                    )
7970                });
7971                if let Err(error) = outcome {
7972                    if let Ok(mut guard) = failure.lock() {
7973                        guard.get_or_insert(error);
7974                    }
7975                    return;
7976                }
7977            });
7978        }
7979    });
7980    match failure.into_inner() {
7981        Ok(Some(error)) => Err(error),
7982        Ok(None) => Ok(()),
7983        Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7984    }
7985}
7986
7987fn put_presigned_source(
7988    cfg: &HubConfig,
7989    raw: &str,
7990    headers: &Value,
7991    store: &Store,
7992    source: &V2UploadSource,
7993    shared: Option<&ureq::Agent>,
7994) -> LinkResult<()> {
7995    put_presigned_source_with_budget(
7996        cfg,
7997        raw,
7998        headers,
7999        store,
8000        source,
8001        shared,
8002        std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
8003    )
8004}
8005
8006fn put_presigned_source_with_budget(
8007    cfg: &HubConfig,
8008    raw: &str,
8009    headers: &Value,
8010    store: &Store,
8011    source: &V2UploadSource,
8012    shared: Option<&ureq::Agent>,
8013    total_budget: std::time::Duration,
8014) -> LinkResult<()> {
8015    // The URL's own safety checks run either way; only the connection pool is
8016    // shared, so a batch never trades a check for a warm socket.
8017    let owned = match shared {
8018        Some(_) => {
8019            checked_presigned_url(cfg, raw)?;
8020            None
8021        }
8022        None => Some(presigned_agent(cfg, raw)?),
8023    };
8024    let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
8025    let deadline = std::time::Instant::now()
8026        .checked_add(total_budget)
8027        .ok_or_else(upload_deadline_error)?;
8028    let mut attempt = 0;
8029    let result = loop {
8030        let file = store.open_regular(Path::new(&source.path))?;
8031        if file.metadata()?.len() != source.bytes {
8032            return Err(LinkError::InvalidPack {
8033                message: format!("local path `{}` changed before upload", source.path),
8034            });
8035        }
8036        // The reservation already states content-length, lowercased. The HTTP
8037        // client de-duplicates headers by exact name, not case, so setting
8038        // `Content-Length` here as well leaves both on the wire and an edge
8039        // rejects the request as malformed. Supply it only when absent.
8040        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
8041        let mut has_content_length = false;
8042        if let Some(map) = headers.as_object() {
8043            for (name, value) in map {
8044                if let Some(value) = value.as_str() {
8045                    has_content_length |= name.eq_ignore_ascii_case("content-length");
8046                    req = req.set(name, value);
8047                }
8048            }
8049        }
8050        if !has_content_length {
8051            req = req.set("Content-Length", &source.bytes.to_string());
8052        }
8053        match req.send(file) {
8054            // Every transport failure is retryable here, not only the
8055            // pre-request kinds: staging an object is idempotent (fixed content
8056            // address, exact length and checksum, write-once precondition), the
8057            // file is reopened on each attempt, and a migration holds
8058            // connections long enough that a mid-request drop is ordinary.
8059            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
8060                attempt += 1;
8061            }
8062            // A migration stages tens of thousands of immutable objects, and an
8063            // edge or object store may refuse a single one under sustained
8064            // load. The upload is content-addressed and precondition-guarded,
8065            // so replaying it is safe; failing the whole push on one refusal is
8066            // not. 412 already means "someone else stored these exact bytes".
8067            Err(ureq::Error::Status(status, _))
8068                if status != 412
8069                    && is_retryable_upload_status(status)
8070                    && wait_for_upload_retry(deadline, attempt) =>
8071            {
8072                attempt += 1;
8073            }
8074            result => break result,
8075        }
8076    };
8077    match result {
8078        Ok(response) if (200..300).contains(&response.status()) => {
8079            drain_presigned_response(response);
8080            Ok(())
8081        }
8082        Ok(response) => {
8083            // The object store states its own reason (checksum mismatch,
8084            // precondition, expiry). Carrying a bounded excerpt turns an
8085            // unactionable refusal into one an operator can diagnose; the
8086            // coordinate is the store path, never file content.
8087            let status = response.status();
8088            let detail = response
8089                .into_string()
8090                .ok()
8091                .map(|body| body.chars().take(400).collect::<String>())
8092                .filter(|body| !body.trim().is_empty());
8093            Err(LinkError::Http {
8094                what: "v2 changed-byte upload",
8095                status,
8096                message: match detail {
8097                    Some(body) => format!(
8098                        "object store rejected the upload of `{}`: {}",
8099                        source.path,
8100                        body.replace('\n', " ")
8101                    ),
8102                    None => format!("object store rejected the upload of `{}`", source.path),
8103                },
8104                code: None,
8105                details: None,
8106            })
8107        }
8108        Err(error) => match error {
8109            ureq::Error::Status(412, _) => Ok(()),
8110            ureq::Error::Status(_, response) => {
8111                let status = response.status();
8112                let detail = response
8113                    .into_string()
8114                    .ok()
8115                    .map(|body| body.chars().take(400).collect::<String>())
8116                    .filter(|body| !body.trim().is_empty());
8117                Err(LinkError::Http {
8118                    what: "v2 changed-byte upload",
8119                    status,
8120                    message: match detail {
8121                        Some(body) => format!(
8122                            "object store rejected the upload of `{}`: {}",
8123                            source.path,
8124                            body.replace('\n', " ")
8125                        ),
8126                        None => {
8127                            format!("object store rejected the upload of `{}`", source.path)
8128                        }
8129                    },
8130                    code: None,
8131                    details: None,
8132                })
8133            }
8134            ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
8135        },
8136    }
8137}
8138
8139/// The request as it is hashed and signed. A staged push carries its operations
8140/// by address rather than inline, so the signed view restores them: the request
8141/// hash covers the change itself, never the transport that carried it.
8142fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
8143    if body.get("operations").is_some() {
8144        return body.clone();
8145    }
8146    let mut value = body.clone();
8147    if let Some(map) = value.as_object_mut() {
8148        map.remove("staged_change");
8149        map.insert("operations".to_string(), Value::Array(operations.to_vec()));
8150    }
8151    value
8152}
8153
8154/// Open one staging window, waiting out a busy hub rather than abandoning the
8155/// push. The call reserves capacity and mints URLs; it commits nothing, so
8156/// asking again after a refusal costs only the wait.
8157fn reserve_upload_window(
8158    cfg: &HubConfig,
8159    path: &str,
8160    body: &Value,
8161    what: &'static str,
8162) -> LinkResult<Value> {
8163    let mut attempt = 0;
8164    loop {
8165        let pause = |attempt: usize| {
8166            std::thread::sleep(std::time::Duration::from_millis(
8167                RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
8168            ));
8169        };
8170        match request(cfg, "POST", path, Some(body), Auth::Required) {
8171            // A migration opens dozens of windows over tens of minutes, so one
8172            // response that never arrives is ordinary rather than final. The
8173            // call reserves capacity and mints URLs; it commits nothing, so a
8174            // dropped or timed-out attempt is worth asking again.
8175            Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
8176                pause(attempt);
8177                attempt += 1;
8178            }
8179            Err(error) => return Err(error),
8180            Ok(response) => {
8181                if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
8182                    pause(attempt);
8183                    attempt += 1;
8184                    continue;
8185                }
8186                return ensure_ok(response, what);
8187            }
8188        }
8189    }
8190}
8191
8192/// The exact bytes of a staged change. The hub refuses a manifest carrying any
8193/// other key, so this is the whole contract: operations, and the blob
8194/// references they need, and nothing else.
8195fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
8196    let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
8197        .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
8198    if bytes.len() > MAX_STAGED_CHANGE_BYTES {
8199        return Err(LinkError::PushTooLarge {
8200            detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
8201        });
8202    }
8203    Ok(bytes)
8204}
8205
8206/// State one change's operations and blob references through the same
8207/// content-addressed lane its bytes already ride.
8208///
8209/// A first push of a real store names one operation and one blob reference per
8210/// file, which alone outgrows the bounded commit request. Staging that metadata
8211/// and addressing it by hash keeps the push a single atomic commit: the hub
8212/// verifies the address, splices the manifest in, and validates exactly what it
8213/// would have validated inline. The alternative — many partial commits — would
8214/// make a failed migration leave a half-populated brain readable.
8215fn stage_v2_change(
8216    cfg: &HubConfig,
8217    requested_brain: &str,
8218    operations: &[Value],
8219    blobs: Value,
8220) -> LinkResult<Value> {
8221    let bytes = v2_change_manifest(operations, blobs)?;
8222    let sha256 = content_sha256(&bytes);
8223    let reserved = reserve_upload_window(
8224        cfg,
8225        &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8226        &json!({
8227            "blobs": [{
8228                "sha256": sha256,
8229                "bytes": bytes.len(),
8230                "kind": "staged_change",
8231            }],
8232        }),
8233        "stage the v2 change",
8234    )?;
8235    let items = reserved
8236        .get("uploads")
8237        .and_then(Value::as_array)
8238        .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
8239    let [item] = items.as_slice() else {
8240        return Err(invalid_feed(
8241            "v2 change staging response changed the requested set",
8242        ));
8243    };
8244    let reservation_id = item
8245        .get("reservation_id")
8246        .and_then(Value::as_str)
8247        .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
8248    if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
8249        || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
8250        || !crate::ulid::is_ulid(reservation_id)
8251    {
8252        return Err(invalid_feed("v2 change staging item is inconsistent"));
8253    }
8254    match item.get("status").and_then(Value::as_str) {
8255        Some("upload") => put_presigned(
8256            cfg,
8257            item.get("url")
8258                .and_then(Value::as_str)
8259                .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
8260            item.get("headers").unwrap_or(&Value::Null),
8261            &bytes,
8262        )?,
8263        Some("already_present") => {}
8264        _ => return Err(invalid_feed("v2 change staging has an unknown status")),
8265    }
8266    Ok(json!({
8267        "sha256": sha256,
8268        "bytes": bytes.len(),
8269        "reservation_id": reservation_id,
8270    }))
8271}
8272
8273/// Move a commit request's operations and blob references off the wire body
8274/// when they no longer fit in it. Idempotent by construction: the mutation ID
8275/// is derived from the change, never from how the change was transported.
8276fn stage_oversized_v2_change(
8277    cfg: &HubConfig,
8278    requested_brain: &str,
8279    operations: &[Value],
8280    body: &mut Value,
8281) -> LinkResult<()> {
8282    if body.to_string().len() <= MAX_PUSH_BYTES {
8283        return Ok(());
8284    }
8285    let staged = stage_v2_change(
8286        cfg,
8287        requested_brain,
8288        operations,
8289        body.get("blobs")
8290            .cloned()
8291            .unwrap_or(Value::Array(Vec::new())),
8292    )?;
8293    let map = body
8294        .as_object_mut()
8295        .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
8296    map.remove("operations");
8297    map.remove("blobs");
8298    map.insert("staged_change".to_string(), staged);
8299    Ok(())
8300}
8301
8302fn v2_sync_push(
8303    cfg: &HubConfig,
8304    requested_brain: &str,
8305    store: &Store,
8306    head: V2VerifiedHead,
8307    options: V2SyncPushOptions<'_>,
8308) -> LinkResult<Value> {
8309    let V2SyncPushOptions {
8310        resume_local_policy,
8311        bulk_confirmation,
8312        resolution,
8313        pulled,
8314        withdrawal_paths,
8315        withdrawal_reason,
8316        allow_contract_phase,
8317    } = options;
8318    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
8319    let head = v2_verified_head(cfg, requested_brain)?
8320        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
8321    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
8322    ensure_v2_view_compatible(&head, baseline.as_ref())?;
8323    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
8324        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
8325            Some(snapshot) => (
8326                snapshot.files,
8327                snapshot.assets,
8328                Some(snapshot.local),
8329                Some(snapshot.local_assets),
8330            ),
8331            None => match baseline
8332                .as_ref()
8333                .filter(|state| v2_baseline_matches_head(&head, state))
8334            {
8335                Some(state) => (state.files.clone(), state.assets.clone(), None, None),
8336                None => (
8337                    files_for_v2_view(
8338                        &head,
8339                        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
8340                    ),
8341                    v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
8342                    None,
8343                    None,
8344                ),
8345            },
8346        };
8347    if head.view_kind == "scoped" && baseline.is_none() {
8348        return Err(LinkError::ScopedViewChanged);
8349    }
8350    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
8351    let local = &local_view.riding;
8352    let local_assets = match carried_local_assets {
8353        Some(assets) => assets,
8354        None => v2_local_asset_records(store)?,
8355    };
8356    if withdrawal_paths.len() > MAX_PUSH_FILES {
8357        return Err(LinkError::PushTooLarge {
8358            detail: "too many explicit withdrawal paths".to_string(),
8359        });
8360    }
8361    let withdrawal_reason = if withdrawal_paths.is_empty() {
8362        None
8363    } else {
8364        let reason = withdrawal_reason
8365            .map(str::trim)
8366            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
8367            .ok_or_else(|| LinkError::InvalidPack {
8368                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
8369            })?;
8370        Some(reason)
8371    };
8372    let mut withdrawals = withdrawal_paths
8373        .iter()
8374        .map(|path| {
8375            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
8376                path: error.to_string(),
8377            })
8378        })
8379        .collect::<LinkResult<Vec<_>>>()?;
8380    withdrawals.sort();
8381    withdrawals.dedup();
8382    if withdrawals.len() != withdrawal_paths.len() {
8383        return Err(LinkError::InvalidPack {
8384            message: "explicit withdrawal paths must be unique".to_string(),
8385        });
8386    }
8387    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
8388    let mut consumed_withdrawals = BTreeSet::new();
8389    if let Some(previous) = baseline.as_ref() {
8390        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
8391            && !resume_local_policy
8392        {
8393            let mut newly_eligible = previous
8394                .local_eligibility
8395                .iter()
8396                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
8397                .map(|(path, _)| path.clone())
8398                .collect::<Vec<_>>();
8399            if !newly_eligible.is_empty() {
8400                newly_eligible.truncate(100);
8401                return Err(LinkError::LocalPolicyTransition {
8402                    paths: newly_eligible,
8403                });
8404            }
8405        }
8406    }
8407    // An accepted commit can outlive the response path that would have saved
8408    // its local baseline. If the complete current riding tree and asset set
8409    // already match the independently verified new head, fast-forward the
8410    // private baseline by proof. This is not force and cannot hide a conflict:
8411    // any differing path falls through to the ordinary three-way algorithm.
8412    if baseline
8413        .as_ref()
8414        .is_some_and(|state| !v2_baseline_matches_head(&head, state))
8415        && resolution.is_none()
8416        && withdrawal_paths.is_empty()
8417        && v2_riding_matches_remote(local, &remote, |path| local_view.policy.keeps_home(path))
8418        && v2_asset_records_match_remote(&local_assets, &remote_assets)
8419    {
8420        let final_head = v2_verified_head(cfg, requested_brain)?
8421            .ok_or_else(|| invalid_feed("v2 head disappeared during baseline recovery"))?;
8422        if !same_v2_head(&head, &final_head) {
8423            return Err(LinkError::RemoteAdvancedDuringSync);
8424        }
8425        let mut final_local = v2_local_files_cached(
8426            store,
8427            Some((&local_view.policy.digest, &local_view.scan_cache)),
8428        )?;
8429        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8430        let final_assets = v2_local_asset_records(store)?;
8431        if final_local.riding != local_view.riding || final_assets != local_assets {
8432            return Err(LinkError::RemoteAdvancedDuringSync);
8433        }
8434        let checkout_pseudonym = v2_checkout_id(
8435            baseline
8436                .as_ref()
8437                .and_then(|current| current.checkout_id.as_deref()),
8438        )?;
8439        let next = v2_baseline_from_head(
8440            cfg,
8441            &head,
8442            remote,
8443            remote_assets,
8444            Some(&final_local),
8445            Some(&checkout_pseudonym),
8446        )?;
8447        let split_count = next.remote_copy_remains.len();
8448        accept_v2_head(cfg, &final_head)?;
8449        refresh_scoped_view_marker(store, &head, next.files.len())?;
8450        save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8451        return Ok(json!({
8452            "v": 2,
8453            "outcome": "no_change",
8454            "sync_status": "synced",
8455            "baseline_recovered": true,
8456            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8457            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8458            "local_policy": {
8459                "remote_copy_remains": split_count,
8460            },
8461        }));
8462    }
8463    let base = match baseline.as_ref() {
8464        Some(state) => &state.files,
8465        None if remote.is_empty() => &remote,
8466        None => {
8467            let mut conflicts = v2_initial_content_conflicts(local, &remote, resolution.is_some());
8468            if !conflicts.is_empty() {
8469                conflicts.truncate(100);
8470                let (bundle, paths) =
8471                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
8472                return Err(LinkError::ConflictBundle { bundle, paths });
8473            }
8474            &remote
8475        }
8476    };
8477    let all_paths = base
8478        .keys()
8479        .chain(remote.keys())
8480        .chain(local.keys())
8481        .cloned()
8482        .collect::<std::collections::BTreeSet<_>>();
8483    let mut conflicts = Vec::new();
8484    let mut operations = Vec::new();
8485    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
8486    for path in all_paths {
8487        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
8488        let remote_file = remote.get(&path);
8489        let remote_hash = remote_file.map(|file| file.sha256.as_str());
8490        let local_file = local.get(&path);
8491        let local_hash = local_file.map(|file| file.0.as_str());
8492        // A remote change that already equals the exact local intent is
8493        // converged, even when the private baseline is older. Emitting a
8494        // redundant put here can turn a completed DB.md contract phase back
8495        // into another mixed contract/content mutation.
8496        if local_hash == remote_hash {
8497            continue;
8498        }
8499        if local_hash == base_hash {
8500            continue;
8501        }
8502        if !v2_resolution_allows_path(resolution, &path, remote_file.is_some()) {
8503            continue;
8504        }
8505        if local_view.policy.keeps_home(&path) {
8506            // Kept-home is a local transfer exclusion, never an implicit
8507            // delete of the company's already-hosted coordinate.
8508            continue;
8509        }
8510        if remote_hash != base_hash && local_hash != remote_hash {
8511            let explicitly_resolved = resolution
8512                .and_then(|allowed| allowed.get(&path))
8513                .is_some_and(|selected| {
8514                    selected.expected_remote.as_deref() == remote_hash
8515                        && selected.selected_local.as_deref() == local_hash
8516                });
8517            if !explicitly_resolved {
8518                conflicts.push(path);
8519                continue;
8520            }
8521        }
8522        match local_file {
8523            Some((sha256, byte_count)) => {
8524                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
8525                operations.push(json!({
8526                    "op": v2_content_put_operation_kind(&path, &local_assets),
8527                    "path": path,
8528                    "expected": v2_expected(remote_file),
8529                    "blob": sha256,
8530                    "bytes": byte_count,
8531                }));
8532                upload_sources
8533                    .entry(sha256.clone())
8534                    .or_insert_with(|| V2UploadSource {
8535                        path: path.clone(),
8536                        bytes: *byte_count,
8537                    });
8538            }
8539            None => {
8540                let Some(current) = remote_file else {
8541                    continue;
8542                };
8543                operations.push(json!({
8544                    "op": "delete",
8545                    "path": path,
8546                    "expected": { "kind": "blob", "hash": current.sha256 },
8547                }));
8548            }
8549        }
8550    }
8551    operations = infer_exact_source_promotions(operations);
8552    for path in &withdrawals {
8553        if !v2_withdrawal_includes_content(path, &local_assets) {
8554            continue;
8555        }
8556        operations.push(v2_content_withdrawal_operation(
8557            store,
8558            &local_view,
8559            &remote,
8560            path,
8561            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8562        )?);
8563        consumed_withdrawals.insert(path.clone());
8564    }
8565    if !conflicts.is_empty() {
8566        conflicts.truncate(100);
8567        let (bundle, paths) = create_v2_conflict_bundle(
8568            cfg,
8569            store,
8570            &head,
8571            baseline.as_ref(),
8572            local,
8573            &remote,
8574            &conflicts,
8575        )?;
8576        return Err(LinkError::ConflictBundle { bundle, paths });
8577    }
8578    let base_assets = match baseline.as_ref() {
8579        Some(state) => &state.assets,
8580        None if remote_assets.is_empty() => &remote_assets,
8581        None => {
8582            let mismatched = remote_assets
8583                .keys()
8584                .chain(local_assets.keys())
8585                .collect::<BTreeSet<_>>()
8586                .into_iter()
8587                .filter(|path| {
8588                    remote_assets
8589                        .get(*path)
8590                        .map(|asset| v2_asset_record(asset, path))
8591                        .as_ref()
8592                        != local_assets.get(*path)
8593                })
8594                .collect::<Vec<_>>();
8595            let resolution_covers_all = !mismatched.is_empty()
8596                && mismatched.iter().all(|path| {
8597                    v2_resolution_allows_asset(
8598                        resolution,
8599                        None,
8600                        remote_assets.get(*path),
8601                        local_assets.get(*path),
8602                    )
8603                });
8604            if !mismatched.is_empty() && !resolution_covers_all {
8605                return Err(LinkError::Conflict {
8606                    paths: vec!["assets.jsonl".to_string()],
8607                });
8608            }
8609            &remote_assets
8610        }
8611    };
8612    let asset_paths = base_assets
8613        .keys()
8614        .chain(remote_assets.keys())
8615        .chain(local_assets.keys())
8616        .cloned()
8617        .collect::<std::collections::BTreeSet<_>>();
8618    let mut asset_policy_transitions = Vec::new();
8619    let mut asset_withdrawal_transitions = Vec::new();
8620    for path in asset_paths {
8621        let base_record = base_assets
8622            .get(&path)
8623            .map(|asset| v2_asset_record(asset, &path));
8624        let remote = remote_assets.get(&path);
8625        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
8626        let local_record = local_assets.get(&path);
8627        if withdrawal_set.contains(&path) {
8628            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
8629                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
8630            })?;
8631            let current = remote.ok_or_else(|| LinkError::InvalidPack {
8632                message: format!(
8633                    "asset withdrawal path `{path}` has no readable hosted coordinate"
8634                ),
8635            })?;
8636            operations.push(v2_asset_withdrawal_operation(
8637                store,
8638                &local_view,
8639                &path,
8640                record,
8641                current,
8642                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
8643            )?);
8644            consumed_withdrawals.insert(path.clone());
8645            continue;
8646        }
8647        let mut raw_present = false;
8648        let mut disposition = "withheld";
8649        let mut resumes_hosting = false;
8650        if let Some(record) = local_record {
8651            crate::linkmd_v2::normalize_path(&record.path)
8652                .map_err(|error| invalid_feed(error.to_string()))?;
8653            let kept_home = local_view.policy.keeps_home(&path);
8654            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
8655            disposition = if kept_home || !raw_present {
8656                "withheld"
8657            } else {
8658                "hosted"
8659            };
8660            let inherits_withheld_absence = v2_asset_inherits_withheld_absence(
8661                base_assets.get(&path),
8662                base_record.as_ref(),
8663                local_record,
8664                raw_present,
8665            );
8666            if !raw_present && record.required && !kept_home && !inherits_withheld_absence {
8667                return Err(LinkError::InvalidPack {
8668                    message: format!("required asset {path} is missing"),
8669                });
8670            }
8671            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
8672            if remote.is_some_and(|asset| asset.disposition == "hosted")
8673                && disposition == "withheld"
8674            {
8675                asset_withdrawal_transitions.push(path.clone());
8676                continue;
8677            }
8678        }
8679        if local_record == base_record.as_ref() && !resumes_hosting {
8680            continue;
8681        }
8682        if remote_record != base_record
8683            && local_record != remote_record.as_ref()
8684            && !v2_resolution_allows_asset(resolution, base_assets.get(&path), remote, local_record)
8685        {
8686            conflicts.push(path);
8687            continue;
8688        }
8689        let Some(record) = local_record else {
8690            if let Some(remote) = remote {
8691                operations.push(json!({
8692                    "op": "asset_delete",
8693                    "path": path,
8694                    "expected": v2_asset_expected(Some(remote)),
8695                }));
8696            }
8697            continue;
8698        };
8699        let raw = if raw_present {
8700            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
8701            Some(())
8702        } else {
8703            None
8704        };
8705        let op = if resumes_hosting {
8706            if !resume_local_policy {
8707                asset_policy_transitions.push(path);
8708                continue;
8709            }
8710            "asset_resume"
8711        } else {
8712            "asset_put"
8713        };
8714        operations.push(json!({
8715            "op": op,
8716            "path": path,
8717            "expected": v2_asset_expected(remote),
8718            "asset": v2_asset_value(record, disposition),
8719        }));
8720        if disposition == "hosted" {
8721            raw.expect("hosted asset was checked present");
8722            upload_sources
8723                .entry(record.sha256.clone())
8724                .or_insert_with(|| V2UploadSource {
8725                    path: path.clone(),
8726                    bytes: record.bytes,
8727                });
8728        }
8729    }
8730    if consumed_withdrawals != withdrawal_set {
8731        let missing = withdrawal_set
8732            .difference(&consumed_withdrawals)
8733            .next()
8734            .expect("different withdrawal sets have one member");
8735        return Err(LinkError::InvalidPack {
8736            message: format!(
8737                "withdrawal path `{missing}` is not a readable content or asset coordinate"
8738            ),
8739        });
8740    }
8741    if !conflicts.is_empty() {
8742        conflicts.truncate(100);
8743        return Err(LinkError::Conflict { paths: conflicts });
8744    }
8745    if !asset_policy_transitions.is_empty() {
8746        asset_policy_transitions.truncate(100);
8747        return Err(LinkError::LocalPolicyTransition {
8748            paths: asset_policy_transitions,
8749        });
8750    }
8751    if !asset_withdrawal_transitions.is_empty() {
8752        asset_withdrawal_transitions.truncate(100);
8753        return Err(LinkError::AssetWithdrawalRequired {
8754            paths: asset_withdrawal_transitions,
8755        });
8756    }
8757    let contract_phase = operations.len() > 1
8758        && operations.iter().any(|operation| {
8759            operation.get("path").and_then(Value::as_str) == Some("DB.md")
8760                && !operation
8761                    .get("op")
8762                    .and_then(Value::as_str)
8763                    .is_some_and(|kind| kind.starts_with("asset_"))
8764        });
8765    if contract_phase {
8766        if !allow_contract_phase {
8767            return Err(LinkError::RemoteAdvancedDuringSync);
8768        }
8769        operations.retain(|operation| {
8770            operation.get("path").and_then(Value::as_str) == Some("DB.md")
8771                && !operation
8772                    .get("op")
8773                    .and_then(Value::as_str)
8774                    .is_some_and(|kind| kind.starts_with("asset_"))
8775        });
8776        let contract_blobs = operations
8777            .iter()
8778            .filter_map(|operation| operation.get("blob").and_then(Value::as_str))
8779            .collect::<BTreeSet<_>>();
8780        upload_sources.retain(|sha256, _| contract_blobs.contains(sha256.as_str()));
8781    }
8782    let touched_sources = operations
8783        .iter()
8784        .filter_map(
8785            |operation| match operation.get("op").and_then(Value::as_str) {
8786                Some("put" | "put_asset_content" | "restore") => {
8787                    operation.get("path").and_then(Value::as_str)
8788                }
8789                Some("rename") => operation.get("to").and_then(Value::as_str),
8790                _ => None,
8791            },
8792        )
8793        .collect::<std::collections::BTreeSet<_>>();
8794    let withheld_links = local_view
8795        .withheld_links
8796        .iter()
8797        .filter(|link| touched_sources.contains(link.source.as_str()))
8798        .collect::<Vec<_>>();
8799    let checkout_pseudonym = v2_checkout_id(
8800        baseline
8801            .as_ref()
8802            .and_then(|current| current.checkout_id.as_deref()),
8803    )?;
8804    let checkout_id = if withheld_links.is_empty() {
8805        None
8806    } else {
8807        Some(checkout_pseudonym.clone())
8808    };
8809    if operations.is_empty() {
8810        let final_head = v2_verified_head(cfg, requested_brain)?
8811            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
8812        if !same_v2_head(&head, &final_head) {
8813            return Err(LinkError::RemoteAdvancedDuringSync);
8814        }
8815        let mut final_local = v2_local_files_cached(
8816            store,
8817            Some((&local_view.policy.digest, &local_view.scan_cache)),
8818        )?;
8819        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
8820        let final_assets = v2_local_asset_records(store)?;
8821        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
8822        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
8823            final_local.policy.keeps_home(path)
8824        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
8825        let next = v2_baseline_from_head(
8826            cfg,
8827            &head,
8828            remote,
8829            remote_assets,
8830            Some(&final_local),
8831            Some(&checkout_pseudonym),
8832        )?;
8833        let split_count = next.remote_copy_remains.len();
8834        accept_v2_head(cfg, &final_head)?;
8835        if !remote_ahead {
8836            refresh_scoped_view_marker(store, &head, next.files.len())?;
8837            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
8838        }
8839        return Ok(json!({
8840            "v": 2,
8841            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
8842            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
8843            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
8844            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
8845            "local_policy": {
8846                "remote_copy_remains": split_count,
8847            },
8848        }));
8849    }
8850    let includes_contract = operations
8851        .iter()
8852        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
8853    let rebase = if head.pointer.is_none() || includes_contract {
8854        "strict"
8855    } else {
8856        "disjoint"
8857    };
8858    let base_value = head.pointer.as_ref().map(|pointer| {
8859        json!({
8860            "seq": pointer.seq,
8861            "commit_hash": pointer.commit_hash,
8862            "content_root": pointer.content_root,
8863            "asset_root": pointer.asset_root,
8864        })
8865    });
8866    // The mutation coordinate is stable for the exact observed base and local
8867    // operation set. A lost response can therefore be retried without a
8868    // duplicate commit/proposal even across process restarts.
8869    let entropy = format!(
8870        "{}\0{}\0{}\0{}\0{}\0{}",
8871        normalized_origin(&cfg.hub)?,
8872        head.brain_id,
8873        serde_json::to_string(&base_value).unwrap_or_default(),
8874        serde_json::to_string(&operations).unwrap_or_default(),
8875        serde_json::to_string(&withheld_links).unwrap_or_default(),
8876        checkout_id.as_deref().unwrap_or("")
8877    );
8878    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
8879    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
8880        total
8881            .checked_add(source.bytes)
8882            .ok_or_else(|| LinkError::PushTooLarge {
8883                detail: "v2 changed-byte total overflow".to_string(),
8884            })
8885    })?;
8886    let inline = changed_bytes <= 3 * 1024 * 1024;
8887    let inline_blobs = if inline {
8888        upload_sources
8889            .iter()
8890            .map(|(sha256, source)| {
8891                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
8892                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
8893                    return Err(LinkError::InvalidPack {
8894                        message: format!("local path `{}` changed before upload", source.path),
8895                    });
8896                }
8897                Ok(json!({
8898                    "sha256": sha256,
8899                    "bytes": source.bytes,
8900                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
8901                }))
8902            })
8903            .collect::<LinkResult<Vec<_>>>()?
8904    } else {
8905        Vec::new()
8906    };
8907    let mut body = json!({
8908        "mutation_id": mutation_id,
8909        "base": base_value,
8910        "rebase": rebase,
8911        "reason": "dbmd sync",
8912        "operations": operations,
8913        "blobs": inline_blobs,
8914    });
8915    if !withheld_links.is_empty() {
8916        body["withheld_links"] = serde_json::to_value(&withheld_links)
8917            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
8918        body["checkout_id"] =
8919            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
8920    }
8921    if let Some(confirmation) = bulk_confirmation {
8922        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
8923            return Err(LinkError::InvalidPack {
8924                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
8925                    .to_string(),
8926            });
8927        }
8928        // A preview is bound to the exact observed head. Confirmation can
8929        // therefore never use disjoint rebase semantics, even when the normal
8930        // small-mutation path could.
8931        body["rebase"] = Value::String("strict".to_string());
8932        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8933        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8934    }
8935    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8936        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8937        for operation in &operations {
8938            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8939                return Err(invalid_feed("v2 upload operation has no kind"));
8940            };
8941            let hash = match kind {
8942                "put" | "put_asset_content" | "restore" | "rename" => {
8943                    operation.get("blob").and_then(Value::as_str)
8944                }
8945                "asset_put" | "asset_resume" => operation
8946                    .get("asset")
8947                    .and_then(|asset| asset.get("blob_sha256"))
8948                    .and_then(Value::as_str),
8949                _ => None,
8950            };
8951            let Some(hash) = hash else { continue };
8952            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8953            if kind == "rename" {
8954                for field in ["from", "to"] {
8955                    coordinates.insert(
8956                        operation
8957                            .get(field)
8958                            .and_then(Value::as_str)
8959                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8960                            .to_string(),
8961                    );
8962                }
8963            } else {
8964                let path = operation
8965                    .get("path")
8966                    .and_then(Value::as_str)
8967                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8968                coordinates.insert(if kind.starts_with("asset_") {
8969                    format!("assets/{path}")
8970                } else {
8971                    path.to_string()
8972                });
8973            }
8974        }
8975        let declarations = upload_sources
8976            .iter()
8977            .map(|(sha256, source)| {
8978                json!({
8979                    "sha256": sha256,
8980                    "bytes": source.bytes,
8981                    "coordinates": coordinates_by_hash
8982                        .get(sha256)
8983                        .into_iter()
8984                        .flatten()
8985                        .collect::<Vec<_>>(),
8986                })
8987            })
8988            .collect::<Vec<_>>();
8989        let mut references = Vec::with_capacity(upload_sources.len());
8990        let mut seen = std::collections::BTreeSet::new();
8991        let mut reserved_count = 0usize;
8992        let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8993        // A reservation's presigned URL is short-lived, so each batch's bytes
8994        // ride before the next batch is reserved. Reserving every blob first
8995        // and uploading afterwards expires the earliest URLs on a real store.
8996        for batch in batch_upload_declarations(declarations) {
8997            let batch_len = batch.len();
8998            let reserved = reserve_upload_window(
8999                cfg,
9000                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
9001                &json!({ "blobs": batch }),
9002                "prepare v2 changed-byte uploads",
9003            )?;
9004            let items = reserved
9005                .get("uploads")
9006                .and_then(Value::as_array)
9007                .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
9008            if items.len() != batch_len {
9009                return Err(invalid_feed(
9010                    "v2 upload reservation response changed the requested set",
9011                ));
9012            }
9013            reserved_count += items.len();
9014            for item in items {
9015                let sha256 = item
9016                    .get("sha256")
9017                    .and_then(Value::as_str)
9018                    .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
9019                let source = upload_sources
9020                    .get(sha256)
9021                    .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
9022                let declared_bytes = item
9023                    .get("bytes")
9024                    .and_then(Value::as_u64)
9025                    .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
9026                let reservation_id = item
9027                    .get("reservation_id")
9028                    .and_then(Value::as_str)
9029                    .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
9030                let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
9031                    invalid_feed("v2 upload reservation has no coordinate binding")
9032                })?;
9033                let returned_coordinates = item
9034                    .get("coordinates")
9035                    .and_then(Value::as_array)
9036                    .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
9037                if declared_bytes != source.bytes
9038                    || !crate::ulid::is_ulid(reservation_id)
9039                    || !seen.insert(sha256.to_string())
9040                    || returned_coordinates.len() != expected_coordinates.len()
9041                    || returned_coordinates
9042                        .iter()
9043                        .zip(expected_coordinates)
9044                        .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
9045                {
9046                    return Err(invalid_feed("v2 upload reservation item is inconsistent"));
9047                }
9048                match item.get("status").and_then(Value::as_str) {
9049                    Some("upload") => {
9050                        let url = item
9051                            .get("url")
9052                            .and_then(Value::as_str)
9053                            .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
9054                        pending_uploads.push(V2PendingUpload {
9055                            url: url.to_string(),
9056                            headers: item.get("headers").cloned().unwrap_or(Value::Null),
9057                            sha256: sha256.to_string(),
9058                            source,
9059                        });
9060                    }
9061                    Some("already_present") => {}
9062                    _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
9063                }
9064                references.push(json!({
9065                    "sha256": sha256,
9066                    "bytes": source.bytes,
9067                    "reservation_id": reservation_id,
9068                }));
9069            }
9070            // Stage this batch's bytes concurrently. Sequential PUTs move a real
9071            // store at a few objects a second, which both takes hours and pushes
9072            // the batch past its reservation's upload window; the object store
9073            // is content-addressed and precondition-guarded, so parallel writes
9074            // stay safe.
9075            upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
9076            pending_uploads.clear();
9077        }
9078        if reserved_count != upload_sources.len() {
9079            return Err(invalid_feed(
9080                "v2 upload reservation response changed the requested set",
9081            ));
9082        }
9083        body["blobs"] = Value::Array(references);
9084    }
9085    stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
9086    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
9087    let mut candidate_hub_signer: Option<String> = None;
9088    let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
9089    let bulk_preview_required = !(200..300).contains(&response.status)
9090        && response.body.as_ref().is_some_and(|value| {
9091            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
9092                || value
9093                    .get("details")
9094                    .and_then(|details| details.get("code"))
9095                    .and_then(Value::as_str)
9096                    == Some("bulk_preview_required")
9097        });
9098    if bulk_preview_required && bulk_confirmation.is_none() {
9099        body["rebase"] = Value::String("strict".to_string());
9100        body["preview_only"] = Value::Bool(true);
9101        let preview = ensure_ok(
9102            request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
9103            "v2 bulk preview",
9104        )?;
9105        let preview_code = preview.get("code").and_then(Value::as_str);
9106        let required = preview.get("required").and_then(Value::as_bool);
9107        if preview.get("v").and_then(Value::as_u64) != Some(2)
9108            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
9109            || !matches!(
9110                preview_code,
9111                Some("bulk_preview_created" | "bulk_preview_not_required")
9112            )
9113            || required.is_none()
9114        {
9115            return Err(invalid_feed(
9116                "bulk preview response is not bound to the requested mutation",
9117            ));
9118        }
9119        if required == Some(true) {
9120            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
9121            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
9122            if preview_code != Some("bulk_preview_created")
9123                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
9124                || preview_digest.is_none_or(|value| !is_sha256(value))
9125                || preview.get("expires_at").and_then(Value::as_str).is_none()
9126                || !preview.get("impact").is_some_and(Value::is_object)
9127            {
9128                return Err(invalid_feed("bulk preview receipt is malformed"));
9129            }
9130            return Err(LinkError::BulkPreviewRequired { preview });
9131        }
9132        if preview_code != Some("bulk_preview_not_required") {
9133            return Err(invalid_feed("bulk preview requirement is inconsistent"));
9134        }
9135        // The rolling activity window can expire between the refusal and
9136        // preview. Retry once, still pinned strictly to the observed head.
9137        body.as_object_mut()
9138            .expect("v2 commit request is an object")
9139            .remove("preview_only");
9140        response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
9141    }
9142    let mut result = ensure_ok(response, "v2 sync push")?;
9143    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
9144        if let Some(object) = result.as_object_mut() {
9145            object.insert(
9146                "sync_status".to_string(),
9147                Value::String("proposal_pending".to_string()),
9148            );
9149        }
9150        return Ok(result);
9151    }
9152    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
9153        let request_id = result
9154            .get("request_id")
9155            .and_then(Value::as_str)
9156            .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
9157            .to_string();
9158        let challenge = result
9159            .get("signing_challenge")
9160            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
9161        let mut expected_candidate = remote.clone();
9162        let mut expected_candidate_assets = remote_assets.clone();
9163        apply_generated_v2_operations(
9164            &operations,
9165            &local_assets,
9166            &mut expected_candidate,
9167            &mut expected_candidate_assets,
9168        )?;
9169        verify_v2_markdown_asset_content_bindings(&expected_candidate, &expected_candidate_assets)?;
9170        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
9171            cfg,
9172            &head,
9173            &expected_candidate,
9174            &expected_candidate_assets,
9175            &mutation_id,
9176            &v2_signed_request_view(&body, &operations),
9177            challenge,
9178        )?;
9179        body["signing_challenge_id"] = Value::String(challenge_id);
9180        body["signature_base64url"] = Value::String(signature);
9181        candidate_hub_signer = Some(actor_signer);
9182        result = ensure_ok(
9183            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
9184            "v2 self-custody commit",
9185        )?;
9186    }
9187    let refreshed = v2_verified_head(cfg, requested_brain)?
9188        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
9189    if candidate_hub_signer
9190        .as_ref()
9191        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
9192    {
9193        return Err(invalid_feed(
9194            "self-custody actor signer differs from the committed hub pointer signer",
9195        ));
9196    }
9197    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
9198    if refreshed
9199        .pointer
9200        .as_ref()
9201        .map(|pointer| pointer.commit_hash.as_str())
9202        != accepted_hash
9203    {
9204        return Err(LinkError::RemoteAdvancedDuringSync);
9205    }
9206    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
9207    let rebased = result
9208        .get("rebased")
9209        .and_then(Value::as_bool)
9210        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
9211    let (refreshed_files, refreshed_assets) = if rebased {
9212        (
9213            files_for_v2_view(
9214                &refreshed,
9215                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
9216            ),
9217            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
9218        )
9219    } else {
9220        let asset_changed = apply_generated_v2_operations(
9221            &operations,
9222            &local_assets,
9223            &mut remote,
9224            &mut remote_assets,
9225        )?;
9226        let assets = if asset_changed {
9227            // Asset leaf hashes are server-generated signed coordinates; only
9228            // asset-changing commits need this usually tiny post-commit read.
9229            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
9230        } else {
9231            remote_assets
9232        };
9233        (remote, assets)
9234    };
9235    verify_v2_markdown_asset_content_bindings(&refreshed_files, &refreshed_assets)?;
9236    let mut final_local = v2_local_files_cached(
9237        store,
9238        Some((&local_view.policy.digest, &local_view.scan_cache)),
9239    )?;
9240    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
9241    let final_assets = v2_local_asset_records(store)?;
9242    let local_dirty = final_local.riding != local_view.riding
9243        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
9244            final_local.policy.keeps_home(path)
9245        })
9246        || final_assets != local_assets
9247        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
9248    // A required DB.md-only first phase can hand its already verified remote
9249    // manifests and local stat/hash view directly to the second phase. This is
9250    // an in-memory optimization only; v2_sync_push re-verifies the hosted head
9251    // under the operation lock, upload sources are re-opened before use, and
9252    // the ordinary final scan still detects concurrent local edits.
9253    let contract_handoff = contract_phase.then(|| V2PulledSnapshot {
9254        report: PullReport {
9255            brain: refreshed.brain_id.clone(),
9256            slug: requested_brain.to_string(),
9257            head_seq: refreshed.pointer.as_ref().map_or(0, |pointer| pointer.seq),
9258            files: refreshed_files.len(),
9259            dest: store.root.to_string_lossy().into_owned(),
9260            extra_local: Vec::new(),
9261            sync_status: "contract_phase".to_string(),
9262        },
9263        head: refreshed.clone(),
9264        files: refreshed_files.clone(),
9265        assets: refreshed_assets.clone(),
9266        local: final_local.clone(),
9267        local_assets: final_assets.clone(),
9268    });
9269    let next = v2_baseline_from_head(
9270        cfg,
9271        &refreshed,
9272        refreshed_files,
9273        refreshed_assets,
9274        Some(&final_local),
9275        Some(&checkout_pseudonym),
9276    )?;
9277    let split_count = next.remote_copy_remains.len();
9278    accept_v2_head(cfg, &refreshed)?;
9279    if !local_dirty {
9280        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
9281    }
9282    // The verified accepted head remains the correct three-way base when a
9283    // local writer races the final scan. v2_baseline_from_head kept cache
9284    // entries only for exact local/remote matches, so saving here neither
9285    // hides those edits nor trusts their stale hashes.
9286    save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
9287    if let Some(object) = result.as_object_mut() {
9288        object.insert(
9289            "local_policy".to_string(),
9290            json!({ "remote_copy_remains": split_count }),
9291        );
9292        object.insert(
9293            "sync_status".to_string(),
9294            Value::String(if local_dirty {
9295                "remote_committed_local_dirty".to_string()
9296            } else {
9297                "synced".to_string()
9298            }),
9299        );
9300    }
9301    if contract_phase && result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9302        let contract_receipt = result;
9303        // The hub deliberately requires DB.md authority changes to be a sole,
9304        // strict-head mutation. Release the checkout-wide operation lock, then
9305        // recompute the remaining delta from the independently verified new
9306        // head. No local byte or permission conclusion is carried across.
9307        drop(_operation_lock);
9308        let mut remaining = v2_sync_push(
9309            cfg,
9310            requested_brain,
9311            store,
9312            refreshed,
9313            V2SyncPushOptions {
9314                resume_local_policy,
9315                bulk_confirmation,
9316                resolution,
9317                pulled: contract_handoff,
9318                withdrawal_paths,
9319                withdrawal_reason,
9320                allow_contract_phase: false,
9321            },
9322        )?;
9323        if let Some(object) = remaining.as_object_mut() {
9324            object.insert("contract_phase".to_string(), contract_receipt);
9325        }
9326        return Ok(remaining);
9327    }
9328    if contract_phase {
9329        if let Some(object) = result.as_object_mut() {
9330            object.insert("contract_phase_pending".to_string(), Value::Bool(true));
9331        }
9332    }
9333    Ok(result)
9334}
9335
9336/// Negotiate v2 and send only local changes. A v1 hub retains the existing
9337/// whole-snapshot behavior until its brain advertises the new profile.
9338pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
9339    sync_push_incremental_with_policy(cfg, brain, store, false)
9340}
9341
9342/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
9343/// path that became eligible because local policy was weakened or removed.
9344pub fn sync_push_incremental_with_policy(
9345    cfg: &HubConfig,
9346    brain: &str,
9347    store: &Store,
9348    resume_local_policy: bool,
9349) -> LinkResult<Value> {
9350    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
9351}
9352
9353/// Incremental push with both local-policy adoption and an optional exact
9354/// permissioned bulk-preview confirmation.
9355pub fn sync_push_incremental_with_options(
9356    cfg: &HubConfig,
9357    brain: &str,
9358    store: &Store,
9359    resume_local_policy: bool,
9360    bulk_confirmation: Option<&V2BulkConfirmation>,
9361) -> LinkResult<Value> {
9362    sync_push_incremental_with_controls(
9363        cfg,
9364        brain,
9365        store,
9366        resume_local_policy,
9367        bulk_confirmation,
9368        &[],
9369        None,
9370    )
9371}
9372
9373/// Incremental push with explicit permissioned current-host withdrawal.
9374pub fn sync_push_incremental_with_controls(
9375    cfg: &HubConfig,
9376    brain: &str,
9377    store: &Store,
9378    resume_local_policy: bool,
9379    bulk_confirmation: Option<&V2BulkConfirmation>,
9380    withdrawal_paths: &[String],
9381    withdrawal_reason: Option<&str>,
9382) -> LinkResult<Value> {
9383    require_safe_ref(brain)?;
9384    if let Some(head) = v2_verified_head(cfg, brain)? {
9385        return v2_sync_push(
9386            cfg,
9387            brain,
9388            store,
9389            head,
9390            V2SyncPushOptions {
9391                resume_local_policy,
9392                bulk_confirmation,
9393                resolution: None,
9394                pulled: None,
9395                withdrawal_paths,
9396                withdrawal_reason,
9397                allow_contract_phase: true,
9398            },
9399        );
9400    }
9401    if !withdrawal_paths.is_empty() {
9402        return Err(LinkError::InvalidPack {
9403            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
9404        });
9405    }
9406    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
9407}
9408
9409/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
9410/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
9411/// a damaged `DB.md` makes strict store-open fail.
9412pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
9413    require_safe_ref(brain)?;
9414    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
9415}
9416
9417/// Rebind one verified incremental baseline after its checkout directory was
9418/// moved without changing the checkout bytes. Baselines deliberately live in
9419/// private trust state and are path-bound, so callers performing an atomic
9420/// staged publish must cross this explicit boundary instead of silently
9421/// turning the moved checkout into a baseline-less clone.
9422pub fn relocate_v2_sync_baseline(
9423    cfg: &HubConfig,
9424    brain: &str,
9425    from: &Path,
9426    to: &Path,
9427) -> LinkResult<Value> {
9428    require_hardened_filesystem("verified link.md v2 baseline relocation")?;
9429    require_safe_ref(brain)?;
9430    if !crate::ulid::is_ulid(brain) {
9431        return Err(invalid_feed(
9432            "v2 baseline relocation requires the canonical brain id",
9433        ));
9434    }
9435    let from_absolute = if from.is_absolute() {
9436        from.to_path_buf()
9437    } else {
9438        std::env::current_dir()?.join(from)
9439    };
9440    let to_absolute = if to.is_absolute() {
9441        to.to_path_buf()
9442    } else {
9443        std::env::current_dir()?.join(to)
9444    };
9445    let source_name = v2_baseline_name(cfg, brain, &from_absolute)?;
9446    let target_name = v2_baseline_name(cfg, brain, &to_absolute)?;
9447    if source_name == target_name {
9448        return Err(invalid_feed(
9449            "v2 baseline relocation source and destination are the same checkout",
9450        ));
9451    }
9452    match std::fs::symlink_metadata(&from_absolute) {
9453        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
9454        Err(error) => return Err(error.into()),
9455        Ok(_) => {
9456            return Err(LinkError::InvalidPack {
9457                message: "the old checkout still exists; move it before relocating its baseline"
9458                    .to_string(),
9459            })
9460        }
9461    }
9462    let store = Store::open_strict(&to_absolute).map_err(|error| LinkError::InvalidPack {
9463        message: format!("relocated checkout is not a valid db.md store: {error}"),
9464    })?;
9465    let _operation_lock = lock_v2_sync_operation(cfg, brain)?;
9466
9467    #[cfg(any(unix, windows))]
9468    {
9469        let directory = open_trust_dir(cfg)?;
9470        let mut lock_names = [source_name.as_str(), target_name.as_str()];
9471        lock_names.sort();
9472        let _locks = lock_names
9473            .iter()
9474            .map(|name| lock_trust_name(&directory, name))
9475            .collect::<LinkResult<Vec<_>>>()?;
9476        let source = load_v2_baseline_in(cfg, brain, &directory, &source_name)?;
9477        let target = load_v2_baseline_in(cfg, brain, &directory, &target_name)?;
9478        let (baseline, already_relocated) = match (source, target) {
9479            (Some(source), None) => (source, false),
9480            (None, Some(target)) => (target, true),
9481            (Some(_), Some(_)) => {
9482                return Err(LinkError::InvalidPack {
9483                    message: "both old and new checkout paths already have private sync baselines"
9484                        .to_string(),
9485                })
9486            }
9487            (None, None) => {
9488                return Err(LinkError::InvalidPack {
9489                    message: "the old checkout has no verified incremental baseline to relocate"
9490                        .to_string(),
9491                })
9492            }
9493        };
9494
9495        let mut local = v2_local_files(&store)?;
9496        if baseline.view_kind.as_deref() == Some("scoped") {
9497            let expected = baseline
9498                .projection_sha256
9499                .as_deref()
9500                .ok_or_else(|| invalid_feed("scoped baseline has no projection hash"))?;
9501            if local.riding.get("DB.md").map(|value| value.0.as_str()) != Some(expected) {
9502                return Err(LinkError::ScopedProjectionModified);
9503            }
9504            local.riding.remove("DB.md");
9505        }
9506        if baseline.local_policy_digest.as_deref() != Some(local.policy.digest.as_str())
9507            || !v2_riding_matches_remote(&local.riding, &baseline.files, |path| {
9508                local.policy.keeps_home(path)
9509            })
9510            || !v2_asset_records_match_remote(&v2_local_asset_records(&store)?, &baseline.assets)
9511        {
9512            return Err(LinkError::InvalidPack {
9513                message: "the moved checkout no longer matches its verified incremental baseline"
9514                    .to_string(),
9515            });
9516        }
9517        if !already_relocated {
9518            crate::fsx::rename_beneath(
9519                &directory,
9520                Path::new(&source_name),
9521                Path::new(&target_name),
9522            )?;
9523            directory.sync_all()?;
9524        }
9525        Ok(json!({
9526            "v": 2,
9527            "class": "checkout_baseline_relocated",
9528            "brain": baseline.brain,
9529            "from": from_absolute,
9530            "to": to_absolute,
9531            "headSeq": baseline.head_seq.unwrap_or(0),
9532            "commitHash": baseline.commit_hash,
9533            "moved": !already_relocated,
9534        }))
9535    }
9536
9537    #[cfg(not(any(unix, windows)))]
9538    Err(LinkError::UnsupportedPlatform {
9539        operation: "verified link.md v2 baseline relocation",
9540    })
9541}
9542
9543#[cfg(windows)]
9544fn legacy_sync_push_incremental(
9545    _cfg: &HubConfig,
9546    _brain: &str,
9547    _store: &Store,
9548    _resume_local_policy: bool,
9549    _bulk_confirmation: Option<&V2BulkConfirmation>,
9550) -> LinkResult<Value> {
9551    Err(LinkError::UnsupportedPlatform {
9552        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
9553    })
9554}
9555
9556#[cfg(not(windows))]
9557fn legacy_sync_push_incremental(
9558    cfg: &HubConfig,
9559    brain: &str,
9560    store: &Store,
9561    resume_local_policy: bool,
9562    bulk_confirmation: Option<&V2BulkConfirmation>,
9563) -> LinkResult<Value> {
9564    if resume_local_policy || bulk_confirmation.is_some() {
9565        return Err(LinkError::InvalidPack {
9566            message: "v2 sync options require a link.md v2 brain".to_string(),
9567        });
9568    }
9569    let files = collect_push_files(store)?;
9570    sync_push(cfg, brain, &files)
9571}
9572
9573/// One explicit resolution for a previously preserved private conflict bundle.
9574#[derive(Debug, Clone)]
9575pub enum V2ConflictChoice {
9576    KeepLocal,
9577    TakeRemote,
9578    From(PathBuf),
9579}
9580
9581fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
9582    if !crate::ulid::is_ulid(bundle) {
9583        return Err(LinkError::InvalidPack {
9584            message: "conflict bundle must be a lowercase ULID".to_string(),
9585        });
9586    }
9587    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
9588    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
9589        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
9590    if plan.v != 2
9591        || plan.class != "content_resolution_required"
9592        || plan.bundle != bundle
9593        || !crate::ulid::is_ulid(&plan.brain)
9594        || plan.files.is_empty()
9595        || plan.files.len() > 100
9596        || plan.files.iter().any(|file| {
9597            crate::linkmd_v2::normalize_path(&file.path).is_err()
9598                || [&file.base, &file.local, &file.remote]
9599                    .into_iter()
9600                    .any(|coordinate| {
9601                        coordinate
9602                            .sha256
9603                            .as_deref()
9604                            .is_some_and(|hash| !is_sha256(hash))
9605                            || coordinate.file.as_deref().is_some_and(|name| {
9606                                name.starts_with('/')
9607                                    || name
9608                                        .split('/')
9609                                        .any(|part| part.is_empty() || part == "." || part == "..")
9610                            })
9611                    })
9612        })
9613    {
9614        return Err(invalid_feed("private conflict plan failed validation"));
9615    }
9616    Ok(plan)
9617}
9618
9619/// Inspect or prune private conflict-control state. Ordinary pruning removes
9620/// only expired completed bundles and interrupted bundles with no `plan.json`;
9621/// corrupt completed plans fail closed. `all` is an explicit local discard of
9622/// every well-addressed bundle and never changes hosted brain data.
9623pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
9624    require_hardened_filesystem("private conflict maintenance")?;
9625    if all && !prune {
9626        return Err(LinkError::InvalidPack {
9627            message: "discarding all conflict bundles requires prune=true".to_string(),
9628        });
9629    }
9630    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9631        message: format!("conflict checkout is not a valid db.md store: {error}"),
9632    })?;
9633    let _transaction = store.transaction()?;
9634    let root = Path::new(".dbmd/conflicts");
9635    let names = match store.directory_names(root) {
9636        Ok(names) => names,
9637        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
9638        Err(error) => return Err(error.into()),
9639    };
9640    let now = SystemTime::now()
9641        .duration_since(UNIX_EPOCH)
9642        .unwrap_or_default()
9643        .as_secs();
9644    let mut bundles = Vec::new();
9645    let mut pruned = 0_u64;
9646    for name in names {
9647        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
9648            continue;
9649        };
9650        let plan_path = v2_conflict_relative(bundle, "plan.json");
9651        let plan_exists = store.regular_file_exists(&plan_path)?;
9652        let expired = if plan_exists {
9653            match load_v2_conflict_plan(&store, bundle) {
9654                Ok(plan) => plan.expires_unix < now,
9655                Err(error) if all => {
9656                    let _ = error;
9657                    true
9658                }
9659                Err(error) => return Err(error),
9660            }
9661        } else {
9662            true
9663        };
9664        if prune && (all || expired) {
9665            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9666            pruned += 1;
9667            continue;
9668        }
9669        bundles.push(json!({
9670            "bundle": bundle,
9671            "complete": plan_exists,
9672            "expired": expired,
9673        }));
9674    }
9675    Ok(json!({
9676        "v": 2,
9677        "class": "private_conflict_state",
9678        "bundles": bundles.len(),
9679        "pruned": pruned,
9680        "items": bundles,
9681    }))
9682}
9683
9684/// Resolve one exact private conflict plan. Drift never lowers a precondition:
9685/// the remote head/view and every original local coordinate are rechecked
9686/// before either an explicit local install or a fresh normal commit.
9687pub fn sync_resolve_conflict(
9688    cfg: &HubConfig,
9689    checkout: &Path,
9690    bundle: &str,
9691    choice: V2ConflictChoice,
9692    bulk_confirmation: Option<&V2BulkConfirmation>,
9693) -> LinkResult<Value> {
9694    require_hardened_filesystem("conflict resolution")?;
9695    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9696        message: format!("conflict checkout is not a valid db.md store: {error}"),
9697    })?;
9698    let plan = load_v2_conflict_plan(&store, bundle)?;
9699    if plan.origin != normalized_origin(&cfg.hub)? {
9700        return Err(invalid_feed(
9701            "conflict bundle belongs to another hub origin",
9702        ));
9703    }
9704    let now = SystemTime::now()
9705        .duration_since(UNIX_EPOCH)
9706        .unwrap_or_default()
9707        .as_secs();
9708    if now > plan.expires_unix {
9709        return Err(LinkError::InvalidPack {
9710            message: "conflict bundle expired; rerun sync to obtain current coordinates"
9711                .to_string(),
9712        });
9713    }
9714    let head = v2_verified_head(cfg, &plan.brain)?
9715        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
9716    let pointer = head.pointer.as_ref();
9717    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
9718        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
9719        || pointer.and_then(|value| value.content_root.as_deref())
9720            != plan.remote_content_root.as_deref()
9721        || head.view_kind != plan.view_kind
9722        || head.view_revision != plan.view_revision
9723    {
9724        return Err(LinkError::RemoteAdvancedDuringSync);
9725    }
9726
9727    // A resolution never overwrites a post-bundle local edit.
9728    for file in &plan.files {
9729        let actual = match store.regular_file_exists(Path::new(&file.path))? {
9730            true => Some(content_sha256(&store.read_bounded(
9731                Path::new(&file.path),
9732                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
9733            )?)),
9734            false => None,
9735        };
9736        if actual.as_deref() != file.local.sha256.as_deref() {
9737            return Err(LinkError::InvalidPack {
9738                message: format!(
9739                    "local conflict path `{}` changed after the bundle was created",
9740                    file.path
9741                ),
9742            });
9743        }
9744    }
9745
9746    let from_source = match &choice {
9747        V2ConflictChoice::From(source) => Some(source.clone()),
9748        _ => None,
9749    };
9750    let result = match choice {
9751        V2ConflictChoice::TakeRemote => {
9752            if bulk_confirmation.is_some() {
9753                return Err(LinkError::InvalidPack {
9754                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
9755                });
9756            }
9757            // Re-read the exact current manifest so every selected remote byte
9758            // retains its signed inclusion proof. The private bundle is useful
9759            // for inspection, but is never promoted into live state as trust.
9760            let current_remote =
9761                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
9762            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
9763            let selected = plan
9764                .files
9765                .iter()
9766                .map(|file| file.path.clone())
9767                .collect::<std::collections::BTreeSet<_>>();
9768            serde_json::to_value(
9769                v2_sync_pull_with_resolution(
9770                    cfg,
9771                    &plan.brain,
9772                    head,
9773                    Some(checkout),
9774                    Some(&selected),
9775                )?
9776                .report,
9777            )
9778            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
9779        }
9780        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
9781            if let Some(source) = from_source.as_ref() {
9782                if plan.files.len() != 1 {
9783                    return Err(LinkError::InvalidPack {
9784                        message: "--from requires a bundle with exactly one conflict".to_string(),
9785                    });
9786                }
9787                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
9788                if std::str::from_utf8(&candidate).is_err() {
9789                    return Err(LinkError::NotUtf8 {
9790                        path: source.display().to_string(),
9791                    });
9792                }
9793                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
9794            }
9795            let refreshed_store =
9796                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9797                    message: format!("resolved checkout is not a valid db.md store: {error}"),
9798                })?;
9799            let mut overrides = std::collections::BTreeMap::new();
9800            for file in &plan.files {
9801                let selected_local = match refreshed_store
9802                    .regular_file_exists(Path::new(&file.path))?
9803                {
9804                    true => Some(content_sha256(
9805                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
9806                    )),
9807                    false => None,
9808                };
9809                overrides.insert(
9810                    file.path.clone(),
9811                    V2ResolutionOverride {
9812                        expected_remote: file.remote.sha256.clone(),
9813                        selected_local,
9814                    },
9815                );
9816            }
9817            v2_sync_push(
9818                cfg,
9819                &plan.brain,
9820                &refreshed_store,
9821                head,
9822                V2SyncPushOptions {
9823                    resume_local_policy: true,
9824                    bulk_confirmation,
9825                    resolution: Some(&overrides),
9826                    pulled: None,
9827                    withdrawal_paths: &[],
9828                    withdrawal_reason: None,
9829                    allow_contract_phase: true,
9830                },
9831            )?
9832        }
9833    };
9834
9835    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
9836        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9837            message: format!("resolved checkout is not a valid db.md store: {error}"),
9838        })?;
9839        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
9840    }
9841    Ok(json!({
9842        "v": 2,
9843        "class": "auto_converged",
9844        "bundle": bundle,
9845        "receipt": result,
9846    }))
9847}
9848
9849/// Converge one established permissioned-v2 checkout in both directions.
9850///
9851/// The pull half first performs the ordinary three-way conflict check and
9852/// crash-safe install against the accepted baseline. The push half then opens
9853/// that exact installed checkout, takes its local transaction guard, computes
9854/// a fresh three-way delta, and submits it with mandatory preconditions. A
9855/// remote movement between halves is therefore a normal rebase/conflict input,
9856/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
9857/// its whole-pack replacement semantics are never presented as granular
9858/// convergence.
9859pub fn sync_converge(
9860    cfg: &HubConfig,
9861    brain: &str,
9862    checkout: &Path,
9863    resume_local_policy: bool,
9864) -> LinkResult<Value> {
9865    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
9866}
9867
9868/// Bidirectional convergence with optional exact bulk-preview confirmation.
9869pub fn sync_converge_with_options(
9870    cfg: &HubConfig,
9871    brain: &str,
9872    checkout: &Path,
9873    resume_local_policy: bool,
9874    bulk_confirmation: Option<&V2BulkConfirmation>,
9875) -> LinkResult<Value> {
9876    sync_converge_with_controls(
9877        cfg,
9878        brain,
9879        checkout,
9880        resume_local_policy,
9881        bulk_confirmation,
9882        &[],
9883        None,
9884    )
9885}
9886
9887/// Bidirectional convergence with optional exact withdrawal intents.
9888pub fn sync_converge_with_controls(
9889    cfg: &HubConfig,
9890    brain: &str,
9891    checkout: &Path,
9892    resume_local_policy: bool,
9893    bulk_confirmation: Option<&V2BulkConfirmation>,
9894    withdrawal_paths: &[String],
9895    withdrawal_reason: Option<&str>,
9896) -> LinkResult<Value> {
9897    require_hardened_filesystem("bidirectional sync")?;
9898    require_safe_ref(brain)?;
9899    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
9900        message:
9901            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
9902                .to_string(),
9903    })?;
9904    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
9905    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
9906        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
9907    })?;
9908    let _transaction = store.transaction()?;
9909    let pulled_report = pulled.report.clone();
9910    let pulled_head = pulled.head.clone();
9911    let mut result = v2_sync_push(
9912        cfg,
9913        brain,
9914        &store,
9915        pulled_head,
9916        V2SyncPushOptions {
9917            resume_local_policy,
9918            bulk_confirmation,
9919            resolution: None,
9920            pulled: Some(pulled),
9921            withdrawal_paths,
9922            withdrawal_reason,
9923            allow_contract_phase: true,
9924        },
9925    )?;
9926    if let Some(object) = result.as_object_mut() {
9927        object.insert("pulled_files".to_string(), json!(pulled_report.files));
9928        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
9929        object.insert(
9930            "mode".to_string(),
9931            Value::String("bidirectional".to_string()),
9932        );
9933    }
9934    Ok(result)
9935}
9936
9937/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
9938/// exported path is safety-gated before it touches disk; files are written
9939/// atomically; nothing local is ever deleted (locals the export lacks are
9940/// *reported* in `extra_local` instead). Returns the report; rebuilding the
9941/// local index catalog afterwards is the caller's (cheap, optional) step.
9942pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9943    require_hardened_filesystem("sync pull")?;
9944    require_safe_ref(brain)?;
9945    if let Some(head) = v2_verified_head(cfg, brain)? {
9946        return v2_sync_pull(cfg, brain, head, out);
9947    }
9948    legacy_sync_pull(cfg, brain, out)
9949}
9950
9951#[cfg(windows)]
9952fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
9953    Err(LinkError::UnsupportedPlatform {
9954        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
9955    })
9956}
9957
9958#[cfg(not(windows))]
9959fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
9960    let remote = verified_remote_head(cfg, brain, false)?;
9961    if !remote.head.verified {
9962        return Err(invalid_feed(
9963            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
9964        ));
9965    }
9966    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
9967    let path = format!(
9968        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
9969        remote.head.seq
9970    );
9971    let body = ensure_ok(
9972        request(cfg, "GET", &path, None, Auth::Required)?,
9973        "sync pull",
9974    )?;
9975    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
9976        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
9977    {
9978        return Err(invalid_feed(
9979            "export response is not bound to the verified snapshot token",
9980        ));
9981    }
9982
9983    let remote_slug = body
9984        .get("slug")
9985        .and_then(Value::as_str)
9986        .filter(|slug| is_safe_slug(slug));
9987    let slug = remote_slug
9988        .or_else(|| is_safe_slug(brain).then_some(brain))
9989        .unwrap_or("brain")
9990        .to_string();
9991    let brain_id = body
9992        .get("brain")
9993        .and_then(Value::as_str)
9994        .unwrap_or(&remote.head.brain)
9995        .to_string();
9996    if brain_id != remote.head.brain {
9997        return Err(invalid_feed(
9998            "export response names a different brain than the verified head",
9999        ));
10000    }
10001    let head_seq = remote.head.seq;
10002    let dest: PathBuf = match out {
10003        Some(p) => p.to_path_buf(),
10004        None => PathBuf::from(&slug),
10005    };
10006    let entries = if head_seq == 0 {
10007        let files = body
10008            .get("files")
10009            .and_then(Value::as_array)
10010            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
10011        if !files.is_empty() || body.get("url").is_some() {
10012            return Err(invalid_feed(
10013                "empty signed feed cannot authorize non-empty exported content",
10014            ));
10015        }
10016        Vec::new()
10017    } else {
10018        let signed_head = remote
10019            .head_entry
10020            .as_ref()
10021            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
10022        let expected = &signed_head.entry.pack_sha256;
10023        if !is_sha256(expected) {
10024            return Err(invalid_feed(
10025                "signed head carries an invalid snapshot pack digest",
10026            ));
10027        }
10028        if let Some(url) = body.get("url").and_then(Value::as_str) {
10029            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
10030                return Err(invalid_feed(
10031                    "export pack digest does not match the signed head entry",
10032                ));
10033            }
10034            let bytes = get_presigned(cfg, url)?;
10035            let actual = format!("{:x}", Sha256::digest(&bytes));
10036            if actual != *expected {
10037                return Err(LinkError::InvalidPack {
10038                    message: "downloaded pack does not match the signed snapshot digest"
10039                        .to_string(),
10040                });
10041            }
10042            let entries = parse_store_pack(bytes)?;
10043            if signed_head.entry.kind == "push" {
10044                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
10045            }
10046            entries
10047        } else {
10048            if signed_head.entry.kind != "push" {
10049                return Err(invalid_feed(
10050                    "delta snapshots must export the exact signed pack",
10051                ));
10052            }
10053            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
10054                invalid_feed("verified snapshot export carried neither a pack nor files")
10055            })?;
10056            let mut entries = Vec::with_capacity(files.len());
10057            for file in files {
10058                let path = file
10059                    .get("path")
10060                    .and_then(Value::as_str)
10061                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
10062                let content = file
10063                    .get("content")
10064                    .and_then(Value::as_str)
10065                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
10066                entries.push((path.to_string(), content.as_bytes().to_vec()));
10067            }
10068            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
10069            entries
10070        }
10071    };
10072
10073    // Gate the complete manifest before the first filesystem mutation.
10074    let mut seen = std::collections::HashSet::new();
10075    for (path, _) in &entries {
10076        if !safe_store_rel_path(path) {
10077            return Err(LinkError::UnsafePath { path: path.clone() });
10078        }
10079        if !seen.insert(path) {
10080            return Err(LinkError::InvalidPack {
10081                message: format!("duplicate path `{path}`"),
10082            });
10083        }
10084    }
10085    // Compute divergence against the still-live tree. The staged clone keeps
10086    // these extra locals byte-for-byte while overlaying the signed snapshot.
10087    let pulled: std::collections::BTreeSet<&str> =
10088        entries.iter().map(|(p, _)| p.as_str()).collect();
10089    let mut extra_local = Vec::new();
10090    if let Ok(store) = Store::open(&dest) {
10091        if let Ok(walked) = store.walk() {
10092            for rel in walked {
10093                let rel_str = rel.to_string_lossy().replace('\\', "/");
10094                if !pulled.contains(rel_str.as_str()) {
10095                    extra_local.push(rel_str);
10096                }
10097            }
10098        }
10099    }
10100    #[cfg(unix)]
10101    install_pulled_snapshot(&dest, &entries)?;
10102
10103    Ok(PullReport {
10104        brain: brain_id,
10105        slug,
10106        head_seq,
10107        files: entries.len(),
10108        dest: dest.to_string_lossy().into_owned(),
10109        extra_local,
10110        sync_status: "synced".to_string(),
10111    })
10112}
10113
10114#[cfg(unix)]
10115fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
10116    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
10117        path: display.to_string(),
10118    })
10119}
10120
10121#[cfg(unix)]
10122fn open_dir_at(
10123    parent: std::os::fd::RawFd,
10124    name: &std::ffi::CStr,
10125    display: &str,
10126) -> LinkResult<std::fs::File> {
10127    use std::os::fd::FromRawFd as _;
10128    let fd = unsafe {
10129        libc::openat(
10130            parent,
10131            name.as_ptr(),
10132            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10133        )
10134    };
10135    if fd < 0 {
10136        return Err(LinkError::UnsafePath {
10137            path: display.to_string(),
10138        });
10139    }
10140    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
10141}
10142
10143/// Open (and, where absent, create) a directory path without following a
10144/// symlink in any component. The returned directory capability remains bound
10145/// to the opened inode even if an attacker renames or replaces an ancestor.
10146#[cfg(unix)]
10147fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
10148    use std::os::fd::AsRawFd as _;
10149
10150    // macOS exposes a few system-owned compatibility symlinks at the root.
10151    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
10152    // caller-controlled ancestor.
10153    #[cfg(target_os = "macos")]
10154    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
10155        .into_iter()
10156        .find_map(|(alias, real)| {
10157            path.strip_prefix(alias)
10158                .ok()
10159                .map(|rest| Path::new(real).join(rest))
10160        })
10161        .unwrap_or_else(|| path.to_path_buf());
10162    #[cfg(not(target_os = "macos"))]
10163    let normalized = path.to_path_buf();
10164
10165    let start = if normalized.is_absolute() {
10166        std::fs::File::open("/")?
10167    } else {
10168        std::fs::File::open(".")?
10169    };
10170    let mut directory = start;
10171    for component in normalized.components() {
10172        use std::path::Component;
10173        let name = match component {
10174            Component::RootDir | Component::CurDir => continue,
10175            Component::Normal(name) => name,
10176            Component::ParentDir | Component::Prefix(_) => {
10177                return Err(LinkError::UnsafePath {
10178                    path: path.display().to_string(),
10179                });
10180            }
10181        };
10182        use std::os::unix::ffi::OsStrExt as _;
10183        let name = c_name(name.as_bytes(), &path.display().to_string())?;
10184        if create {
10185            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10186            if made != 0 {
10187                let error = std::io::Error::last_os_error();
10188                if error.raw_os_error() != Some(libc::EEXIST) {
10189                    return Err(error.into());
10190                }
10191            }
10192        }
10193        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
10194    }
10195    Ok(directory)
10196}
10197
10198#[cfg(unix)]
10199fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
10200    open_dir_path_nofollow(path, true)
10201}
10202
10203#[cfg(unix)]
10204fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
10205    open_dir_path_nofollow(path, false)
10206}
10207
10208#[cfg(unix)]
10209fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
10210    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
10211    let result =
10212        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
10213    if result == 0 {
10214        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
10215    }
10216    let error = std::io::Error::last_os_error();
10217    if error.kind() == std::io::ErrorKind::NotFound {
10218        Ok(None)
10219    } else {
10220        Err(error.into())
10221    }
10222}
10223
10224#[cfg(unix)]
10225fn create_dir_exclusive_at(
10226    parent: std::os::fd::RawFd,
10227    name: &std::ffi::CStr,
10228    display: &str,
10229) -> LinkResult<std::fs::File> {
10230    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
10231    if made != 0 {
10232        return Err(LinkError::UnsafePath {
10233            path: display.to_string(),
10234        });
10235    }
10236    open_dir_at(parent, name, display)
10237}
10238
10239#[cfg(unix)]
10240fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
10241    use std::os::fd::AsRawFd as _;
10242
10243    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
10244    if duplicate < 0 {
10245        return Err(std::io::Error::last_os_error().into());
10246    }
10247    let stream = unsafe { libc::fdopendir(duplicate) };
10248    if stream.is_null() {
10249        let error = std::io::Error::last_os_error();
10250        unsafe {
10251            libc::close(duplicate);
10252        }
10253        return Err(error.into());
10254    }
10255    let mut names = Vec::new();
10256    loop {
10257        let entry = unsafe { libc::readdir(stream) };
10258        if entry.is_null() {
10259            break;
10260        }
10261        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
10262        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
10263            names.push(raw.to_owned());
10264        }
10265    }
10266    if unsafe { libc::closedir(stream) } != 0 {
10267        return Err(std::io::Error::last_os_error().into());
10268    }
10269    Ok(names)
10270}
10271
10272/// Remove an entry tree relative to a held directory capability. Symlinks are
10273/// unlinked, never traversed, even when an old mirror contains hostile names.
10274#[cfg(unix)]
10275fn remove_tree_at(
10276    parent: std::os::fd::RawFd,
10277    name: &std::ffi::CStr,
10278    display: &str,
10279) -> LinkResult<()> {
10280    use std::os::fd::AsRawFd as _;
10281
10282    match entry_is_dir_at(parent, name)? {
10283        None => return Ok(()),
10284        Some(false) => {
10285            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
10286                return Err(std::io::Error::last_os_error().into());
10287            }
10288        }
10289        Some(true) => {
10290            let directory = open_dir_at(parent, name, display)?;
10291            for child in directory_entry_names(&directory)? {
10292                let child_display =
10293                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
10294                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
10295            }
10296            drop(directory);
10297            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
10298                return Err(std::io::Error::last_os_error().into());
10299            }
10300        }
10301    }
10302    Ok(())
10303}
10304
10305/// Clone a live destination into a private sibling stage without following
10306/// any symlink. Regular files are streamed between held descriptors; symlinks
10307/// are reproduced as links, never opened. Special files are refused.
10308#[cfg(unix)]
10309fn clone_tree_contents(
10310    source: &std::fs::File,
10311    destination: &std::fs::File,
10312    display: &str,
10313) -> LinkResult<()> {
10314    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10315
10316    for name in directory_entry_names(source)? {
10317        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
10318        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
10319        if unsafe {
10320            libc::fstatat(
10321                source.as_raw_fd(),
10322                name.as_ptr(),
10323                &mut stat,
10324                libc::AT_SYMLINK_NOFOLLOW,
10325            )
10326        } != 0
10327        {
10328            return Err(std::io::Error::last_os_error().into());
10329        }
10330        match stat.st_mode & libc::S_IFMT {
10331            libc::S_IFDIR => {
10332                if unsafe {
10333                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
10334                } != 0
10335                {
10336                    return Err(std::io::Error::last_os_error().into());
10337                }
10338                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
10339                let destination_child =
10340                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
10341                clone_tree_contents(&source_child, &destination_child, &child_display)?;
10342                destination_child.sync_all()?;
10343            }
10344            libc::S_IFREG => {
10345                let source_fd = unsafe {
10346                    libc::openat(
10347                        source.as_raw_fd(),
10348                        name.as_ptr(),
10349                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10350                    )
10351                };
10352                if source_fd < 0 {
10353                    return Err(std::io::Error::last_os_error().into());
10354                }
10355                let destination_fd = unsafe {
10356                    libc::openat(
10357                        destination.as_raw_fd(),
10358                        name.as_ptr(),
10359                        libc::O_WRONLY
10360                            | libc::O_CREAT
10361                            | libc::O_EXCL
10362                            | libc::O_CLOEXEC
10363                            | libc::O_NOFOLLOW,
10364                        (stat.st_mode & 0o777) as libc::c_uint,
10365                    )
10366                };
10367                if destination_fd < 0 {
10368                    unsafe {
10369                        libc::close(source_fd);
10370                    }
10371                    return Err(std::io::Error::last_os_error().into());
10372                }
10373                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
10374                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
10375                std::io::copy(&mut input, &mut output)?;
10376                output.sync_all()?;
10377            }
10378            libc::S_IFLNK => {
10379                let mut target = vec![0_u8; 4097];
10380                let length = unsafe {
10381                    libc::readlinkat(
10382                        source.as_raw_fd(),
10383                        name.as_ptr(),
10384                        target.as_mut_ptr().cast(),
10385                        target.len(),
10386                    )
10387                };
10388                if length < 0 || length as usize >= target.len() {
10389                    return Err(LinkError::UnsafePath {
10390                        path: child_display,
10391                    });
10392                }
10393                target.truncate(length as usize);
10394                let target = c_name(&target, &child_display)?;
10395                if unsafe {
10396                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
10397                } != 0
10398                {
10399                    return Err(std::io::Error::last_os_error().into());
10400                }
10401            }
10402            _ => {
10403                return Err(LinkError::UnsafePath {
10404                    path: child_display,
10405                });
10406            }
10407        }
10408    }
10409    destination.sync_all()?;
10410    Ok(())
10411}
10412
10413#[cfg(target_os = "linux")]
10414fn install_stage_at(
10415    parent: std::os::fd::RawFd,
10416    stage: &std::ffi::CStr,
10417    dest: &std::ffi::CStr,
10418    dest_exists: bool,
10419) -> LinkResult<()> {
10420    let flags = if dest_exists {
10421        libc::RENAME_EXCHANGE
10422    } else {
10423        libc::RENAME_NOREPLACE
10424    };
10425    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
10426    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
10427    // atomic exchange/no-replace boundary compiles for glibc and musl.
10428    let result = unsafe {
10429        libc::syscall(
10430            libc::SYS_renameat2,
10431            parent,
10432            stage.as_ptr(),
10433            parent,
10434            dest.as_ptr(),
10435            flags,
10436        )
10437    };
10438    if result == 0 {
10439        Ok(())
10440    } else {
10441        Err(std::io::Error::last_os_error().into())
10442    }
10443}
10444
10445#[cfg(target_os = "macos")]
10446fn install_stage_at(
10447    parent: std::os::fd::RawFd,
10448    stage: &std::ffi::CStr,
10449    dest: &std::ffi::CStr,
10450    dest_exists: bool,
10451) -> LinkResult<()> {
10452    let flags = if dest_exists {
10453        libc::RENAME_SWAP
10454    } else {
10455        libc::RENAME_EXCL
10456    };
10457    let result =
10458        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
10459    if result == 0 {
10460        Ok(())
10461    } else {
10462        Err(std::io::Error::last_os_error().into())
10463    }
10464}
10465
10466#[cfg(unix)]
10467fn write_pull_entries_beneath_dir(
10468    root: &std::fs::File,
10469    entries: &[(String, Vec<u8>)],
10470) -> LinkResult<()> {
10471    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10472
10473    for (path, content) in entries {
10474        let components: Vec<&str> = path.split('/').collect();
10475        let (leaf, parents) = components
10476            .split_last()
10477            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10478        let mut directory = root.try_clone()?;
10479        for component in parents {
10480            let name = c_name(component.as_bytes(), path)?;
10481            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10482            if made != 0 {
10483                let error = std::io::Error::last_os_error();
10484                if error.raw_os_error() != Some(libc::EEXIST) {
10485                    return Err(error.into());
10486                }
10487            }
10488            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10489        }
10490
10491        let leaf_name = c_name(leaf.as_bytes(), path)?;
10492        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
10493        let inspected = unsafe {
10494            libc::fstatat(
10495                directory.as_raw_fd(),
10496                leaf_name.as_ptr(),
10497                &mut existing,
10498                libc::AT_SYMLINK_NOFOLLOW,
10499            )
10500        };
10501        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
10502            return Err(LinkError::UnsafePath { path: path.clone() });
10503        }
10504
10505        let nonce = std::time::SystemTime::now()
10506            .duration_since(std::time::UNIX_EPOCH)
10507            .unwrap_or_default()
10508            .as_nanos();
10509        let temp_name = format!(
10510            ".dbmd-pull-{}-{nonce}-{}",
10511            std::process::id(),
10512            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
10513        );
10514        let temp = c_name(temp_name.as_bytes(), path)?;
10515        let fd = unsafe {
10516            libc::openat(
10517                directory.as_raw_fd(),
10518                temp.as_ptr(),
10519                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10520                0o600,
10521            )
10522        };
10523        if fd < 0 {
10524            return Err(std::io::Error::last_os_error().into());
10525        }
10526        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10527        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
10528            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10529            return Err(error.into());
10530        }
10531        drop(file);
10532        let renamed = unsafe {
10533            libc::renameat(
10534                directory.as_raw_fd(),
10535                temp.as_ptr(),
10536                directory.as_raw_fd(),
10537                leaf_name.as_ptr(),
10538            )
10539        };
10540        if renamed != 0 {
10541            let error = std::io::Error::last_os_error();
10542            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10543            return Err(error.into());
10544        }
10545        directory.sync_all()?;
10546    }
10547    root.sync_all()?;
10548    Ok(())
10549}
10550
10551#[cfg(unix)]
10552fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
10553    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10554
10555    let path = &entry.path;
10556    let components: Vec<&str> = path.split('/').collect();
10557    let (leaf, parents) = components
10558        .split_last()
10559        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10560    let mut directory = root.try_clone()?;
10561    for component in parents {
10562        let name = c_name(component.as_bytes(), path)?;
10563        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
10564        if made != 0 {
10565            let error = std::io::Error::last_os_error();
10566            if error.raw_os_error() != Some(libc::EEXIST) {
10567                return Err(error.into());
10568            }
10569        }
10570        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10571    }
10572    let leaf_name = c_name(leaf.as_bytes(), path)?;
10573    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
10574    if unsafe {
10575        libc::fstatat(
10576            directory.as_raw_fd(),
10577            leaf_name.as_ptr(),
10578            &mut existing,
10579            libc::AT_SYMLINK_NOFOLLOW,
10580        )
10581    } == 0
10582        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
10583    {
10584        return Err(LinkError::UnsafePath { path: path.clone() });
10585    }
10586    let nonce = SystemTime::now()
10587        .duration_since(UNIX_EPOCH)
10588        .unwrap_or_default()
10589        .as_nanos();
10590    let temp_name = format!(
10591        ".dbmd-pull-{}-{nonce}-{}",
10592        std::process::id(),
10593        content_sha256(path.as_bytes())
10594    );
10595    let temp = c_name(temp_name.as_bytes(), path)?;
10596    let fd = unsafe {
10597        libc::openat(
10598            directory.as_raw_fd(),
10599            temp.as_ptr(),
10600            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10601            0o600,
10602        )
10603    };
10604    if fd < 0 {
10605        return Err(std::io::Error::last_os_error().into());
10606    }
10607    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
10608    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
10609    let mut digest = Sha256::new();
10610    let mut total = 0_u64;
10611    let mut buffer = [0_u8; 64 * 1024];
10612    let copied = (|| -> std::io::Result<()> {
10613        loop {
10614            let read = input.read(&mut buffer)?;
10615            if read == 0 {
10616                break;
10617            }
10618            total = total.saturating_add(read as u64);
10619            if total > entry.bytes {
10620                return Err(std::io::Error::new(
10621                    std::io::ErrorKind::InvalidData,
10622                    "staged sync source grew beyond its verified length",
10623                ));
10624            }
10625            digest.update(&buffer[..read]);
10626            output.write_all(&buffer[..read])?;
10627        }
10628        Ok(())
10629    })();
10630    if let Err(error) = copied {
10631        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10632        return Err(error.into());
10633    }
10634    drop(output);
10635    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
10636        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10637        return Err(invalid_feed(
10638            "private staged sync source failed final integrity verification",
10639        ));
10640    }
10641    if unsafe {
10642        libc::renameat(
10643            directory.as_raw_fd(),
10644            temp.as_ptr(),
10645            directory.as_raw_fd(),
10646            leaf_name.as_ptr(),
10647        )
10648    } != 0
10649    {
10650        let error = std::io::Error::last_os_error();
10651        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
10652        return Err(error.into());
10653    }
10654    Ok(())
10655}
10656
10657#[cfg(unix)]
10658fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
10659    use std::os::fd::{AsRawFd as _, FromRawFd as _};
10660
10661    let path = &entry.path;
10662    let components: Vec<&str> = path.split('/').collect();
10663    let (leaf, parents) = components
10664        .split_last()
10665        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
10666    let mut directory = root.try_clone()?;
10667    for component in parents {
10668        directory = open_dir_at(
10669            directory.as_raw_fd(),
10670            &c_name(component.as_bytes(), path)?,
10671            path,
10672        )?;
10673    }
10674    let leaf = c_name(leaf.as_bytes(), path)?;
10675    let fd = unsafe {
10676        libc::openat(
10677            directory.as_raw_fd(),
10678            leaf.as_ptr(),
10679            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
10680        )
10681    };
10682    if fd < 0 {
10683        return Err(std::io::Error::last_os_error().into());
10684    }
10685    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
10686    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
10687        return Err(invalid_feed(
10688            "private pull stage changed before its durability barrier",
10689        ));
10690    }
10691    file.sync_all()?;
10692    Ok(())
10693}
10694
10695#[cfg(unix)]
10696fn run_pull_source_workers(
10697    root: &std::fs::File,
10698    entries: &[V2StagedFile],
10699    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
10700) -> LinkResult<()> {
10701    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
10702
10703    let next = AtomicUsize::new(0);
10704    let failed = AtomicBool::new(false);
10705    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
10706    let mut first_error = None;
10707    std::thread::scope(|scope| {
10708        let (sender, receiver) = std::sync::mpsc::channel();
10709        for _ in 0..worker_count {
10710            let sender = sender.clone();
10711            let next = &next;
10712            let failed = &failed;
10713            scope.spawn(move || {
10714                while !failed.load(Ordering::Acquire) {
10715                    let index = next.fetch_add(1, Ordering::Relaxed);
10716                    let Some(entry) = entries.get(index) else {
10717                        break;
10718                    };
10719                    let result = operation(root, entry);
10720                    if result.is_err() {
10721                        failed.store(true, Ordering::Release);
10722                    }
10723                    if sender.send(result).is_err() {
10724                        break;
10725                    }
10726                }
10727            });
10728        }
10729        drop(sender);
10730        for result in receiver {
10731            if let Err(error) = result {
10732                if first_error.is_none() {
10733                    first_error = Some(error);
10734                }
10735            }
10736        }
10737    });
10738    if let Some(error) = first_error {
10739        return Err(error);
10740    }
10741    if next.load(Ordering::Relaxed) < entries.len() {
10742        return Err(invalid_feed(
10743            "a bounded pull worker stopped before reporting every file",
10744        ));
10745    }
10746    Ok(())
10747}
10748
10749#[cfg(unix)]
10750fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
10751    use std::os::fd::AsRawFd as _;
10752
10753    for name in directory_entry_names(root)? {
10754        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
10755            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
10756            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
10757            sync_pull_directory_tree(&child, &child_display)?;
10758        }
10759    }
10760    root.sync_all()?;
10761    Ok(())
10762}
10763
10764#[cfg(unix)]
10765fn write_pull_sources_beneath_dir(
10766    root: &std::fs::File,
10767    entries: &[V2StagedFile],
10768) -> LinkResult<()> {
10769    // The stage is still private and unreachable as the checkout here. Write
10770    // and verify every leaf first so the filesystem can coalesce dirty data,
10771    // then re-open/re-hash/fsync leaves through the held root capability. Only
10772    // after every file barrier succeeds do we flush each directory once. This
10773    // preserves the same crash durability as file-fsync + directory-fsync per
10774    // path without serializing 50,000 redundant directory barriers.
10775    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
10776    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
10777    sync_pull_directory_tree(root, "v2 pull stage")
10778}
10779
10780#[cfg(unix)]
10781fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
10782    use std::os::fd::AsRawFd as _;
10783    for path in paths {
10784        if !safe_store_rel_path(path) {
10785            return Err(LinkError::UnsafePath { path: path.clone() });
10786        }
10787        let components = path.split('/').collect::<Vec<_>>();
10788        let Some((leaf, parents)) = components.split_last() else {
10789            return Err(LinkError::UnsafePath { path: path.clone() });
10790        };
10791        let mut directory = root.try_clone()?;
10792        let mut missing = false;
10793        for component in parents {
10794            let name = c_name(component.as_bytes(), path)?;
10795            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
10796                None => {
10797                    missing = true;
10798                    break;
10799                }
10800                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
10801                Some(true) => {
10802                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
10803                }
10804            }
10805        }
10806        if missing {
10807            continue;
10808        }
10809        let leaf = c_name(leaf.as_bytes(), path)?;
10810        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
10811            None => {}
10812            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
10813            Some(false) => {
10814                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
10815                    return Err(std::io::Error::last_os_error().into());
10816                }
10817                directory.sync_all()?;
10818            }
10819        }
10820    }
10821    Ok(())
10822}
10823
10824#[cfg(unix)]
10825fn install_pulled_delta(
10826    dest: &Path,
10827    entries: &[(String, Vec<u8>)],
10828    deleted: &[String],
10829    rebuild_indexes: bool,
10830) -> LinkResult<()> {
10831    use ring::rand::SecureRandom as _;
10832    use std::os::fd::AsRawFd as _;
10833    use std::os::unix::ffi::OsStrExt as _;
10834
10835    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10836    let name = dest
10837        .file_name()
10838        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10839        .ok_or_else(|| LinkError::UnsafePath {
10840            path: dest.display().to_string(),
10841        })?;
10842    let parent_dir = open_or_create_dir_nofollow(parent)?;
10843    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10844    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10845        None => false,
10846        Some(true) => true,
10847        Some(false) => {
10848            return Err(LinkError::UnsafePath {
10849                path: dest.display().to_string(),
10850            });
10851        }
10852    };
10853
10854    let mut nonce = [0_u8; 16];
10855    ring::rand::SystemRandom::new()
10856        .fill(&mut nonce)
10857        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10858    let stage_label = format!(
10859        ".{}.dbmd-pull-stage-{}",
10860        name.to_string_lossy(),
10861        URL_SAFE_NO_PAD.encode(nonce)
10862    );
10863    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10864    let stage_dir = create_dir_exclusive_at(
10865        parent_dir.as_raw_fd(),
10866        &stage_name,
10867        &dest.display().to_string(),
10868    )?;
10869
10870    let prepared = (|| -> LinkResult<()> {
10871        if dest_exists {
10872            let live = open_dir_at(
10873                parent_dir.as_raw_fd(),
10874                &dest_name,
10875                &dest.display().to_string(),
10876            )?;
10877            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10878        }
10879        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
10880        write_pull_entries_beneath_dir(&stage_dir, entries)?;
10881        if rebuild_indexes {
10882            let stage_store =
10883                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
10884                    .map_err(|error| LinkError::InvalidPack {
10885                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
10886                    })?;
10887            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
10888                LinkError::InvalidPack {
10889                    message: format!("could not materialize v2 local catalogs: {error}"),
10890                }
10891            })?;
10892        }
10893        stage_dir.sync_all()?;
10894        Ok(())
10895    })();
10896    if let Err(error) = prepared {
10897        let _ = remove_tree_at(
10898            parent_dir.as_raw_fd(),
10899            &stage_name,
10900            &dest.display().to_string(),
10901        );
10902        return Err(error);
10903    }
10904
10905    if let Err(error) =
10906        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
10907    {
10908        let _ = remove_tree_at(
10909            parent_dir.as_raw_fd(),
10910            &stage_name,
10911            &dest.display().to_string(),
10912        );
10913        return Err(error);
10914    }
10915    parent_dir.sync_all()?;
10916    if dest_exists {
10917        // Commit already happened atomically. Cleanup is best-effort so a
10918        // failure cannot be reported as a failed pull after the live tree
10919        // changed; a crash may leave only this private old-tree sibling.
10920        let _ = remove_tree_at(
10921            parent_dir.as_raw_fd(),
10922            &stage_name,
10923            &dest.display().to_string(),
10924        );
10925        let _ = parent_dir.sync_all();
10926    }
10927    Ok(())
10928}
10929
10930#[cfg(unix)]
10931fn install_pulled_delta_sources(
10932    dest: &Path,
10933    entries: &[V2StagedFile],
10934    deleted: &[String],
10935    rebuild_indexes: bool,
10936    _previous: Option<&V2SyncBaseline>,
10937    _next: &V2VerifiedHead,
10938) -> LinkResult<()> {
10939    use ring::rand::SecureRandom as _;
10940    use std::os::fd::AsRawFd as _;
10941    use std::os::unix::ffi::OsStrExt as _;
10942
10943    // An established checkout is updated only at the changed coordinates
10944    // through the durable pull journal shared with Windows. Initial clones
10945    // still use a private complete stage and one atomic directory install.
10946    if let Ok(store) = Store::open_strict(dest) {
10947        return install_established_v2_delta(
10948            store,
10949            entries,
10950            deleted,
10951            rebuild_indexes,
10952            _previous,
10953            _next,
10954        );
10955    }
10956
10957    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10958    let name = dest
10959        .file_name()
10960        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
10961        .ok_or_else(|| LinkError::UnsafePath {
10962            path: dest.display().to_string(),
10963        })?;
10964    let parent_dir = open_or_create_dir_nofollow(parent)?;
10965    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
10966    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
10967        None => false,
10968        Some(true) => true,
10969        Some(false) => {
10970            return Err(LinkError::UnsafePath {
10971                path: dest.display().to_string(),
10972            })
10973        }
10974    };
10975    let mut nonce = [0_u8; 16];
10976    ring::rand::SystemRandom::new()
10977        .fill(&mut nonce)
10978        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
10979    let stage_label = format!(
10980        ".{}.dbmd-pull-stage-{}",
10981        name.to_string_lossy(),
10982        URL_SAFE_NO_PAD.encode(nonce)
10983    );
10984    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
10985    let stage_dir = create_dir_exclusive_at(
10986        parent_dir.as_raw_fd(),
10987        &stage_name,
10988        &dest.display().to_string(),
10989    )?;
10990    let prepared = (|| -> LinkResult<()> {
10991        if dest_exists {
10992            let live = open_dir_at(
10993                parent_dir.as_raw_fd(),
10994                &dest_name,
10995                &dest.display().to_string(),
10996            )?;
10997            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
10998        }
10999        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
11000        write_pull_sources_beneath_dir(&stage_dir, entries)?;
11001        if rebuild_indexes {
11002            let stage_store =
11003                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
11004                    .map_err(|error| LinkError::InvalidPack {
11005                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
11006                    })?;
11007            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
11008                LinkError::InvalidPack {
11009                    message: format!("could not materialize v2 local catalogs: {error}"),
11010                }
11011            })?;
11012        }
11013        stage_dir.sync_all()?;
11014        Ok(())
11015    })();
11016    if let Err(error) = prepared {
11017        let _ = remove_tree_at(
11018            parent_dir.as_raw_fd(),
11019            &stage_name,
11020            &dest.display().to_string(),
11021        );
11022        return Err(error);
11023    }
11024    if let Err(error) =
11025        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
11026    {
11027        let _ = remove_tree_at(
11028            parent_dir.as_raw_fd(),
11029            &stage_name,
11030            &dest.display().to_string(),
11031        );
11032        return Err(error);
11033    }
11034    parent_dir.sync_all()?;
11035    if dest_exists {
11036        let _ = remove_tree_at(
11037            parent_dir.as_raw_fd(),
11038            &stage_name,
11039            &dest.display().to_string(),
11040        );
11041        let _ = parent_dir.sync_all();
11042    }
11043    Ok(())
11044}
11045
11046#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11047struct V2PullCoordinate {
11048    head_seq: Option<u64>,
11049    commit_hash: Option<String>,
11050    view_kind: Option<String>,
11051    view_revision: Option<String>,
11052}
11053
11054#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11055struct V2PullFileCoordinate {
11056    sha256: String,
11057    bytes: u64,
11058}
11059
11060#[derive(Debug, Clone, Deserialize, Serialize)]
11061struct V2PullJournalEntry {
11062    path: String,
11063    old: Option<V2PullFileCoordinate>,
11064    new: Option<V2PullFileCoordinate>,
11065    backup: Option<String>,
11066}
11067
11068#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
11069#[serde(rename_all = "snake_case")]
11070enum V2PullPhase {
11071    Preparing,
11072    Ready,
11073}
11074
11075#[derive(Debug, Clone, Deserialize, Serialize)]
11076struct V2PullJournal {
11077    v: u8,
11078    phase: V2PullPhase,
11079    brain: String,
11080    previous: V2PullCoordinate,
11081    next: V2PullCoordinate,
11082    backup_dir: String,
11083    entries: Vec<V2PullJournalEntry>,
11084}
11085
11086const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
11087
11088fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
11089    V2PullCoordinate {
11090        head_seq: baseline.and_then(|value| value.head_seq),
11091        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
11092        view_kind: baseline.and_then(|value| value.view_kind.clone()),
11093        view_revision: baseline.and_then(|value| value.view_revision.clone()),
11094    }
11095}
11096
11097fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
11098    V2PullCoordinate {
11099        head_seq: head.pointer.as_ref().map(|value| value.seq),
11100        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
11101        view_kind: Some(head.view_kind.clone()),
11102        view_revision: Some(head.view_revision.clone()),
11103    }
11104}
11105
11106fn v2_pull_file_coordinate(
11107    store: &Store,
11108    path: &str,
11109    limit: u64,
11110) -> LinkResult<Option<V2PullFileCoordinate>> {
11111    let file = match store.open_regular(Path::new(path)) {
11112        Ok(file) => file,
11113        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11114        Err(error) => return Err(error.into()),
11115    };
11116    let bytes = file.metadata()?.len();
11117    if bytes > limit || bytes > MAX_STORE_BYTES {
11118        return Err(invalid_feed(
11119            "pull transaction file exceeds its declared bound",
11120        ));
11121    }
11122    Ok(Some(V2PullFileCoordinate {
11123        sha256: content_sha256_reader(file)?,
11124        bytes,
11125    }))
11126}
11127
11128fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
11129    let mut bytes = serde_json::to_vec_pretty(journal)
11130        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
11131    bytes.push(b'\n');
11132    Ok(bytes)
11133}
11134
11135fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
11136    let backup_prefix = ".dbmd/pull-backup-";
11137    let suffix = journal
11138        .backup_dir
11139        .strip_prefix(backup_prefix)
11140        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
11141    let mut paths = std::collections::BTreeSet::new();
11142    if journal.v != 1
11143        || !crate::ulid::is_ulid(&journal.brain)
11144        || !crate::ulid::is_ulid(suffix)
11145        || journal.entries.is_empty()
11146        || journal.entries.len() > MAX_PUSH_FILES + 4
11147        || journal.previous == journal.next
11148    {
11149        return Err(invalid_feed("v2 pull journal failed validation"));
11150    }
11151    for (index, entry) in journal.entries.iter().enumerate() {
11152        if !safe_store_rel_path(&entry.path)
11153            || entry.path == V2_PULL_JOURNAL
11154            || entry.path.starts_with(backup_prefix)
11155            || !paths.insert(entry.path.clone())
11156            || (entry.old.is_none() && entry.new.is_none())
11157            || entry
11158                .old
11159                .iter()
11160                .chain(entry.new.iter())
11161                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
11162            || entry.backup.as_deref()
11163                != entry
11164                    .old
11165                    .as_ref()
11166                    .map(|_| format!("{index:08x}"))
11167                    .as_deref()
11168        {
11169            return Err(invalid_feed("v2 pull journal entry failed validation"));
11170        }
11171    }
11172    Ok(())
11173}
11174
11175fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
11176    #[cfg(unix)]
11177    {
11178        use std::os::unix::fs::PermissionsExt as _;
11179        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
11180            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
11181                return Err(invalid_feed(
11182                    "v2 pull journal is accessible to group/other; set mode 0600",
11183                ));
11184            }
11185            Ok(_) => {}
11186            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11187            Err(error) => return Err(error.into()),
11188        }
11189    }
11190    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
11191        Ok(bytes) => bytes,
11192        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
11193        Err(error) => return Err(error.into()),
11194    };
11195    let journal: V2PullJournal =
11196        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
11197    validate_v2_pull_journal(&journal)?;
11198    Ok(Some(journal))
11199}
11200
11201fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
11202    // The journal is the only recovery authority. Remove it first only after
11203    // rollback or baseline commit is durable; an orphan private backup is safe
11204    // and can be pruned, while a journal without its backups is not recoverable.
11205    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
11206        Ok(()) => {}
11207        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
11208        Err(error) => return Err(error.into()),
11209    }
11210    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
11211        Ok(()) => Ok(()),
11212        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
11213        Err(error) => Err(error.into()),
11214    }
11215}
11216
11217fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
11218    let names = match store.directory_names(Path::new(".dbmd")) {
11219        Ok(names) => names,
11220        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
11221        Err(error) => return Err(error.into()),
11222    };
11223    for name in names {
11224        let Some(name) = name.to_str() else {
11225            continue;
11226        };
11227        let Some(suffix) = name.strip_prefix("pull-backup-") else {
11228            continue;
11229        };
11230        if crate::ulid::is_ulid(suffix) {
11231            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
11232        }
11233    }
11234    Ok(())
11235}
11236
11237fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
11238    // Validate every live coordinate and every backup before the first restore.
11239    for entry in &journal.entries {
11240        let limit = entry
11241            .old
11242            .as_ref()
11243            .into_iter()
11244            .chain(entry.new.iter())
11245            .map(|value| value.bytes)
11246            .max()
11247            .unwrap_or(0);
11248        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
11249        if current != entry.old && current != entry.new {
11250            return Err(LinkError::InvalidPack {
11251                message: format!(
11252                    "cannot recover interrupted pull because `{}` changed afterward",
11253                    entry.path
11254                ),
11255            });
11256        }
11257        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
11258            let path = Path::new(&journal.backup_dir).join(backup);
11259            let file = store.open_regular(&path)?;
11260            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
11261                return Err(invalid_feed("v2 pull recovery backup failed verification"));
11262            }
11263        }
11264    }
11265    for entry in journal.entries.iter().rev() {
11266        match (&entry.old, &entry.backup) {
11267            (Some(old), Some(backup)) => {
11268                let bytes =
11269                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
11270                store.write_atomic(Path::new(&entry.path), &bytes)?;
11271            }
11272            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
11273                store.remove_file(Path::new(&entry.path))?;
11274            }
11275            (None, None) => {}
11276            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
11277        }
11278    }
11279    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
11280        message: format!("could not rebuild catalogs after pull recovery: {error}"),
11281    })?;
11282    cleanup_v2_pull_journal(store, journal)
11283}
11284
11285fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
11286    let Ok(store) = Store::open_strict(dest) else {
11287        return Ok(());
11288    };
11289    if let Some(journal) = load_v2_pull_journal(&store)? {
11290        if journal.brain != brain {
11291            return Err(invalid_feed("v2 pull journal belongs to another brain"));
11292        }
11293        if journal.phase == V2PullPhase::Preparing {
11294            cleanup_v2_pull_journal(&store, &journal)?;
11295        } else {
11296            let baseline = load_v2_baseline(cfg, brain, dest)?;
11297            let current = v2_pull_baseline_coordinate(baseline.as_ref());
11298            if current == journal.next {
11299                cleanup_v2_pull_journal(&store, &journal)?;
11300            } else {
11301                if current != journal.previous {
11302                    return Err(invalid_feed(
11303                        "cannot recover interrupted pull because its baseline changed afterward",
11304                    ));
11305                }
11306                rollback_v2_pull(&store, &journal)?;
11307            }
11308        }
11309    }
11310    // Cleanup removes the journal first after the state is durably old or new.
11311    // A hard kill in the tiny interval before deleting its backup directory can
11312    // therefore leave only an inert, private orphan. The per-brain operation
11313    // lock is held by the caller, so no live pull can own one here.
11314    prune_orphan_v2_pull_backups(&store)
11315}
11316
11317fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
11318    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
11319        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
11320    })?;
11321    if let Some(journal) = load_v2_pull_journal(&store)? {
11322        cleanup_v2_pull_journal(&store, &journal)?;
11323    }
11324    Ok(())
11325}
11326
11327#[cfg(windows)]
11328fn install_windows_initial_sources(
11329    dest: &Path,
11330    entries: &[V2StagedFile],
11331    rebuild_indexes: bool,
11332) -> LinkResult<()> {
11333    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
11334    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
11335        path: dest.display().to_string(),
11336    })?;
11337    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
11338    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
11339        return Err(LinkError::UnsafePath {
11340            path: dest.display().to_string(),
11341        });
11342    }
11343    let stage_name = format!(
11344        ".{}.dbmd-pull-stage-{}",
11345        name.to_string_lossy(),
11346        crate::ulid::mint()
11347    );
11348    let stage_path = parent.join(&stage_name);
11349    let stage_capability =
11350        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
11351    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
11352    let prepared = (|| -> LinkResult<()> {
11353        for entry in entries {
11354            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
11355            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
11356                return Err(invalid_feed(
11357                    "private staged sync source failed final integrity verification",
11358                ));
11359            }
11360            stage.write_atomic(Path::new(&entry.path), &bytes)?;
11361        }
11362        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
11363            .map_err(|error| LinkError::InvalidPack {
11364                message: format!("v2 staging tree is not a valid db.md store: {error}"),
11365            })?;
11366        if rebuild_indexes {
11367            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
11368                message: format!("could not materialize v2 local catalogs: {error}"),
11369            })?;
11370        }
11371        Ok(())
11372    })();
11373    if let Err(error) = prepared {
11374        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
11375        return Err(error);
11376    }
11377    crate::fsx::rename_directory_beneath(
11378        &parent_capability,
11379        Path::new(&stage_name),
11380        Path::new(name),
11381    )?;
11382    Ok(())
11383}
11384
11385fn install_established_v2_delta(
11386    store: Store,
11387    entries: &[V2StagedFile],
11388    deleted: &[String],
11389    rebuild_indexes: bool,
11390    previous: Option<&V2SyncBaseline>,
11391    next: &V2VerifiedHead,
11392) -> LinkResult<()> {
11393    if load_v2_pull_journal(&store)?.is_some() {
11394        return Err(invalid_feed(
11395            "an interrupted pull must be recovered before installing",
11396        ));
11397    }
11398    let mut sources = std::collections::BTreeMap::new();
11399    for entry in entries {
11400        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
11401            return Err(invalid_feed("pull mutation repeats a path"));
11402        }
11403    }
11404    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
11405    paths.extend(deleted.iter().cloned());
11406    paths.sort();
11407    paths.dedup();
11408    if paths.is_empty() {
11409        return Ok(());
11410    }
11411    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
11412    let mut journal = V2PullJournal {
11413        v: 1,
11414        phase: V2PullPhase::Preparing,
11415        brain: next.brain_id.clone(),
11416        previous: v2_pull_baseline_coordinate(previous),
11417        next: v2_pull_head_coordinate(next),
11418        backup_dir: backup_dir.clone(),
11419        entries: Vec::with_capacity(paths.len()),
11420    };
11421    for path in &paths {
11422        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
11423        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
11424            sha256: entry.sha256.clone(),
11425            bytes: entry.bytes,
11426        });
11427        if old == new {
11428            continue;
11429        }
11430        let index = journal.entries.len();
11431        journal.entries.push(V2PullJournalEntry {
11432            path: path.clone(),
11433            backup: old.as_ref().map(|_| format!("{index:08x}")),
11434            old,
11435            new,
11436        });
11437    }
11438    if journal.entries.is_empty() {
11439        return Ok(());
11440    }
11441    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
11442        entry
11443            .old
11444            .as_ref()
11445            .map_or(Some(total), |old| total.checked_add(old.bytes))
11446    });
11447    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
11448        return Err(LinkError::InvalidPack {
11449            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
11450        });
11451    }
11452    validate_v2_pull_journal(&journal)?;
11453    store.write_private_atomic_new(
11454        Path::new(V2_PULL_JOURNAL),
11455        &v2_pull_journal_bytes(&journal)?,
11456    )?;
11457    let prepared = (|| -> LinkResult<()> {
11458        store.create_private_dir_all(Path::new(&backup_dir))?;
11459        for entry in &journal.entries {
11460            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
11461                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
11462                if content_sha256(&bytes) != old.sha256 {
11463                    return Err(invalid_feed("live pull source changed during backup"));
11464                }
11465                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
11466            }
11467        }
11468        journal.phase = V2PullPhase::Ready;
11469        store.write_private_atomic(
11470            Path::new(V2_PULL_JOURNAL),
11471            &v2_pull_journal_bytes(&journal)?,
11472        )?;
11473        Ok(())
11474    })();
11475    if let Err(error) = prepared {
11476        let cleanup = cleanup_v2_pull_journal(&store, &journal);
11477        return match cleanup {
11478            Ok(()) => Err(error),
11479            Err(cleanup) => Err(LinkError::InvalidPack {
11480                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
11481            }),
11482        };
11483    }
11484    let installed = (|| -> LinkResult<()> {
11485        for entry in &journal.entries {
11486            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
11487                return Err(LinkError::InvalidPack {
11488                    message: format!("local path `{}` changed during pull", entry.path),
11489                });
11490            }
11491            if let Some(source) = sources.get(&entry.path) {
11492                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
11493                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
11494                    return Err(invalid_feed(
11495                        "private staged sync source failed final integrity verification",
11496                    ));
11497                }
11498                store.write_atomic(Path::new(&entry.path), &bytes)?;
11499            } else if entry.old.is_some() {
11500                store.remove_file(Path::new(&entry.path))?;
11501            }
11502        }
11503        if rebuild_indexes {
11504            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
11505                message: format!("could not materialize v2 local catalogs: {error}"),
11506            })?;
11507        }
11508        Ok(())
11509    })();
11510    if let Err(error) = installed {
11511        return match rollback_v2_pull(&store, &journal) {
11512            Ok(()) => Err(error),
11513            Err(rollback) => Err(LinkError::InvalidPack {
11514                message: format!("{error}; durable pull rollback also failed: {rollback}"),
11515            }),
11516        };
11517    }
11518    Ok(())
11519}
11520
11521#[cfg(windows)]
11522fn install_pulled_delta_sources(
11523    dest: &Path,
11524    entries: &[V2StagedFile],
11525    deleted: &[String],
11526    rebuild_indexes: bool,
11527    previous: Option<&V2SyncBaseline>,
11528    next: &V2VerifiedHead,
11529) -> LinkResult<()> {
11530    match Store::open_strict(dest) {
11531        Ok(store) => {
11532            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
11533        }
11534        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
11535    }
11536}
11537
11538#[cfg(not(any(unix, windows)))]
11539fn install_pulled_delta_sources(
11540    _dest: &Path,
11541    _entries: &[V2StagedFile],
11542    _deleted: &[String],
11543    _rebuild_indexes: bool,
11544    _previous: Option<&V2SyncBaseline>,
11545    _next: &V2VerifiedHead,
11546) -> LinkResult<()> {
11547    Err(LinkError::UnsupportedPlatform {
11548        operation: "atomic v2 pull install",
11549    })
11550}
11551
11552#[cfg(unix)]
11553fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
11554    install_pulled_delta(dest, entries, &[], false)
11555}
11556
11557#[cfg(not(windows))]
11558fn is_safe_slug(slug: &str) -> bool {
11559    !slug.is_empty()
11560        && slug.len() <= 63
11561        && !slug.starts_with('-')
11562        && !slug.ends_with('-')
11563        && slug
11564            .bytes()
11565            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
11566}
11567
11568fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
11569    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
11570}
11571
11572fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
11573    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
11574}
11575
11576fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
11577    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
11578}
11579
11580fn preflight_zip_central_directory(
11581    bytes: &[u8],
11582    offset: usize,
11583    size: usize,
11584    count: u64,
11585) -> LinkResult<()> {
11586    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
11587    let end = offset
11588        .checked_add(size)
11589        .filter(|end| *end <= bytes.len())
11590        .ok_or_else(|| LinkError::InvalidPack {
11591            message: "ZIP central directory is out of bounds".to_string(),
11592        })?;
11593    let mut cursor = offset;
11594    for _ in 0..count {
11595        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
11596            return Err(LinkError::InvalidPack {
11597                message: "ZIP central directory entry count is inconsistent".to_string(),
11598            });
11599        }
11600        if le_u16(bytes, cursor + 34) != Some(0) {
11601            return Err(LinkError::InvalidPack {
11602                message: "multi-disk ZIP archives are not supported".to_string(),
11603            });
11604        }
11605        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
11606            total.checked_add(le_u16(bytes, cursor + at)? as usize)
11607        });
11608        cursor = cursor
11609            .checked_add(46)
11610            .and_then(|fixed| fixed.checked_add(variable?))
11611            .filter(|cursor| *cursor <= end)
11612            .ok_or_else(|| LinkError::InvalidPack {
11613                message: "ZIP central directory entry is truncated".to_string(),
11614            })?;
11615    }
11616    if cursor != end {
11617        return Err(LinkError::InvalidPack {
11618            message: "ZIP central directory size is inconsistent".to_string(),
11619        });
11620    }
11621    Ok(())
11622}
11623
11624/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
11625/// metadata object per central-directory entry. Supports ordinary EOCD and the
11626/// Zip64 locator/record emitted for >65,535-entry archives.
11627fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
11628    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
11629    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
11630    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
11631    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
11632    let eocd = bytes[search_start..]
11633        .windows(4)
11634        .rposition(|window| window == EOCD_SIG)
11635        .map(|offset| search_start + offset)
11636        .ok_or_else(|| LinkError::InvalidPack {
11637            message: "ZIP has no end-of-central-directory record".to_string(),
11638        })?;
11639    let invalid_end = || LinkError::InvalidPack {
11640        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
11641    };
11642    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
11643    if eocd
11644        .checked_add(22)
11645        .and_then(|end| end.checked_add(comment_len))
11646        != Some(bytes.len())
11647    {
11648        // Do not fall back to an earlier signature. ZipArchive does, and a
11649        // fake low-count EOCD appended after a real Zip64 entry-count bomb
11650        // would otherwise bypass this allocation preflight.
11651        return Err(invalid_end());
11652    }
11653    let disk = le_u16(bytes, eocd + 4);
11654    let central_disk = le_u16(bytes, eocd + 6);
11655    if disk != Some(0) || central_disk != Some(0) {
11656        return Err(LinkError::InvalidPack {
11657            message: "multi-disk ZIP archives are not supported".to_string(),
11658        });
11659    }
11660    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
11661    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
11662    if entries_on_disk != ordinary {
11663        return Err(LinkError::InvalidPack {
11664            message: "multi-disk ZIP archives are not supported".to_string(),
11665        });
11666    }
11667    let zip64_locator = eocd
11668        .checked_sub(20)
11669        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
11670    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
11671        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
11672        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
11673        if central_offset
11674            .checked_add(central_size)
11675            .filter(|end| *end == eocd)
11676            .is_none()
11677        {
11678            return Err(invalid_end());
11679        }
11680        (ordinary as u64, central_offset, central_size)
11681    } else {
11682        let Some(locator) = zip64_locator else {
11683            return Err(invalid_end());
11684        };
11685        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
11686            return Err(LinkError::InvalidPack {
11687                message: "multi-disk ZIP64 archives are not supported".to_string(),
11688            });
11689        }
11690        let record = le_u64(bytes, locator + 8)
11691            .and_then(|offset| usize::try_from(offset).ok())
11692            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
11693            .ok_or_else(|| LinkError::InvalidPack {
11694                message: "ZIP64 archive has an invalid end record".to_string(),
11695            })?;
11696        let record_size = le_u64(bytes, record + 4)
11697            .and_then(|size| usize::try_from(size).ok())
11698            .filter(|size| *size >= 44)
11699            .ok_or_else(invalid_end)?;
11700        if record
11701            .checked_add(12)
11702            .and_then(|end| end.checked_add(record_size))
11703            != Some(locator)
11704            || le_u32(bytes, record + 16) != Some(0)
11705            || le_u32(bytes, record + 20) != Some(0)
11706        {
11707            return Err(invalid_end());
11708        }
11709        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
11710        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
11711        let central_size = le_u64(bytes, record + 40)
11712            .and_then(|size| usize::try_from(size).ok())
11713            .ok_or_else(invalid_end)?;
11714        let central_offset = le_u64(bytes, record + 48)
11715            .and_then(|offset| usize::try_from(offset).ok())
11716            .ok_or_else(invalid_end)?;
11717        if zip64_on_disk != zip64_total
11718            || central_offset
11719                .checked_add(central_size)
11720                .filter(|end| *end == record)
11721                .is_none()
11722        {
11723            return Err(invalid_end());
11724        }
11725        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
11726        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
11727        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
11728            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
11729        {
11730            return Err(invalid_end());
11731        }
11732        (zip64_total, central_offset, central_size)
11733    };
11734    if count == 0 || count > max_entries as u64 {
11735        return Err(LinkError::InvalidPack {
11736            message: format!("invalid file count {count}"),
11737        });
11738    }
11739    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
11740    Ok(())
11741}
11742
11743fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
11744    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
11745    let mut archive =
11746        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
11747            message: format!("ZIP parse failed: {err}"),
11748        })?;
11749    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
11750        return Err(LinkError::InvalidPack {
11751            message: format!("invalid file count {}", archive.len()),
11752        });
11753    }
11754    let mut total = 0u64;
11755    let mut seen = std::collections::HashSet::new();
11756    let mut entries = Vec::with_capacity(archive.len());
11757    for index in 0..archive.len() {
11758        let mut file = archive
11759            .by_index(index)
11760            .map_err(|err| LinkError::InvalidPack {
11761                message: format!("ZIP entry failed: {err}"),
11762            })?;
11763        if file.is_dir() {
11764            continue;
11765        }
11766        let path = file.name().to_string();
11767        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
11768            return Err(LinkError::UnsafePath { path });
11769        }
11770        if file
11771            .unix_mode()
11772            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
11773        {
11774            return Err(LinkError::InvalidPack {
11775                message: format!("non-file entry `{path}`"),
11776            });
11777        }
11778        if !seen.insert(path.clone()) {
11779            return Err(LinkError::InvalidPack {
11780                message: format!("duplicate path `{path}`"),
11781            });
11782        }
11783        let remaining = MAX_STORE_BYTES.saturating_sub(total);
11784        if file.size() > remaining {
11785            return Err(LinkError::InvalidPack {
11786                message: "expanded content exceeds the 512 MB limit".to_string(),
11787            });
11788        }
11789        let mut content = Vec::new();
11790        (&mut file)
11791            .take(remaining + 1)
11792            .read_to_end(&mut content)
11793            .map_err(|err| LinkError::InvalidPack {
11794                message: format!("could not decompress `{path}`: {err}"),
11795            })?;
11796        if content.len() as u64 > remaining {
11797            return Err(LinkError::InvalidPack {
11798                message: "expanded content exceeds the 512 MB limit".to_string(),
11799            });
11800        }
11801        if content.len() as u64 != file.size() {
11802            return Err(LinkError::InvalidPack {
11803                message: format!("length mismatch for `{path}`"),
11804            });
11805        }
11806        total += content.len() as u64;
11807        entries.push((path, content));
11808    }
11809    if entries.is_empty() {
11810        return Err(LinkError::InvalidPack {
11811            message: "pack contains no files".to_string(),
11812        });
11813    }
11814    Ok(entries)
11815}
11816
11817fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
11818    let mut expected = std::collections::BTreeMap::new();
11819    for file in signed {
11820        if !safe_store_rel_path(&file.path) {
11821            return Err(LinkError::UnsafePath {
11822                path: file.path.clone(),
11823            });
11824        }
11825        if !is_sha256(&file.sha256)
11826            || expected
11827                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
11828                .is_some()
11829        {
11830            return Err(invalid_feed(
11831                "signed snapshot manifest contains an invalid or duplicate file",
11832            ));
11833        }
11834    }
11835    if expected.len() != entries.len() {
11836        return Err(invalid_feed(
11837            "downloaded pack file set differs from the signed snapshot manifest",
11838        ));
11839    }
11840    for (path, bytes) in entries {
11841        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
11842            return Err(invalid_feed(format!(
11843                "downloaded pack contains unsigned path `{path}`"
11844            )));
11845        };
11846        if *declared_bytes != bytes.len() as u64
11847            || *sha256 != format!("{:x}", Sha256::digest(bytes))
11848        {
11849            return Err(invalid_feed(format!(
11850                "downloaded file `{path}` differs from its signed manifest"
11851            )));
11852        }
11853    }
11854    Ok(())
11855}
11856
11857/// Collect the files a push sends: the store's owned source text — `DB.md`,
11858/// `assets.jsonl` when present, the canonical curator timeline (`log.md` plus
11859/// rotated `log/*.md` archives), and every content `.md` under `records/` and
11860/// `sources/`. Derived `index.*` catalogs remain local because the receiver
11861/// rebuilds them. Returns `(store-relative path, content)` pairs, path-sorted.
11862pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
11863    require_hardened_filesystem("sync push")?;
11864    preflight_push_ownership(store)?;
11865    let mut out: Vec<(String, String)> = Vec::new();
11866    let mut total = 0u64;
11867
11868    let mut read_text = |rel: &str| -> LinkResult<String> {
11869        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
11870        total = total
11871            .checked_add(bytes.len() as u64)
11872            .ok_or_else(|| LinkError::PushTooLarge {
11873                detail: "uncompressed byte count overflow".to_string(),
11874            })?;
11875        if total > MAX_STORE_BYTES {
11876            return Err(LinkError::PushTooLarge {
11877                detail: format!("{total} uncompressed bytes"),
11878            });
11879        }
11880        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
11881            path: rel.to_string(),
11882        })
11883    };
11884
11885    out.push(("DB.md".to_string(), read_text("DB.md")?));
11886    if store
11887        .regular_file_exists(Path::new("assets.jsonl"))
11888        .unwrap_or(false)
11889    {
11890        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
11891    }
11892    if store
11893        .regular_file_exists(Path::new("log.md"))
11894        .unwrap_or(false)
11895    {
11896        out.push(("log.md".to_string(), read_text("log.md")?));
11897    }
11898    if store.directory_exists(Path::new("log"))? {
11899        for rel in store.walk_regular_files(Path::new("log"))? {
11900            let rel_str = rel.to_string_lossy().replace('\\', "/");
11901            if rel.extension().and_then(std::ffi::OsStr::to_str) != Some("md") {
11902                continue;
11903            }
11904            if !safe_store_rel_path(&rel_str) {
11905                return Err(LinkError::UnsafePath { path: rel_str });
11906            }
11907            let content = read_text(&rel_str)?;
11908            out.push((rel_str, content));
11909        }
11910    }
11911
11912    for rel in store.walk()? {
11913        let rel_str = rel.to_string_lossy().replace('\\', "/");
11914        if !safe_store_rel_path(&rel_str) {
11915            // A locally-legal name outside the hub's portable charset cannot
11916            // travel this wire; refusing beats silently dropping it.
11917            return Err(LinkError::UnsafePath { path: rel_str });
11918        }
11919        let content = read_text(&rel_str)?;
11920        out.push((rel_str, content));
11921    }
11922
11923    out.sort_by(|a, b| a.0.cmp(&b.0));
11924    Ok(out)
11925}
11926
11927/// Refuse to build a destructive whole-store snapshot from an ambiguous local
11928/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
11929/// silently omitted them could delete the hosted copies of those paths.
11930fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
11931    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
11932        return Err(LinkError::from(std::io::Error::new(
11933            std::io::ErrorKind::PermissionDenied,
11934            format!("cannot push: nested db.md store at {}", nested.display()),
11935        )));
11936    }
11937
11938    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
11939        return Err(LinkError::from(std::io::Error::new(
11940            std::io::ErrorKind::PermissionDenied,
11941            format!(
11942                "cannot push: {} is a symlink outside the store ownership model",
11943                symlink.display()
11944            ),
11945        )));
11946    }
11947    Ok(())
11948}
11949
11950/// Push `files` to `brain` as a whole-store snapshot — the hub's push
11951/// semantics: the hosted copy becomes exactly this set (pull first if the
11952/// hosted side may have records the local copy lacks). Client-side caps
11953/// mirror the hub's JSON-path limits so an oversized push fails before the
11954/// upload.
11955pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
11956    require_safe_ref(brain)?;
11957    let remote = verified_remote_head(cfg, brain, false)?;
11958    if files.len() > MAX_PUSH_FILES {
11959        return Err(LinkError::PushTooLarge {
11960            detail: format!("{} files", files.len()),
11961        });
11962    }
11963    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
11964    if raw_total > MAX_STORE_BYTES {
11965        return Err(LinkError::PushTooLarge {
11966            detail: format!("{raw_total} uncompressed bytes"),
11967        });
11968    }
11969
11970    // Self-custody (a brain key is configured): the JSON fast path is
11971    // hub-signed by construction, so every push goes through the pack flow
11972    // with a locally signed entry — the hub verifies and can never sign.
11973    if cfg.brain_key.is_none() {
11974        let body = json!({
11975            "files": files
11976                .iter()
11977                .map(|(p, c)| json!({ "path": p, "content": c }))
11978                .collect::<Vec<_>>(),
11979        });
11980        if body.to_string().len() <= MAX_PUSH_BYTES {
11981            let path = format!("/api/hub/brains/{brain}/push");
11982            let pushed = ensure_ok(
11983                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11984                "sync push",
11985            )?;
11986            return Ok(pushed);
11987        }
11988    }
11989
11990    let pack = build_store_pack(files)?;
11991    if pack.len() as u64 > MAX_PACK_BYTES {
11992        return Err(LinkError::PushTooLarge {
11993            detail: format!("{} pack bytes", pack.len()),
11994        });
11995    }
11996    let sha256 = format!("{:x}", Sha256::digest(&pack));
11997    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
11998    if let Some(key) = &cfg.brain_key {
11999        if !remote.head.verified {
12000            return Err(invalid_feed(
12001                "self-custody push requires a fully verified, unscoped feed head",
12002            ));
12003        }
12004        let identity = remote
12005            .identity
12006            .as_ref()
12007            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
12008        let current_multikey = format!("ed25519:{}", identity.fingerprint);
12009        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
12010            return Err(invalid_feed(
12011                "configured brain key is not the verified current brain identity",
12012            ));
12013        }
12014        // The verified state pins seq + prev; a concurrent writer surfaces as
12015        // the hub's 422 on commit (re-run to retry against the new head).
12016        let next_seq = remote
12017            .head
12018            .seq
12019            .checked_add(1)
12020            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
12021        let mut manifest: Vec<WireFeedFile> = files
12022            .iter()
12023            .map(|(path, content)| WireFeedFile {
12024                path: path.clone(),
12025                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
12026                bytes: content.len() as u64,
12027            })
12028            .collect();
12029        manifest.sort_by(|a, b| a.path.cmp(&b.path));
12030        let ts = crate::now()
12031            .with_timezone(&chrono::Utc)
12032            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
12033            .to_string();
12034        let entry = self_custody_entry(
12035            key,
12036            next_seq,
12037            ts,
12038            &sha256,
12039            &manifest,
12040            remote.head.feed_hash.as_deref(),
12041        )?;
12042        meta["entry"] = Value::String(entry);
12043    }
12044    let presigned = ensure_ok(
12045        request(
12046            cfg,
12047            "POST",
12048            &format!("/api/hub/brains/{brain}/packs/presign"),
12049            Some(&meta),
12050            Auth::Required,
12051        )?,
12052        "prepare pack upload",
12053    )?;
12054    let url = presigned
12055        .get("url")
12056        .and_then(Value::as_str)
12057        .ok_or_else(|| LinkError::InvalidPack {
12058            message: "the hub returned no upload URL".to_string(),
12059        })?;
12060    put_presigned(
12061        cfg,
12062        url,
12063        presigned.get("headers").unwrap_or(&Value::Null),
12064        &pack,
12065    )?;
12066    let committed = ensure_ok(
12067        request(
12068            cfg,
12069            "POST",
12070            &format!("/api/hub/brains/{brain}/packs/commit"),
12071            Some(&meta),
12072            Auth::Required,
12073        )?,
12074        "commit pack",
12075    )?;
12076    Ok(committed)
12077}
12078
12079fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
12080    const LOCAL_HEADER: u32 = 0x0403_4b50;
12081    const CENTRAL_HEADER: u32 = 0x0201_4b50;
12082    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
12083    const VERSION_20: u16 = 20;
12084    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
12085    const UTF8_FLAG: u16 = 1 << 11;
12086    const STORED: u16 = 0;
12087    const DOS_TIME_MIDNIGHT: u16 = 0;
12088    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
12089    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
12090
12091    struct CentralEntry<'a> {
12092        name: &'a [u8],
12093        crc32: u32,
12094        size: u32,
12095        local_offset: u32,
12096    }
12097
12098    fn push_u16(out: &mut Vec<u8>, value: u16) {
12099        out.extend_from_slice(&value.to_le_bytes());
12100    }
12101
12102    fn push_u32(out: &mut Vec<u8>, value: u32) {
12103        out.extend_from_slice(&value.to_le_bytes());
12104    }
12105
12106    if files.is_empty() {
12107        return Err(LinkError::InvalidPack {
12108            message: "cannot create an empty snapshot pack".to_string(),
12109        });
12110    }
12111    if files.len() > u16::MAX as usize {
12112        return Err(LinkError::PushTooLarge {
12113            detail: format!(
12114                "{} files (canonical ZIP32 packs cap at {})",
12115                files.len(),
12116                u16::MAX
12117            ),
12118        });
12119    }
12120
12121    let mut sorted: Vec<_> = files.iter().collect();
12122    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
12123    let mut previous: Option<&str> = None;
12124    for (path, content) in &sorted {
12125        if !safe_store_rel_path(path) {
12126            return Err(LinkError::UnsafePath {
12127                path: (*path).clone(),
12128            });
12129        }
12130        if previous == Some(path.as_str()) {
12131            return Err(LinkError::InvalidPack {
12132                message: format!("duplicate path `{path}`"),
12133            });
12134        }
12135        previous = Some(path.as_str());
12136        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
12137            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
12138        })?;
12139    }
12140
12141    let mut out = Vec::new();
12142    let mut central = Vec::with_capacity(sorted.len());
12143    for (path, content) in sorted {
12144        let name = path.as_bytes();
12145        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
12146            message: format!("ZIP entry name is too long: `{path}`"),
12147        })?;
12148        let bytes = content.as_bytes();
12149        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
12150            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
12151        })?;
12152        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
12153            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
12154        })?;
12155        let crc32 = crc32fast::hash(bytes);
12156
12157        // Canonical local header: sizes and CRC are known up front. Bit 3 is
12158        // deliberately clear, so there is no trailing data descriptor.
12159        push_u32(&mut out, LOCAL_HEADER);
12160        push_u16(&mut out, VERSION_20);
12161        push_u16(&mut out, UTF8_FLAG);
12162        push_u16(&mut out, STORED);
12163        push_u16(&mut out, DOS_TIME_MIDNIGHT);
12164        push_u16(&mut out, DOS_DATE_1980_01_01);
12165        push_u32(&mut out, crc32);
12166        push_u32(&mut out, size);
12167        push_u32(&mut out, size);
12168        push_u16(&mut out, name_len);
12169        push_u16(&mut out, 0); // no local extra data
12170        out.extend_from_slice(name);
12171        out.extend_from_slice(bytes);
12172
12173        central.push(CentralEntry {
12174            name,
12175            crc32,
12176            size,
12177            local_offset,
12178        });
12179    }
12180
12181    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
12182        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
12183    })?;
12184    for entry in &central {
12185        push_u32(&mut out, CENTRAL_HEADER);
12186        push_u16(&mut out, MADE_BY_UNIX_20);
12187        push_u16(&mut out, VERSION_20);
12188        push_u16(&mut out, UTF8_FLAG);
12189        push_u16(&mut out, STORED);
12190        push_u16(&mut out, DOS_TIME_MIDNIGHT);
12191        push_u16(&mut out, DOS_DATE_1980_01_01);
12192        push_u32(&mut out, entry.crc32);
12193        push_u32(&mut out, entry.size);
12194        push_u32(&mut out, entry.size);
12195        push_u16(&mut out, entry.name.len() as u16);
12196        push_u16(&mut out, 0); // no central extra data
12197        push_u16(&mut out, 0); // no file comment
12198        push_u16(&mut out, 0); // disk number
12199        push_u16(&mut out, 0); // internal attributes
12200        push_u32(&mut out, UNIX_REGULAR_0600);
12201        push_u32(&mut out, entry.local_offset);
12202        out.extend_from_slice(entry.name);
12203    }
12204    let central_size = u32::try_from(out.len())
12205        .ok()
12206        .and_then(|end| end.checked_sub(central_offset))
12207        .ok_or_else(|| LinkError::PushTooLarge {
12208            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
12209        })?;
12210    let entry_count = central.len() as u16;
12211
12212    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
12213    push_u16(&mut out, 0); // this disk
12214    push_u16(&mut out, 0); // central directory disk
12215    push_u16(&mut out, entry_count);
12216    push_u16(&mut out, entry_count);
12217    push_u32(&mut out, central_size);
12218    push_u32(&mut out, central_offset);
12219    push_u16(&mut out, 0); // no archive comment
12220
12221    if out.len() > u32::MAX as usize {
12222        return Err(LinkError::PushTooLarge {
12223            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
12224        });
12225    }
12226    Ok(out)
12227}
12228
12229// ─────────────────────────────────────────────────────────────────────────────
12230// grant — issue / list / revoke capabilities (owner-side)
12231// ─────────────────────────────────────────────────────────────────────────────
12232
12233/// The two capabilities a v0 hub enforces.
12234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12235pub enum Capability {
12236    /// Read the granted slice.
12237    Read,
12238    /// Read and push (whole-store; a path-scoped grant is read-only).
12239    Write,
12240}
12241
12242impl Capability {
12243    /// The wire form.
12244    pub fn as_str(self) -> &'static str {
12245        match self {
12246            Capability::Read => "read",
12247            Capability::Write => "write",
12248        }
12249    }
12250}
12251
12252/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
12253/// by email in v0 (the protocol's near-term simplification; key-named
12254/// grantees arrive with the signing layer). `scope` is a store-path prefix
12255/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
12256/// revoked.
12257pub fn grant_issue(
12258    cfg: &HubConfig,
12259    brain: &str,
12260    grantee: &str,
12261    can: Capability,
12262    scope: Option<&str>,
12263    until: Option<&str>,
12264) -> LinkResult<Value> {
12265    require_safe_ref(brain)?;
12266    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
12267    // multikey holder (link.md §6 cross-party keys — no hub account; the
12268    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
12269    // hub principal named by email.
12270    let is_key_grantee = URL_SAFE_NO_PAD
12271        .decode(grantee)
12272        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
12273        .unwrap_or(false);
12274    if let Some(head) = v2_verified_head(cfg, brain)? {
12275        if is_key_grantee {
12276            let scope = scope.unwrap_or("");
12277            let preset = match can {
12278                Capability::Read => "viewer",
12279                Capability::Write => "editor",
12280            };
12281            let entropy = format!(
12282                "{}\0{}\0{}\0{}\0{}\0{}\0{}",
12283                normalized_origin(&cfg.hub)?,
12284                head.brain_id,
12285                head.control_revision,
12286                grantee,
12287                preset,
12288                scope,
12289                until.unwrap_or("")
12290            );
12291            let mut body = json!({
12292                "context": "external",
12293                "expected_control_revision": head.control_revision,
12294                "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
12295                "preset": preset,
12296                "principal_kind": "key",
12297                "public_key": grantee,
12298                "scope": scope,
12299                "scope_kind": "prefix",
12300            });
12301            if let Some(value) = until {
12302                body["expires_at"] = json!(value);
12303            }
12304            let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
12305            let response = ensure_ok(
12306                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12307                "v2 grant issue",
12308            )?;
12309            let expected_fingerprint = identity_fingerprint(grantee)?;
12310            if response.get("v").and_then(Value::as_u64) != Some(2)
12311                || response
12312                    .get("id")
12313                    .and_then(Value::as_str)
12314                    .is_none_or(|id| !crate::ulid::is_ulid(id))
12315                || response.get("principal_kind").and_then(Value::as_str) != Some("key")
12316                || response.get("principal_id").and_then(Value::as_str)
12317                    != Some(expected_fingerprint.as_str())
12318                || response
12319                    .get("control_revision")
12320                    .and_then(Value::as_str)
12321                    .is_none_or(|value| !is_sha256(value))
12322            {
12323                return Err(invalid_feed(
12324                    "v2 grant issue response is not authority-bound",
12325                ));
12326            }
12327            return Ok(response);
12328        }
12329        // Account invitations remain a product workflow. The compatibility
12330        // endpoint resolves email/account identity, then commits a native v2
12331        // grant with the same control-plane recovery boundary. The v2 head
12332        // above is already the authenticated authority checkpoint; never
12333        // fall through to the retired v1 feed verifier for this request.
12334        let mut body = json!({ "email": grantee, "capability": can.as_str() });
12335        if let Some(value) = scope {
12336            body["scopePrefix"] = json!(value);
12337        }
12338        if let Some(value) = until {
12339            body["expiresAt"] = json!(value);
12340        }
12341        let path = format!("/api/hub/brains/{}/grants", head.brain_id);
12342        return ensure_ok(
12343            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12344            "account grant issue",
12345        );
12346    }
12347    let _ = verified_remote_head(cfg, brain, false)?;
12348    let mut body = if is_key_grantee {
12349        json!({ "keySpki": grantee, "capability": can.as_str() })
12350    } else {
12351        json!({ "email": grantee, "capability": can.as_str() })
12352    };
12353    if let Some(s) = scope {
12354        body["scopePrefix"] = json!(s);
12355    }
12356    if let Some(u) = until {
12357        body["expiresAt"] = json!(u);
12358    }
12359    let path = format!("/api/hub/brains/{brain}/grants");
12360    ensure_ok(
12361        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
12362        "grant issue",
12363    )
12364}
12365
12366/// List the active grants (and pending invites) on `brain`. Owner-side.
12367pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
12368    require_safe_ref(brain)?;
12369    if let Some(head) = v2_verified_head(cfg, brain)? {
12370        let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
12371        let response = ensure_ok(
12372            request(cfg, "GET", &path, None, Auth::Required)?,
12373            "v2 grant list",
12374        )?;
12375        if response.get("v").and_then(Value::as_u64) != Some(2)
12376            || response.get("control_revision").and_then(Value::as_str)
12377                != Some(head.control_revision.as_str())
12378            || !response.get("grants").is_some_and(Value::is_array)
12379        {
12380            return Err(invalid_feed(
12381                "v2 grant list is not bound to the verified authority",
12382            ));
12383        }
12384        return Ok(response);
12385    }
12386    let _ = verified_remote_head(cfg, brain, false)?;
12387    let path = format!("/api/hub/brains/{brain}/grants");
12388    ensure_ok(
12389        request(cfg, "GET", &path, None, Auth::Required)?,
12390        "grant list",
12391    )
12392}
12393
12394/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
12395/// is soft on the hub (the audit trail survives).
12396pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
12397    require_safe_ref(brain)?;
12398    require_safe_grant_id(grant_id)?;
12399    if let Some(head) = v2_verified_head(cfg, brain)? {
12400        let entropy = format!(
12401            "{}\0{}\0{}\0{}",
12402            normalized_origin(&cfg.hub)?,
12403            head.brain_id,
12404            head.control_revision,
12405            grant_id
12406        );
12407        let body = json!({
12408            "expected_control_revision": head.control_revision,
12409            "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
12410        });
12411        let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
12412        let response = ensure_ok(
12413            request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12414            "v2 grant revoke",
12415        )?;
12416        if response.get("v").and_then(Value::as_u64) != Some(2)
12417            || response.get("id").and_then(Value::as_str) != Some(grant_id)
12418            || response.get("revoked").and_then(Value::as_bool) != Some(true)
12419            || response
12420                .get("control_revision")
12421                .and_then(Value::as_str)
12422                .is_none_or(|value| !is_sha256(value))
12423        {
12424            return Err(invalid_feed(
12425                "v2 grant revocation response is not authority-bound",
12426            ));
12427        }
12428        return Ok(response);
12429    }
12430    let _ = verified_remote_head(cfg, brain, false)?;
12431    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
12432    ensure_ok(
12433        request(cfg, "DELETE", &path, None, Auth::Required)?,
12434        "grant revoke",
12435    )
12436}
12437
12438// ─────────────────────────────────────────────────────────────────────────────
12439// permissioned v2 proposals — inspect, accept, reject
12440// ─────────────────────────────────────────────────────────────────────────────
12441
12442#[derive(Debug)]
12443struct VerifiedV2Proposal {
12444    value: Value,
12445    changes: Value,
12446    blobs: Vec<(String, u64, String)>,
12447}
12448
12449fn require_proposal_id(id: &str) -> LinkResult<()> {
12450    if crate::ulid::is_ulid(id) {
12451        Ok(())
12452    } else {
12453        Err(invalid_feed("proposal id is not a lowercase ULID"))
12454    }
12455}
12456
12457fn verified_v2_proposal(
12458    cfg: &HubConfig,
12459    head: &V2VerifiedHead,
12460    proposal_id: &str,
12461) -> LinkResult<VerifiedV2Proposal> {
12462    require_proposal_id(proposal_id)?;
12463    if head.view_kind != "full" {
12464        return Err(invalid_feed(
12465            "proposal review requires a full readable view",
12466        ));
12467    }
12468    let path = format!(
12469        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12470        head.brain_id
12471    );
12472    let value = ensure_ok(
12473        request_capped(
12474            cfg,
12475            "GET",
12476            &path,
12477            None,
12478            Auth::Required,
12479            MAX_FEED_RESPONSE_BYTES,
12480        )?,
12481        "v2 proposal",
12482    )?;
12483    verify_v2_proposal_value(head, proposal_id, value)
12484}
12485
12486fn verify_v2_proposal_value(
12487    head: &V2VerifiedHead,
12488    proposal_id: &str,
12489    value: Value,
12490) -> LinkResult<VerifiedV2Proposal> {
12491    if value.get("v").and_then(Value::as_u64) != Some(2) {
12492        return Err(invalid_feed("proposal response has an invalid version"));
12493    }
12494    let proposal = value
12495        .get("proposal")
12496        .and_then(Value::as_object)
12497        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
12498    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
12499        return Err(invalid_feed("proposal response changed its id"));
12500    }
12501    let payload_hash = proposal
12502        .get("payload_sha256")
12503        .and_then(Value::as_str)
12504        .filter(|hash| is_sha256(hash))
12505        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
12506    let clear_hash = proposal
12507        .get("clear_sha256")
12508        .and_then(Value::as_str)
12509        .filter(|hash| is_sha256(hash))
12510        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
12511    let submission_hash = proposal
12512        .get("submission_claim_sha256")
12513        .and_then(Value::as_str)
12514        .filter(|hash| is_sha256(hash))
12515        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
12516    let submission = STANDARD
12517        .decode(
12518            proposal
12519                .get("submission_claim_base64")
12520                .and_then(Value::as_str)
12521                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
12522        )
12523        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
12524    let submission_value: Value = serde_json::from_slice(&submission)
12525        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
12526    if crate::linkmd_v2::canonical_bytes(&submission_value)
12527        .map_err(|error| invalid_feed(error.to_string()))?
12528        != submission
12529        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
12530            .map_err(|error| invalid_feed(error.to_string()))?
12531            != submission_hash
12532    {
12533        return Err(invalid_feed(
12534            "proposal submission claim is not canonical or addressed",
12535        ));
12536    }
12537    let envelope = submission_value
12538        .as_object()
12539        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
12540    let claim = envelope
12541        .get("claim")
12542        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
12543    let claim_object = claim
12544        .as_object()
12545        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
12546    let actor_root = claim_object
12547        .get("actor_root")
12548        .and_then(Value::as_object)
12549        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
12550    let public_key = envelope
12551        .get("public_key")
12552        .and_then(Value::as_str)
12553        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
12554    let fingerprint = envelope
12555        .get("fingerprint")
12556        .and_then(Value::as_str)
12557        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
12558    let signature = envelope
12559        .get("sig")
12560        .and_then(Value::as_str)
12561        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
12562    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
12563        .map_err(|error| invalid_feed(error.to_string()))?;
12564    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
12565    let signer = format!("{fingerprint}:{public_key}");
12566    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
12567    let grants = actor_root.get("grants").and_then(Value::as_array);
12568    let grants_are_canonical = grants.is_some_and(|items| {
12569        let mut prior: Option<&str> = None;
12570        items.iter().all(|item| {
12571            let Some(grant) = item.as_str() else {
12572                return false;
12573            };
12574            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
12575                return false;
12576            }
12577            prior = Some(grant);
12578            true
12579        })
12580    });
12581    let optional_actor_field = |name: &str| {
12582        actor_root.get(name).is_some_and(|value| {
12583            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
12584        })
12585    };
12586    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
12587    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
12588        || format!("{:x}", Sha256::digest(&der)) != fingerprint
12589        || head
12590            .trust
12591            .hub_signer
12592            .as_ref()
12593            .is_some_and(|known| known != &signer)
12594        || !matches!(
12595            actor_class,
12596            Some(
12597                "user"
12598                    | "owned_agent"
12599                    | "foreign_key"
12600                    | "curation"
12601                    | "inbox"
12602                    | "restore"
12603                    | "migration"
12604                    | "operator_recovery"
12605            )
12606        )
12607        || actor_root
12608            .get("principal")
12609            .and_then(Value::as_str)
12610            .is_none_or(|value| value.is_empty())
12611        || actor_root
12612            .get("credential")
12613            .and_then(Value::as_str)
12614            .is_none_or(|value| value.is_empty())
12615        || !optional_actor_field("organization")
12616        || !optional_actor_field("role")
12617        || !grants_are_canonical
12618        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
12619        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12620        || !claim_object
12621            .get("mutation_id")
12622            .and_then(Value::as_str)
12623            .is_some_and(|value| {
12624                !value.is_empty()
12625                    && value.len() <= 128
12626                    && value.chars().enumerate().all(|(index, char)| {
12627                        char.is_ascii_alphanumeric()
12628                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
12629                    })
12630            })
12631        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
12632        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
12633        || !claim_object
12634            .get("control_revision")
12635            .and_then(Value::as_str)
12636            .is_some_and(is_sha256)
12637        || submitted_at.is_none_or(|value| {
12638            chrono::DateTime::parse_from_rfc3339(value).is_err()
12639                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
12640        })
12641        || !proposal
12642            .get("state")
12643            .and_then(Value::as_str)
12644            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
12645        || proposal
12646            .get("expires_at")
12647            .and_then(Value::as_str)
12648            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
12649        || proposal
12650            .get("proposer")
12651            .and_then(Value::as_object)
12652            .and_then(|value| value.get("class"))
12653            .and_then(Value::as_str)
12654            != actor_class
12655    {
12656        return Err(invalid_feed(
12657            "proposal submission claim does not bind the verified proposal",
12658        ));
12659    }
12660    let changes_b64 = proposal
12661        .get("changes_base64")
12662        .and_then(Value::as_str)
12663        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
12664    let changes_bytes = STANDARD
12665        .decode(changes_b64)
12666        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
12667    let changes: Value = serde_json::from_slice(&changes_bytes)
12668        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
12669    if crate::linkmd_v2::canonical_bytes(&changes)
12670        .map_err(|error| invalid_feed(error.to_string()))?
12671        != changes_bytes
12672        || changes.get("v").and_then(Value::as_u64) != Some(2)
12673        || !changes.get("operations").is_some_and(Value::is_array)
12674    {
12675        return Err(invalid_feed("proposal changeset is not canonical v2"));
12676    }
12677    let blob_values = proposal
12678        .get("blobs")
12679        .and_then(Value::as_array)
12680        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
12681    let mut blobs = Vec::with_capacity(blob_values.len());
12682    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
12683    let mut prior_hash: Option<String> = None;
12684    for item in blob_values {
12685        let hash = item
12686            .get("sha256")
12687            .and_then(Value::as_str)
12688            .filter(|hash| is_sha256(hash))
12689            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
12690        let bytes = item
12691            .get("bytes")
12692            .and_then(Value::as_u64)
12693            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
12694            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
12695        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
12696            return Err(invalid_feed(
12697                "proposal blob declarations are not unique and sorted",
12698            ));
12699        }
12700        prior_hash = Some(hash.to_string());
12701        let endpoint = item
12702            .get("endpoint")
12703            .and_then(Value::as_str)
12704            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
12705        let expected_endpoint = format!(
12706            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
12707            head.brain_id
12708        );
12709        if endpoint != expected_endpoint {
12710            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
12711        }
12712        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
12713        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
12714    }
12715    let descriptor = json!({
12716        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
12717        "blobs": descriptor_blobs,
12718        "changes_base64": changes_b64,
12719        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
12720        "v": 2,
12721    });
12722    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
12723        .map_err(|error| invalid_feed(error.to_string()))?;
12724    if content_sha256(&descriptor_bytes) != clear_hash {
12725        return Err(invalid_feed(
12726            "proposal clear payload differs from its signed submission claim",
12727        ));
12728    }
12729    Ok(VerifiedV2Proposal {
12730        value,
12731        changes,
12732        blobs,
12733    })
12734}
12735
12736pub fn proposal_list(
12737    cfg: &HubConfig,
12738    brain: &str,
12739    state: &str,
12740    after: Option<&str>,
12741    limit: usize,
12742) -> LinkResult<Value> {
12743    require_safe_ref(brain)?;
12744    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
12745        return Err(invalid_feed("proposal state is invalid"));
12746    }
12747    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
12748        return Err(invalid_feed("proposal cursor is invalid"));
12749    }
12750    let head = v2_verified_head(cfg, brain)?
12751        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12752    let path = format!(
12753        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
12754        head.brain_id,
12755        limit.clamp(1, 100),
12756        after.map_or_else(String::new, |value| format!("&after={value}"))
12757    );
12758    ensure_ok(
12759        request_capped(
12760            cfg,
12761            "GET",
12762            &path,
12763            None,
12764            Auth::Required,
12765            MAX_FEED_RESPONSE_BYTES,
12766        )?,
12767        "v2 proposal list",
12768    )
12769}
12770
12771pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
12772    require_safe_ref(brain)?;
12773    let head = v2_verified_head(cfg, brain)?
12774        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12775    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
12776}
12777
12778pub fn proposal_reject(
12779    cfg: &HubConfig,
12780    brain: &str,
12781    proposal_id: &str,
12782    mutation_id: &str,
12783    reason: &str,
12784) -> LinkResult<Value> {
12785    require_safe_ref(brain)?;
12786    require_proposal_id(proposal_id)?;
12787    let head = v2_verified_head(cfg, brain)?
12788        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12789    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
12790    let body = json!({
12791        "mutation_id": mutation_id,
12792        "control_revision": head.control_revision,
12793        "reason": reason,
12794    });
12795    let path = format!(
12796        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
12797        head.brain_id
12798    );
12799    ensure_ok(
12800        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
12801        "v2 proposal rejection",
12802    )
12803}
12804
12805pub fn proposal_accept_exact(
12806    cfg: &HubConfig,
12807    brain: &str,
12808    proposal_id: &str,
12809    mutation_id: &str,
12810    reason: &str,
12811) -> LinkResult<Value> {
12812    require_safe_ref(brain)?;
12813    require_proposal_id(proposal_id)?;
12814    let head = v2_verified_head(cfg, brain)?
12815        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
12816    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
12817    let operations = proposal
12818        .changes
12819        .get("operations")
12820        .and_then(Value::as_array)
12821        .cloned()
12822        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
12823    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
12824        return Err(invalid_feed("proposal operation count is invalid"));
12825    }
12826    let mut downloaded = std::collections::BTreeMap::new();
12827    for (hash, bytes, endpoint) in &proposal.blobs {
12828        let body = ensure_raw_ok(
12829            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
12830            "v2 proposal blob",
12831        )?;
12832        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
12833            return Err(invalid_feed("proposal blob does not match its declaration"));
12834        }
12835        downloaded.insert(hash.clone(), body);
12836    }
12837    let remote = files_for_v2_view(
12838        &head,
12839        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
12840    );
12841    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
12842    let mut expected_candidate = remote.clone();
12843    let mut expected_candidate_assets = remote_assets;
12844    for operation in &operations {
12845        let op = operation
12846            .get("op")
12847            .and_then(Value::as_str)
12848            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
12849        match op {
12850            "put" | "put_asset_content" | "restore" => {
12851                let path = operation
12852                    .get("path")
12853                    .and_then(Value::as_str)
12854                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
12855                crate::linkmd_v2::normalize_path(path)
12856                    .map_err(|error| invalid_feed(error.to_string()))?;
12857                let hash = operation
12858                    .get("blob")
12859                    .and_then(Value::as_str)
12860                    .filter(|hash| is_sha256(hash))
12861                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
12862                let bytes = operation
12863                    .get("bytes")
12864                    .and_then(Value::as_u64)
12865                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
12866                expected_candidate.insert(
12867                    path.to_string(),
12868                    V2BaselineFile {
12869                        sha256: hash.to_string(),
12870                        bytes,
12871                        proof: None,
12872                    },
12873                );
12874            }
12875            "delete" | "withdraw_from_hosting" => {
12876                let path = operation
12877                    .get("path")
12878                    .and_then(Value::as_str)
12879                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
12880                crate::linkmd_v2::normalize_path(path)
12881                    .map_err(|error| invalid_feed(error.to_string()))?;
12882                expected_candidate.remove(path);
12883            }
12884            "rename" => {
12885                let from = operation
12886                    .get("from")
12887                    .and_then(Value::as_str)
12888                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
12889                let to = operation
12890                    .get("to")
12891                    .and_then(Value::as_str)
12892                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
12893                crate::linkmd_v2::normalize_path(from)
12894                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
12895                    .map_err(|error| invalid_feed(error.to_string()))?;
12896                let hash = operation
12897                    .get("blob")
12898                    .and_then(Value::as_str)
12899                    .filter(|hash| is_sha256(hash))
12900                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
12901                let bytes = operation
12902                    .get("bytes")
12903                    .and_then(Value::as_u64)
12904                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
12905                expected_candidate.remove(from);
12906                expected_candidate.insert(
12907                    to.to_string(),
12908                    V2BaselineFile {
12909                        sha256: hash.to_string(),
12910                        bytes,
12911                        proof: None,
12912                    },
12913                );
12914            }
12915            "asset_delete" => {
12916                let path = operation
12917                    .get("path")
12918                    .and_then(Value::as_str)
12919                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
12920                expected_candidate_assets.remove(path);
12921            }
12922            "asset_withdraw" => {
12923                let path = operation
12924                    .get("path")
12925                    .and_then(Value::as_str)
12926                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
12927                if !expected_candidate_assets.contains_key(path) {
12928                    return Err(invalid_feed("proposal withdraws an unknown asset"));
12929                }
12930                let Some(asset) = operation.get("asset").and_then(Value::as_object) else {
12931                    // Pre-supersession proposals carried no replacement leaf.
12932                    // Preserve their exact historical meaning while all new
12933                    // pushes include the explicit withheld value.
12934                    let prior = expected_candidate_assets
12935                        .get_mut(path)
12936                        .expect("presence checked above");
12937                    prior.disposition = "withheld".to_string();
12938                    prior.leaf_hash.clear();
12939                    continue;
12940                };
12941                let blob_sha256 = asset
12942                    .get("blob_sha256")
12943                    .and_then(Value::as_str)
12944                    .filter(|hash| is_sha256(hash))
12945                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
12946                let bytes = asset
12947                    .get("bytes")
12948                    .and_then(Value::as_u64)
12949                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
12950                let media_type = asset
12951                    .get("media_type")
12952                    .and_then(Value::as_str)
12953                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
12954                let wrappers = asset
12955                    .get("wrappers")
12956                    .and_then(Value::as_array)
12957                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
12958                    .iter()
12959                    .map(|wrapper| {
12960                        wrapper
12961                            .as_str()
12962                            .map(str::to_string)
12963                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
12964                    })
12965                    .collect::<LinkResult<Vec<_>>>()?;
12966                let required = asset
12967                    .get("required")
12968                    .and_then(Value::as_bool)
12969                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
12970                if asset.get("disposition").and_then(Value::as_str) != Some("withheld") {
12971                    return Err(invalid_feed("proposal asset withdrawal is not withheld"));
12972                }
12973                expected_candidate_assets.insert(
12974                    path.to_string(),
12975                    V2BaselineAsset {
12976                        blob_sha256: blob_sha256.to_string(),
12977                        bytes,
12978                        media_type: media_type.to_string(),
12979                        wrappers,
12980                        required,
12981                        disposition: "withheld".to_string(),
12982                        leaf_hash: String::new(),
12983                    },
12984                );
12985            }
12986            "asset_put" | "asset_resume" => {
12987                let path = operation
12988                    .get("path")
12989                    .and_then(Value::as_str)
12990                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
12991                let asset = operation
12992                    .get("asset")
12993                    .and_then(Value::as_object)
12994                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
12995                let blob_sha256 = asset
12996                    .get("blob_sha256")
12997                    .and_then(Value::as_str)
12998                    .filter(|hash| is_sha256(hash))
12999                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
13000                let bytes = asset
13001                    .get("bytes")
13002                    .and_then(Value::as_u64)
13003                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
13004                let media_type = asset
13005                    .get("media_type")
13006                    .and_then(Value::as_str)
13007                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
13008                let wrappers = asset
13009                    .get("wrappers")
13010                    .and_then(Value::as_array)
13011                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
13012                    .iter()
13013                    .map(|wrapper| {
13014                        wrapper
13015                            .as_str()
13016                            .map(str::to_string)
13017                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
13018                    })
13019                    .collect::<LinkResult<Vec<_>>>()?;
13020                let required = asset
13021                    .get("required")
13022                    .and_then(Value::as_bool)
13023                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
13024                let disposition = asset
13025                    .get("disposition")
13026                    .and_then(Value::as_str)
13027                    .filter(|value| matches!(*value, "hosted" | "withheld"))
13028                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
13029                expected_candidate_assets.insert(
13030                    path.to_string(),
13031                    V2BaselineAsset {
13032                        blob_sha256: blob_sha256.to_string(),
13033                        bytes,
13034                        media_type: media_type.to_string(),
13035                        wrappers,
13036                        required,
13037                        disposition: disposition.to_string(),
13038                        leaf_hash: String::new(),
13039                    },
13040                );
13041            }
13042            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
13043        }
13044    }
13045    let base = head.pointer.as_ref().map(|pointer| {
13046        json!({
13047            "seq": pointer.seq,
13048            "commit_hash": pointer.commit_hash,
13049            "content_root": pointer.content_root,
13050            "asset_root": pointer.asset_root,
13051        })
13052    });
13053    let mut body = json!({
13054        "mutation_id": mutation_id,
13055        "base": base,
13056        "rebase": "strict",
13057        "reason": reason,
13058        "operations": operations,
13059        "blobs": downloaded
13060            .iter()
13061            .map(|(sha256, bytes)| json!({
13062                "sha256": sha256,
13063                "bytes": bytes.len(),
13064                "content_base64": STANDARD.encode(bytes),
13065            }))
13066            .collect::<Vec<_>>(),
13067        "proposal_id": proposal_id,
13068        "proposal_mode": "exact",
13069    });
13070    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
13071        total
13072            .checked_add(bytes.len())
13073            .ok_or_else(|| LinkError::PushTooLarge {
13074                detail: "proposal changed-byte total overflow".to_string(),
13075            })
13076    })?;
13077    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
13078        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
13079        for operation in &operations {
13080            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
13081                return Err(invalid_feed("proposal upload operation has no kind"));
13082            };
13083            let hash = match kind {
13084                "put" | "put_asset_content" | "restore" | "rename" => {
13085                    operation.get("blob").and_then(Value::as_str)
13086                }
13087                "asset_put" | "asset_resume" => operation
13088                    .get("asset")
13089                    .and_then(|asset| asset.get("blob_sha256"))
13090                    .and_then(Value::as_str),
13091                _ => None,
13092            };
13093            let Some(hash) = hash else { continue };
13094            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
13095            if kind == "rename" {
13096                for field in ["from", "to"] {
13097                    coordinates.insert(
13098                        operation
13099                            .get(field)
13100                            .and_then(Value::as_str)
13101                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
13102                            .to_string(),
13103                    );
13104                }
13105            } else {
13106                let path = operation
13107                    .get("path")
13108                    .and_then(Value::as_str)
13109                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
13110                coordinates.insert(if kind.starts_with("asset_") {
13111                    format!("assets/{path}")
13112                } else {
13113                    path.to_string()
13114                });
13115            }
13116        }
13117        let declarations = downloaded
13118            .iter()
13119            .map(|(sha256, bytes)| {
13120                json!({
13121                    "sha256": sha256,
13122                    "bytes": bytes.len(),
13123                    "coordinates": coordinates_by_hash
13124                        .get(sha256)
13125                        .into_iter()
13126                        .flatten()
13127                        .collect::<Vec<_>>(),
13128                })
13129            })
13130            .collect::<Vec<_>>();
13131        let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
13132        for batch in batch_upload_declarations(declarations) {
13133            let reserved = reserve_upload_window(
13134                cfg,
13135                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
13136                &json!({ "blobs": batch }),
13137                "prepare proposal blob transport",
13138            )?;
13139            let reserved_items = reserved
13140                .get("uploads")
13141                .and_then(Value::as_array)
13142                .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
13143            items.extend(reserved_items.iter().cloned());
13144        }
13145        if items.len() != downloaded.len() {
13146            return Err(invalid_feed("proposal upload reservation changed the set"));
13147        }
13148        let mut references = Vec::with_capacity(items.len());
13149        for item in items {
13150            let hash = item
13151                .get("sha256")
13152                .and_then(Value::as_str)
13153                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
13154            let bytes = downloaded
13155                .get(hash)
13156                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
13157            let reservation_id = item
13158                .get("reservation_id")
13159                .and_then(Value::as_str)
13160                .filter(|id| crate::ulid::is_ulid(id))
13161                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
13162            let expected_coordinates = coordinates_by_hash
13163                .get(hash)
13164                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
13165            let returned_coordinates = item
13166                .get("coordinates")
13167                .and_then(Value::as_array)
13168                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
13169            if returned_coordinates.len() != expected_coordinates.len()
13170                || returned_coordinates
13171                    .iter()
13172                    .zip(expected_coordinates)
13173                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
13174            {
13175                return Err(invalid_feed(
13176                    "proposal upload reservation changed its coordinates",
13177                ));
13178            }
13179            match item.get("status").and_then(Value::as_str) {
13180                Some("upload") => put_presigned(
13181                    cfg,
13182                    item.get("url")
13183                        .and_then(Value::as_str)
13184                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
13185                    item.get("headers").unwrap_or(&Value::Null),
13186                    bytes,
13187                )?,
13188                Some("already_present") => {}
13189                _ => return Err(invalid_feed("proposal upload status is invalid")),
13190            }
13191            references.push(json!({
13192                "sha256": hash,
13193                "bytes": bytes.len(),
13194                "reservation_id": reservation_id,
13195            }));
13196        }
13197        body["blobs"] = Value::Array(references);
13198    }
13199    // A proposal can be queued through the staged lane, so acceptance has to be
13200    // able to restate it. Capping only this side would make a large proposal
13201    // permanently unacceptable.
13202    stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
13203    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
13204    let mut result = ensure_ok(
13205        request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
13206        "exact proposal acceptance",
13207    )?;
13208    let mut candidate_hub_signer = None;
13209    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
13210        let request_id = result
13211            .get("request_id")
13212            .and_then(Value::as_str)
13213            .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
13214            .to_string();
13215        let challenge = result
13216            .get("signing_challenge")
13217            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
13218        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
13219            cfg,
13220            &head,
13221            &expected_candidate,
13222            &expected_candidate_assets,
13223            mutation_id,
13224            &v2_signed_request_view(&body, &operations),
13225            challenge,
13226        )?;
13227        body["signing_challenge_id"] = Value::String(challenge_id);
13228        body["signature_base64url"] = Value::String(signature);
13229        candidate_hub_signer = Some(actor_signer);
13230        result = ensure_ok(
13231            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
13232            "signed exact proposal acceptance",
13233        )?;
13234    }
13235    let refreshed = v2_verified_head(cfg, brain)?
13236        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
13237    if candidate_hub_signer
13238        .as_ref()
13239        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
13240        || refreshed
13241            .pointer
13242            .as_ref()
13243            .map(|pointer| pointer.commit_hash.as_str())
13244            != result.get("commit_hash").and_then(Value::as_str)
13245        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
13246        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
13247    {
13248        return Err(LinkError::RemoteAdvancedDuringSync);
13249    }
13250    accept_v2_head(cfg, &refreshed)?;
13251    Ok(result)
13252}
13253
13254// ─────────────────────────────────────────────────────────────────────────────
13255// propose — write without trust: evidence into the owner's inbox
13256// ─────────────────────────────────────────────────────────────────────────────
13257
13258/// Submit `body` to the published site `handle`, addressed to its app page
13259/// `app` (a page that declares the `write-inbox` capability). Deliberately
13260/// unauthenticated — this is the cross-party door; the submission lands as
13261/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
13262/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
13263/// receipt.
13264pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
13265    require_valid_handle(handle)?;
13266    if body.len() as u64 > MAX_PROPOSE_BYTES {
13267        return Err(LinkError::ProposeTooLarge {
13268            bytes: body.len() as u64,
13269        });
13270    }
13271    let payload = json!({ "app": app, "body": body });
13272    // A ULID-shaped target is a bare brain address (link.md §7.4's
13273    // generalization): the brain inbox door, open on public brains, where a
13274    // configured credential earns a bigger actor-class budget. Anything else
13275    // is a published-site handle: that door is unauthenticated by design.
13276    let (path, auth) = if crate::ulid::is_ulid(handle) {
13277        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
13278    } else {
13279        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
13280    };
13281    ensure_ok(
13282        request(cfg, "POST", &path, Some(&payload), auth)?,
13283        "propose",
13284    )
13285}
13286
13287// ─────────────────────────────────────────────────────────────────────────────
13288// subscribe — follow feed-head movement
13289// ─────────────────────────────────────────────────────────────────────────────
13290
13291/// One observation of a brain's feed head.
13292#[derive(Debug, serde::Serialize)]
13293pub struct Head {
13294    /// The brain id.
13295    pub brain: String,
13296    /// The hub's durable feed cursor — advances on every accepted write.
13297    pub seq: u64,
13298    /// The hub's `updatedAt` for the brain, when present.
13299    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
13300    pub updated_at: Option<String>,
13301    /// SHA-256 of the exact signed head entry.
13302    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
13303    pub feed_hash: Option<String>,
13304    /// Whether the head entry's content hash, identity, and Ed25519 signature
13305    /// were verified locally. Path-scoped grants get head movement only.
13306    pub verified: bool,
13307}
13308
13309struct BoundedVecVisitor<T, const MAX: usize> {
13310    label: &'static str,
13311    marker: std::marker::PhantomData<T>,
13312}
13313
13314impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
13315where
13316    T: Deserialize<'de>,
13317{
13318    type Value = Vec<T>;
13319
13320    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13321        write!(formatter, "at most {MAX} {}", self.label)
13322    }
13323
13324    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
13325    where
13326        A: serde::de::SeqAccess<'de>,
13327    {
13328        if sequence.size_hint().is_some_and(|size| size > MAX) {
13329            return Err(serde::de::Error::custom(format!(
13330                "{} exceeds the {MAX}-item limit",
13331                self.label
13332            )));
13333        }
13334        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
13335        while let Some(value) = sequence.next_element()? {
13336            if values.len() == MAX {
13337                return Err(serde::de::Error::custom(format!(
13338                    "{} exceeds the {MAX}-item limit",
13339                    self.label
13340                )));
13341            }
13342            values.push(value);
13343        }
13344        Ok(values)
13345    }
13346}
13347
13348fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
13349    deserializer: D,
13350    label: &'static str,
13351) -> Result<Vec<T>, D::Error>
13352where
13353    D: serde::Deserializer<'de>,
13354    T: Deserialize<'de>,
13355{
13356    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
13357        label,
13358        marker: std::marker::PhantomData,
13359    })
13360}
13361
13362fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
13363where
13364    D: serde::Deserializer<'de>,
13365{
13366    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
13367}
13368
13369fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
13370where
13371    D: serde::Deserializer<'de>,
13372{
13373    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
13374}
13375
13376fn deserialize_previous_identities<'de, D>(
13377    deserializer: D,
13378) -> Result<Vec<PreviousIdentity>, D::Error>
13379where
13380    D: serde::Deserializer<'de>,
13381{
13382    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
13383        deserializer,
13384        "previous identities",
13385    )
13386}
13387
13388fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
13389where
13390    D: serde::Deserializer<'de>,
13391{
13392    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
13393        deserializer,
13394        "rotation statements",
13395    )
13396}
13397
13398fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
13399where
13400    D: serde::Deserializer<'de>,
13401{
13402    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
13403}
13404
13405#[derive(Debug, Clone, Deserialize, Serialize)]
13406struct FeedFile {
13407    path: String,
13408    sha256: String,
13409    bytes: u64,
13410}
13411
13412#[cfg(test)]
13413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13414enum V1DisclosureError {
13415    DuplicateFile,
13416    DuplicateRemoved,
13417    PushManifestMismatch,
13418    EditMissingChange,
13419    EditFalseFile,
13420    RemovedMismatch,
13421}
13422
13423/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
13424/// the exact previous and resulting pack manifests. `edit` accepts the
13425/// historical superset form while still requiring every actual changed path.
13426#[cfg(test)]
13427fn verify_v1_manifest_disclosure(
13428    kind: &str,
13429    previous: &[FeedFile],
13430    resulting: &[FeedFile],
13431    files: &[FeedFile],
13432    removed: &[String],
13433) -> Result<(), V1DisclosureError> {
13434    fn as_map(
13435        files: &[FeedFile],
13436    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
13437        let mut result = std::collections::BTreeMap::new();
13438        for file in files {
13439            if result
13440                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
13441                .is_some()
13442            {
13443                return Err(V1DisclosureError::DuplicateFile);
13444            }
13445        }
13446        Ok(result)
13447    }
13448    let previous = as_map(previous)?;
13449    let resulting = as_map(resulting)?;
13450    let disclosed = as_map(files)?;
13451    let removed_set: std::collections::BTreeSet<&str> =
13452        removed.iter().map(String::as_str).collect();
13453    if removed_set.len() != removed.len() {
13454        return Err(V1DisclosureError::DuplicateRemoved);
13455    }
13456    let expected_removed: std::collections::BTreeSet<&str> = previous
13457        .keys()
13458        .copied()
13459        .filter(|path| !resulting.contains_key(path))
13460        .collect();
13461    if removed_set != expected_removed {
13462        return Err(V1DisclosureError::RemovedMismatch);
13463    }
13464    if kind == "push" {
13465        return if disclosed == resulting {
13466            Ok(())
13467        } else {
13468            Err(V1DisclosureError::PushManifestMismatch)
13469        };
13470    }
13471    if kind != "edit" {
13472        return Err(V1DisclosureError::EditFalseFile);
13473    }
13474    if disclosed
13475        .iter()
13476        .any(|(path, value)| resulting.get(path) != Some(value))
13477    {
13478        return Err(V1DisclosureError::EditFalseFile);
13479    }
13480    for (path, value) in &resulting {
13481        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
13482            return Err(V1DisclosureError::EditMissingChange);
13483        }
13484    }
13485    Ok(())
13486}
13487
13488#[derive(Debug, Clone, Deserialize, Serialize)]
13489struct FeedEntry {
13490    v: u8,
13491    seq: u64,
13492    ts: String,
13493    brain: String,
13494    public_key: String,
13495    kind: String,
13496    op: String,
13497    pack_sha256: String,
13498    #[serde(deserialize_with = "deserialize_feed_files")]
13499    files: Vec<FeedFile>,
13500    #[serde(deserialize_with = "deserialize_removed_paths")]
13501    removed: Vec<String>,
13502    prev_entry_hash: Option<String>,
13503    sig: String,
13504}
13505
13506#[derive(Serialize)]
13507struct UnsignedFeedEntry<'a> {
13508    v: u8,
13509    seq: u64,
13510    ts: &'a str,
13511    brain: &'a str,
13512    public_key: &'a str,
13513    kind: &'a str,
13514    op: &'a str,
13515    pack_sha256: &'a str,
13516    files: &'a [FeedFile],
13517    removed: &'a [String],
13518    prev_entry_hash: &'a Option<String>,
13519}
13520
13521#[derive(Debug, Clone, Deserialize, Serialize)]
13522struct FeedItem {
13523    hash: String,
13524    entry: FeedEntry,
13525}
13526
13527#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13528struct FeedIdentity {
13529    fingerprint: String,
13530    #[serde(rename = "publicKeySpki")]
13531    public_key_spki: String,
13532    /// Rotation history (link.md §9.1): identities this brain previously
13533    /// signed as. Entries verify against current OR previous — rotation
13534    /// never invalidates history.
13535    #[serde(default, deserialize_with = "deserialize_previous_identities")]
13536    previous: Vec<PreviousIdentity>,
13537    /// Exact normative rotation statements, oldest first. A list of previous
13538    /// public keys without these old-key signatures is not a trust chain.
13539    #[serde(default, deserialize_with = "deserialize_rotations")]
13540    rotations: Vec<String>,
13541}
13542
13543#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
13544struct PreviousIdentity {
13545    fingerprint: String,
13546    #[serde(rename = "publicKeySpki")]
13547    public_key_spki: String,
13548}
13549
13550#[derive(Debug, Deserialize)]
13551struct FeedResponse {
13552    #[serde(rename = "headSeq")]
13553    head_seq: u64,
13554    #[serde(rename = "feedHash")]
13555    feed_hash: Option<String>,
13556    identity: Option<FeedIdentity>,
13557    #[serde(deserialize_with = "deserialize_feed_items")]
13558    entries: Vec<FeedItem>,
13559    #[serde(rename = "scopeLimited")]
13560    scope_limited: bool,
13561}
13562
13563#[derive(Debug, Deserialize, Serialize)]
13564#[serde(deny_unknown_fields)]
13565struct RotationStatement {
13566    v: u8,
13567    op: String,
13568    brain: String,
13569    public_key: String,
13570    new_brain: String,
13571    new_public_key: String,
13572    prior_head_seq: u64,
13573    prior_feed_hash: Option<String>,
13574    ts: String,
13575    sig: String,
13576}
13577
13578#[derive(Debug, Clone, Deserialize, Serialize)]
13579struct TrustState {
13580    v: u8,
13581    origin: String,
13582    /// The exact caller-visible ref whose resolution this checkpoint pins.
13583    /// Slugs/handles are mutable names at the hub; once observed, they may not
13584    /// silently resolve to a different canonical brain.
13585    #[serde(default)]
13586    requested: String,
13587    /// Canonical hub brain id returned for `requested`.
13588    brain: String,
13589    /// Federation home origin when this ref was learned from a registry.
13590    /// Once observed, the registry cannot silently relocate the same handle.
13591    #[serde(default, skip_serializing_if = "Option::is_none")]
13592    home: Option<String>,
13593    anchor: String,
13594    current: String,
13595    #[serde(rename = "headSeq")]
13596    head_seq: u64,
13597    #[serde(rename = "feedHash")]
13598    feed_hash: Option<String>,
13599    /// Exact accepted old-key-signed rotation statements, oldest first. v2
13600    /// checkpoints require this vector to be an immutable prefix of every
13601    /// subsequently served identity chain.
13602    #[serde(default)]
13603    rotations: Vec<String>,
13604    /// Hub pointer-signing identity pinned on first v2 observation. This is
13605    /// separate from the brain key, which signs commits.
13606    #[serde(default, skip_serializing_if = "Option::is_none")]
13607    hub_signer: Option<String>,
13608    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
13609    /// are recognized by their pinned hub signer during one-way migration.
13610    #[serde(default, skip_serializing_if = "Option::is_none")]
13611    protocol_profile: Option<String>,
13612}
13613
13614fn accepted_as_v2(state: &TrustState) -> bool {
13615    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
13616}
13617
13618fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
13619    let directory = open_trust_dir(cfg)?;
13620    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
13621        return Ok(true);
13622    }
13623    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
13624        return Ok(false);
13625    };
13626    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
13627}
13628
13629#[derive(Debug, Clone, Deserialize, Serialize)]
13630struct AliasBinding {
13631    v: u8,
13632    origin: String,
13633    requested: String,
13634    brain: String,
13635    #[serde(default, skip_serializing_if = "Option::is_none")]
13636    home: Option<String>,
13637}
13638
13639struct VerifiedRemote {
13640    head: Head,
13641    identity: Option<FeedIdentity>,
13642    head_entry: Option<FeedItem>,
13643    /// Present when the caller requested and verified the complete chain.
13644    entries: Vec<FeedItem>,
13645    anchor: Option<String>,
13646}
13647
13648fn invalid_feed(message: impl Into<String>) -> LinkError {
13649    LinkError::InvalidFeed {
13650        message: message.into(),
13651    }
13652}
13653
13654fn is_sha256(value: &str) -> bool {
13655    value.len() == 64
13656        && value
13657            .bytes()
13658            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
13659}
13660
13661fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
13662    let der = URL_SAFE_NO_PAD
13663        .decode(public_key_spki)
13664        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
13665    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
13666        return Err(invalid_feed(
13667            "identity public key is not a valid Ed25519 SPKI",
13668        ));
13669    }
13670    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
13671}
13672
13673/// Verify the hub's current identity and every old-key-signed rotation that
13674/// leads from the original TOFU anchor to it. `previous` alone is metadata,
13675/// never authority.
13676fn verify_identity_chain(
13677    identity: &FeedIdentity,
13678    pinned: Option<&TrustState>,
13679) -> LinkResult<String> {
13680    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
13681        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
13682    {
13683        return Err(invalid_feed(
13684            "identity rotation history exceeds the client cap",
13685        ));
13686    }
13687    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
13688        return Err(invalid_feed(
13689            "current identity fingerprint does not match its public key",
13690        ));
13691    }
13692    for previous in &identity.previous {
13693        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
13694            return Err(invalid_feed(
13695                "previous identity fingerprint does not match its public key",
13696            ));
13697        }
13698    }
13699    if identity.rotations.len() != identity.previous.len() {
13700        return Err(invalid_feed(
13701            "identity history is missing an old-key-signed rotation statement",
13702        ));
13703    }
13704
13705    // The HTTP identity lists prior identities newest first. Rotation
13706    // statements are chronological, so verification walks the reversed list
13707    // and ends at the current identity.
13708    let mut chain: Vec<(&str, &str)> = identity
13709        .previous
13710        .iter()
13711        .rev()
13712        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
13713        .collect();
13714    chain.push((&identity.fingerprint, &identity.public_key_spki));
13715
13716    for (index, raw) in identity.rotations.iter().enumerate() {
13717        let statement: RotationStatement = serde_json::from_str(raw)
13718            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
13719        let (old_fingerprint, old_spki) = chain[index];
13720        let (new_fingerprint, new_spki) = chain[index + 1];
13721        if statement.v != 1
13722            || statement.op != "rotate"
13723            || statement.brain != format!("ed25519:{old_fingerprint}")
13724            || statement.public_key != old_spki
13725            || statement.new_brain != format!("ed25519:{new_fingerprint}")
13726            || statement.new_public_key != new_spki
13727            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
13728            || (statement.prior_head_seq > 0
13729                && statement
13730                    .prior_feed_hash
13731                    .as_deref()
13732                    .is_none_or(|hash| !is_sha256(hash)))
13733        {
13734            return Err(invalid_feed(
13735                "rotation statement does not connect adjacent identities",
13736            ));
13737        }
13738        let unsigned = serde_json::to_string(&UnsignedRotation {
13739            v: statement.v,
13740            op: &statement.op,
13741            brain: &statement.brain,
13742            public_key: &statement.public_key,
13743            new_brain: &statement.new_brain,
13744            new_public_key: &statement.new_public_key,
13745            prior_head_seq: statement.prior_head_seq,
13746            prior_feed_hash: statement.prior_feed_hash.as_deref(),
13747            ts: statement.ts.clone(),
13748        })
13749        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
13750        let exact = format!(
13751            "{},\"sig\":\"{}\"}}",
13752            &unsigned[..unsigned.len() - 1],
13753            statement.sig
13754        );
13755        if exact != *raw {
13756            return Err(invalid_feed(
13757                "rotation statement is not in normative serialization",
13758            ));
13759        }
13760        let der = URL_SAFE_NO_PAD
13761            .decode(old_spki)
13762            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
13763        let signature = URL_SAFE_NO_PAD
13764            .decode(&statement.sig)
13765            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
13766        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
13767            .verify(unsigned.as_bytes(), &signature)
13768            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
13769        if index > 0 {
13770            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
13771                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
13772            if statement.prior_head_seq < prior.prior_head_seq {
13773                return Err(invalid_feed("rotation feed boundaries move backward"));
13774            }
13775        }
13776    }
13777
13778    let anchor = format!("ed25519:{}", chain[0].0);
13779    let current = format!("ed25519:{}", identity.fingerprint);
13780    if let Some(pin) = pinned {
13781        if pin.anchor != anchor {
13782            return Err(invalid_feed(
13783                "served identity chain does not descend from the pinned anchor",
13784            ));
13785        }
13786        if !chain
13787            .iter()
13788            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
13789        {
13790            return Err(invalid_feed(
13791                "served identity chain forked away from the last pinned identity",
13792            ));
13793        }
13794        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
13795            return Err(invalid_feed("served identity discarded its rotation chain"));
13796        }
13797        if pin.v >= 2
13798            && (identity.rotations.len() < pin.rotations.len()
13799                || identity.rotations[..pin.rotations.len()] != pin.rotations)
13800        {
13801            return Err(invalid_feed(
13802                "served identity rewrote the locally accepted rotation history",
13803            ));
13804        }
13805    }
13806    Ok(anchor)
13807}
13808
13809fn verify_rotation_feed_boundaries(
13810    identity: &FeedIdentity,
13811    pinned: Option<&TrustState>,
13812    observed: &[FeedItem],
13813    advertised_seq: u64,
13814) -> LinkResult<()> {
13815    let mut chain: Vec<String> = identity
13816        .previous
13817        .iter()
13818        .rev()
13819        .map(|previous| format!("ed25519:{}", previous.fingerprint))
13820        .collect();
13821    chain.push(format!("ed25519:{}", identity.fingerprint));
13822    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
13823
13824    for (index, raw) in identity.rotations.iter().enumerate() {
13825        let rotation: RotationStatement = serde_json::from_str(raw)
13826            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13827        if rotation.prior_head_seq > advertised_seq {
13828            return Err(invalid_feed(
13829                "rotation claims a feed boundary beyond the advertised head",
13830            ));
13831        }
13832        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
13833            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
13834                return Err(invalid_feed(
13835                    "newly disclosed rotation predates the local feed checkpoint",
13836                ));
13837            }
13838        }
13839        let actual = if rotation.prior_head_seq == 0 {
13840            None
13841        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
13842            pinned.and_then(|pin| pin.feed_hash.as_deref())
13843        } else {
13844            observed
13845                .iter()
13846                .find(|item| item.entry.seq == rotation.prior_head_seq)
13847                .map(|item| item.hash.as_str())
13848        };
13849        if let Some(actual) = actual {
13850            if rotation.prior_feed_hash.as_deref() != Some(actual) {
13851                return Err(invalid_feed(
13852                    "rotation statement does not commit the verified feed boundary",
13853                ));
13854            }
13855        } else if rotation.prior_head_seq == 0 {
13856            // The empty-feed boundary is represented by (0, null); there is
13857            // no entry hash to find in `observed`.
13858        } else if pinned.is_some_and(|pin| {
13859            pinned_index.is_some_and(|pin_index| index >= pin_index)
13860                || rotation.prior_head_seq >= pin.head_seq
13861        }) {
13862            return Err(invalid_feed(
13863                "rotation feed boundary was not present in the verified chain",
13864            ));
13865        }
13866    }
13867    Ok(())
13868}
13869
13870/// Once a client has checkpointed identity K, identities retired before K may
13871/// verify old history but can never regain authority over a later sequence.
13872/// This is also the safe migration rule for legacy v1 checkpoints that did not
13873/// persist the exact accepted rotation statements.
13874fn reject_retired_signer_after_checkpoint(
13875    identity: &FeedIdentity,
13876    pinned: Option<&TrustState>,
13877    item: &FeedItem,
13878) -> LinkResult<()> {
13879    let Some(pin) = pinned else {
13880        return Ok(());
13881    };
13882    if item.entry.seq <= pin.head_seq {
13883        return Ok(());
13884    }
13885    let mut chain: Vec<String> = identity
13886        .previous
13887        .iter()
13888        .rev()
13889        .map(|previous| format!("ed25519:{}", previous.fingerprint))
13890        .collect();
13891    chain.push(format!("ed25519:{}", identity.fingerprint));
13892    let pinned_index = chain
13893        .iter()
13894        .position(|key| key == &pin.current)
13895        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
13896    let signer_index = chain
13897        .iter()
13898        .position(|key| key == &item.entry.brain)
13899        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
13900    if signer_index < pinned_index {
13901        return Err(invalid_feed(
13902            "a retired identity attempted to sign after the local checkpoint",
13903        ));
13904    }
13905    Ok(())
13906}
13907
13908fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
13909    let origin = normalized_origin(&cfg.hub)?;
13910    let key = format!(
13911        "{:x}",
13912        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
13913    );
13914    Ok(format!("{key}.json"))
13915}
13916
13917fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
13918    let origin = normalized_origin(&cfg.hub)?;
13919    let key = format!(
13920        "{:x}",
13921        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
13922    );
13923    Ok(format!("alias-{key}.json"))
13924}
13925
13926#[cfg(any(unix, windows))]
13927struct TrustLock {
13928    _file: std::fs::File,
13929}
13930
13931#[cfg(unix)]
13932fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13933    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13934
13935    let lock_string = format!(".{state_name}.lock");
13936    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
13937    let fd = unsafe {
13938        libc::openat(
13939            directory.as_raw_fd(),
13940            lock_name.as_ptr(),
13941            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13942            0o600,
13943        )
13944    };
13945    if fd < 0 {
13946        return Err(std::io::Error::last_os_error().into());
13947    }
13948    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13949    if !file.metadata()?.is_file() {
13950        return Err(LinkError::UnsafePath { path: lock_string });
13951    }
13952    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
13953        return Err(std::io::Error::last_os_error().into());
13954    }
13955    Ok(TrustLock { _file: file })
13956}
13957
13958#[cfg(windows)]
13959fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
13960    let lock_name = format!(".{state_name}.lock");
13961    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
13962    Ok(TrustLock { _file: file })
13963}
13964
13965#[cfg(any(unix, windows))]
13966fn lock_trust_many(
13967    cfg: &HubConfig,
13968    directory: &std::fs::File,
13969    refs: &[&str],
13970) -> LinkResult<Vec<TrustLock>> {
13971    let mut names = refs
13972        .iter()
13973        .map(|reference| trust_file_name(cfg, reference))
13974        .collect::<LinkResult<Vec<_>>>()?;
13975    names.sort();
13976    names.dedup();
13977    names
13978        .iter()
13979        .map(|name| lock_trust_name(directory, name))
13980        .collect()
13981}
13982
13983#[cfg(not(any(unix, windows)))]
13984fn lock_trust_many(
13985    _cfg: &HubConfig,
13986    _directory: &TrustDirectory,
13987    _refs: &[&str],
13988) -> LinkResult<Vec<()>> {
13989    Err(LinkError::UnsupportedPlatform {
13990        operation: "verified link.md state",
13991    })
13992}
13993
13994#[cfg(any(unix, windows))]
13995type TrustDirectory = std::fs::File;
13996
13997#[cfg(not(any(unix, windows)))]
13998struct TrustDirectory;
13999
14000#[cfg(unix)]
14001fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
14002    use std::os::fd::AsRawFd as _;
14003
14004    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
14005    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
14006        return Err(std::io::Error::last_os_error().into());
14007    }
14008    directory.sync_all()?;
14009    Ok(directory)
14010}
14011
14012#[cfg(windows)]
14013fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
14014    let marker = cfg.state_dir.join("trust").join(".directory");
14015    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
14016    Ok(crate::fsx::open_directory_nofollow(
14017        marker.parent().expect("trust marker has a parent"),
14018    )?)
14019}
14020
14021#[cfg(not(any(unix, windows)))]
14022fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
14023    Err(LinkError::UnsupportedPlatform {
14024        operation: "verified link.md state",
14025    })
14026}
14027
14028#[cfg(unix)]
14029fn load_trust_in(
14030    cfg: &HubConfig,
14031    directory: &TrustDirectory,
14032    requested: &str,
14033) -> LinkResult<Option<TrustState>> {
14034    use std::os::fd::{AsRawFd as _, FromRawFd as _};
14035
14036    let name_string = trust_file_name(cfg, requested)?;
14037    let name = c_name(name_string.as_bytes(), &name_string)?;
14038    let fd = unsafe {
14039        libc::openat(
14040            directory.as_raw_fd(),
14041            name.as_ptr(),
14042            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14043        )
14044    };
14045    if fd < 0 {
14046        let error = std::io::Error::last_os_error();
14047        if error.kind() == std::io::ErrorKind::NotFound {
14048            return Ok(None);
14049        }
14050        return Err(LinkError::UnsafePath { path: name_string });
14051    }
14052    let file = unsafe { std::fs::File::from_raw_fd(fd) };
14053    if !file.metadata()?.is_file() {
14054        return Err(LinkError::UnsafePath { path: name_string });
14055    }
14056    let mut bytes = Vec::new();
14057    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
14058    if bytes.len() > 1024 * 1024 {
14059        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
14060    }
14061    let mut state: TrustState = serde_json::from_slice(&bytes)
14062        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
14063    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
14064        return Err(invalid_feed(
14065            "local identity/feed checkpoint does not match this hub and brain",
14066        ));
14067    }
14068    if state.v == 1 {
14069        // Legacy files were keyed by canonical brain id. They can migrate only
14070        // when the caller used that exact id; old slug lookups had no durable
14071        // alias binding and therefore cannot be guessed safely.
14072        if state.brain != requested {
14073            return Err(invalid_feed(
14074                "legacy checkpoint is not bound to the requested brain id",
14075            ));
14076        }
14077        state.requested = requested.to_string();
14078    } else if state.requested != requested {
14079        return Err(invalid_feed(
14080            "local identity/feed checkpoint is bound to a different requested ref",
14081        ));
14082    }
14083    Ok(Some(state))
14084}
14085
14086#[cfg(windows)]
14087fn load_trust_in(
14088    cfg: &HubConfig,
14089    directory: &TrustDirectory,
14090    requested: &str,
14091) -> LinkResult<Option<TrustState>> {
14092    let name = trust_file_name(cfg, requested)?;
14093    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
14094    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
14095        Ok(bytes) => bytes,
14096        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
14097        Err(_) => return Err(LinkError::UnsafePath { path: name }),
14098    };
14099    let mut state: TrustState = serde_json::from_slice(&bytes)
14100        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
14101    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
14102        return Err(invalid_feed(
14103            "local identity/feed checkpoint does not match this hub and brain",
14104        ));
14105    }
14106    if state.v == 1 {
14107        if state.brain != requested {
14108            return Err(invalid_feed(
14109                "legacy checkpoint is not bound to the requested brain id",
14110            ));
14111        }
14112        state.requested = requested.to_string();
14113    } else if state.requested != requested {
14114        return Err(invalid_feed(
14115            "local identity/feed checkpoint is bound to a different requested ref",
14116        ));
14117    }
14118    Ok(Some(state))
14119}
14120
14121#[cfg(not(any(unix, windows)))]
14122fn load_trust_in(
14123    _cfg: &HubConfig,
14124    _directory: &TrustDirectory,
14125    _brain: &str,
14126) -> LinkResult<Option<TrustState>> {
14127    Err(LinkError::UnsupportedPlatform {
14128        operation: "verified link.md state",
14129    })
14130}
14131
14132#[cfg(all(test, any(unix, windows)))]
14133fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
14134    let directory = open_trust_dir(cfg)?;
14135    load_trust_in(cfg, &directory, requested)
14136}
14137
14138#[cfg(unix)]
14139fn save_trust_in(
14140    cfg: &HubConfig,
14141    directory: &TrustDirectory,
14142    state: &TrustState,
14143) -> LinkResult<()> {
14144    use std::os::fd::{AsRawFd as _, FromRawFd as _};
14145
14146    let name_string = trust_file_name(cfg, &state.requested)?;
14147    let name = c_name(name_string.as_bytes(), &name_string)?;
14148    let mut bytes = serde_json::to_vec(state)
14149        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
14150    bytes.push(b'\n');
14151
14152    let nonce = std::time::SystemTime::now()
14153        .duration_since(std::time::UNIX_EPOCH)
14154        .unwrap_or_default()
14155        .as_nanos();
14156    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
14157    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
14158    let fd = unsafe {
14159        libc::openat(
14160            directory.as_raw_fd(),
14161            temp.as_ptr(),
14162            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14163            0o600,
14164        )
14165    };
14166    if fd < 0 {
14167        return Err(std::io::Error::last_os_error().into());
14168    }
14169    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
14170    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
14171        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14172        return Err(error.into());
14173    }
14174    drop(file);
14175    if unsafe {
14176        libc::renameat(
14177            directory.as_raw_fd(),
14178            temp.as_ptr(),
14179            directory.as_raw_fd(),
14180            name.as_ptr(),
14181        )
14182    } != 0
14183    {
14184        let error = std::io::Error::last_os_error();
14185        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14186        return Err(error.into());
14187    }
14188    directory.sync_all()?;
14189    Ok(())
14190}
14191
14192#[cfg(windows)]
14193fn save_trust_in(
14194    cfg: &HubConfig,
14195    directory: &TrustDirectory,
14196    state: &TrustState,
14197) -> LinkResult<()> {
14198    let name = trust_file_name(cfg, &state.requested)?;
14199    let mut bytes = serde_json::to_vec(state)
14200        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
14201    bytes.push(b'\n');
14202    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
14203    Ok(())
14204}
14205
14206#[cfg(not(any(unix, windows)))]
14207fn save_trust_in(
14208    _cfg: &HubConfig,
14209    _directory: &TrustDirectory,
14210    _state: &TrustState,
14211) -> LinkResult<()> {
14212    Err(LinkError::UnsupportedPlatform {
14213        operation: "verified link.md state",
14214    })
14215}
14216
14217#[cfg(unix)]
14218fn load_alias_in(
14219    cfg: &HubConfig,
14220    directory: &TrustDirectory,
14221    requested: &str,
14222) -> LinkResult<Option<AliasBinding>> {
14223    use std::os::fd::{AsRawFd as _, FromRawFd as _};
14224
14225    let name_string = alias_file_name(cfg, requested)?;
14226    let name = c_name(name_string.as_bytes(), &name_string)?;
14227    let fd = unsafe {
14228        libc::openat(
14229            directory.as_raw_fd(),
14230            name.as_ptr(),
14231            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14232        )
14233    };
14234    if fd < 0 {
14235        let error = std::io::Error::last_os_error();
14236        if error.kind() == std::io::ErrorKind::NotFound {
14237            return Ok(None);
14238        }
14239        return Err(LinkError::UnsafePath { path: name_string });
14240    }
14241    let file = unsafe { std::fs::File::from_raw_fd(fd) };
14242    if !file.metadata()?.is_file() {
14243        return Err(LinkError::UnsafePath { path: name_string });
14244    }
14245    let mut bytes = Vec::new();
14246    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
14247    if bytes.len() > 64 * 1024 {
14248        return Err(invalid_feed("local alias binding is oversized"));
14249    }
14250    let alias: AliasBinding = serde_json::from_slice(&bytes)
14251        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
14252    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
14253    {
14254        return Err(invalid_feed(
14255            "local alias binding does not match this hub and requested ref",
14256        ));
14257    }
14258    Ok(Some(alias))
14259}
14260
14261#[cfg(windows)]
14262fn load_alias_in(
14263    cfg: &HubConfig,
14264    directory: &TrustDirectory,
14265    requested: &str,
14266) -> LinkResult<Option<AliasBinding>> {
14267    let name = alias_file_name(cfg, requested)?;
14268    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
14269    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
14270        Ok(bytes) => bytes,
14271        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
14272        Err(_) => return Err(LinkError::UnsafePath { path: name }),
14273    };
14274    let alias: AliasBinding = serde_json::from_slice(&bytes)
14275        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
14276    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
14277    {
14278        return Err(invalid_feed(
14279            "local alias binding does not match this hub and requested ref",
14280        ));
14281    }
14282    Ok(Some(alias))
14283}
14284
14285#[cfg(not(any(unix, windows)))]
14286fn load_alias_in(
14287    _cfg: &HubConfig,
14288    _directory: &TrustDirectory,
14289    _requested: &str,
14290) -> LinkResult<Option<AliasBinding>> {
14291    Err(LinkError::UnsupportedPlatform {
14292        operation: "verified link.md state",
14293    })
14294}
14295
14296#[cfg(unix)]
14297fn save_alias_in(
14298    cfg: &HubConfig,
14299    directory: &TrustDirectory,
14300    alias: &AliasBinding,
14301) -> LinkResult<()> {
14302    use std::os::fd::{AsRawFd as _, FromRawFd as _};
14303
14304    let name_string = alias_file_name(cfg, &alias.requested)?;
14305    let name = c_name(name_string.as_bytes(), &name_string)?;
14306    let mut bytes = serde_json::to_vec(alias)
14307        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
14308    bytes.push(b'\n');
14309    let nonce = std::time::SystemTime::now()
14310        .duration_since(std::time::UNIX_EPOCH)
14311        .unwrap_or_default()
14312        .as_nanos();
14313    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
14314    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
14315    let fd = unsafe {
14316        libc::openat(
14317            directory.as_raw_fd(),
14318            temp.as_ptr(),
14319            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14320            0o600,
14321        )
14322    };
14323    if fd < 0 {
14324        return Err(std::io::Error::last_os_error().into());
14325    }
14326    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
14327    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
14328        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14329        return Err(error.into());
14330    }
14331    drop(file);
14332    if unsafe {
14333        libc::renameat(
14334            directory.as_raw_fd(),
14335            temp.as_ptr(),
14336            directory.as_raw_fd(),
14337            name.as_ptr(),
14338        )
14339    } != 0
14340    {
14341        let error = std::io::Error::last_os_error();
14342        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
14343        return Err(error.into());
14344    }
14345    directory.sync_all()?;
14346    Ok(())
14347}
14348
14349#[cfg(windows)]
14350fn save_alias_in(
14351    cfg: &HubConfig,
14352    directory: &TrustDirectory,
14353    alias: &AliasBinding,
14354) -> LinkResult<()> {
14355    let name = alias_file_name(cfg, &alias.requested)?;
14356    let mut bytes = serde_json::to_vec(alias)
14357        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
14358    bytes.push(b'\n');
14359    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
14360    Ok(())
14361}
14362
14363#[cfg(not(any(unix, windows)))]
14364fn save_alias_in(
14365    _cfg: &HubConfig,
14366    _directory: &TrustDirectory,
14367    _alias: &AliasBinding,
14368) -> LinkResult<()> {
14369    Err(LinkError::UnsupportedPlatform {
14370        operation: "verified link.md state",
14371    })
14372}
14373
14374/// Load the canonical checkpoint shared by every spelling of one brain and
14375/// the separate alias binding. This also performs the one-way migration from
14376/// pre-v3 checkpoints that stored a full trust state under the alias itself.
14377/// Callers hold both alias and canonical locks before entering.
14378fn load_canonical_pin(
14379    cfg: &HubConfig,
14380    directory: &TrustDirectory,
14381    requested: &str,
14382    resolved_brain: &str,
14383) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
14384    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
14385    if requested == resolved_brain {
14386        return Ok((canonical, None));
14387    }
14388
14389    let mut alias = load_alias_in(cfg, directory, requested)?;
14390    if let Some(binding) = &alias {
14391        if binding.brain != resolved_brain {
14392            return Err(LinkError::AliasRebindRequired {
14393                alias: requested.to_string(),
14394                from: binding.brain.clone(),
14395                to: resolved_brain.to_string(),
14396            });
14397        }
14398        return Ok((canonical, alias));
14399    }
14400
14401    // A v2 build stored the complete checkpoint under the requested slug.
14402    // Promote it to the canonical ULID key before creating the lightweight
14403    // alias binding. Never silently merge two independently advanced states.
14404    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
14405        if legacy.brain != resolved_brain {
14406            return Err(invalid_feed(
14407                "legacy alias checkpoint names a different canonical brain",
14408            ));
14409        }
14410        if let Some(existing) = &canonical {
14411            if existing.brain != legacy.brain
14412                || existing.anchor != legacy.anchor
14413                || existing.current != legacy.current
14414                || existing.head_seq != legacy.head_seq
14415                || existing.feed_hash != legacy.feed_hash
14416                || existing.rotations != legacy.rotations
14417            {
14418                return Err(invalid_feed(
14419                    "legacy alias checkpoint conflicts with the canonical checkpoint",
14420                ));
14421            }
14422        } else {
14423            let mut promoted = legacy.clone();
14424            promoted.requested = resolved_brain.to_string();
14425            promoted.home = None;
14426            save_trust_in(cfg, directory, &promoted)?;
14427            canonical = Some(promoted);
14428        }
14429        alias = Some(AliasBinding {
14430            v: 1,
14431            origin: normalized_origin(&cfg.hub)?,
14432            requested: requested.to_string(),
14433            brain: resolved_brain.to_string(),
14434            home: legacy.home,
14435        });
14436        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
14437    }
14438    Ok((canonical, alias))
14439}
14440
14441/// Explicitly replace one mutable alias binding after the operator reviews the
14442/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
14443/// or rewritten, so old history remains pinned and the new brain establishes
14444/// its own independently verified trust chain.
14445pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
14446    require_hardened_filesystem("verified alias rebind")?;
14447    require_safe_ref(alias)?;
14448    require_safe_ref(from)?;
14449    require_safe_ref(to)?;
14450    if crate::ulid::is_ulid(alias)
14451        || !crate::ulid::is_ulid(from)
14452        || !crate::ulid::is_ulid(to)
14453        || from == to
14454    {
14455        return Err(LinkError::InvalidPack {
14456            message:
14457                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
14458                    .to_string(),
14459        });
14460    }
14461
14462    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
14463        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
14464    })?;
14465    accept_v2_head(cfg, &verified)?;
14466
14467    let alias_response = ensure_ok(
14468        request(
14469            cfg,
14470            "GET",
14471            &format!("/api/hub/brains/{alias}/v2/head"),
14472            None,
14473            Auth::Required,
14474        )?,
14475        "resolve alias for explicit rebind",
14476    )?;
14477    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
14478        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
14479    if resolved.v != 2 || resolved.brain_id != to {
14480        return Err(LinkError::RemoteAdvancedDuringSync);
14481    }
14482
14483    let directory = open_trust_dir(cfg)?;
14484    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
14485    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
14486        message: "the requested alias has no existing local binding to replace".to_string(),
14487    })?;
14488    if binding.brain != from {
14489        return Err(LinkError::AliasRebindRequired {
14490            alias: alias.to_string(),
14491            from: binding.brain,
14492            to: to.to_string(),
14493        });
14494    }
14495    save_alias_in(
14496        cfg,
14497        &directory,
14498        &AliasBinding {
14499            v: 1,
14500            origin: normalized_origin(&cfg.hub)?,
14501            requested: alias.to_string(),
14502            brain: to.to_string(),
14503            home: binding.home,
14504        },
14505    )?;
14506    Ok(json!({
14507        "v": 2,
14508        "alias": alias,
14509        "from": from,
14510        "to": to,
14511        "outcome": "alias_rebound",
14512    }))
14513}
14514
14515fn save_canonical_pin_and_alias(
14516    cfg: &HubConfig,
14517    directory: &TrustDirectory,
14518    requested: &str,
14519    resolved_brain: &str,
14520    mut state: TrustState,
14521    existing_alias: Option<&AliasBinding>,
14522) -> LinkResult<()> {
14523    state.requested = resolved_brain.to_string();
14524    state.brain = resolved_brain.to_string();
14525    state.home = None;
14526    save_trust_in(cfg, directory, &state)?;
14527    if requested != resolved_brain {
14528        save_alias_in(
14529            cfg,
14530            directory,
14531            &AliasBinding {
14532                v: 1,
14533                origin: normalized_origin(&cfg.hub)?,
14534                requested: requested.to_string(),
14535                brain: resolved_brain.to_string(),
14536                home: existing_alias.and_then(|alias| alias.home.clone()),
14537            },
14538        )?;
14539    }
14540    Ok(())
14541}
14542
14543fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
14544    const ED25519_SPKI_PREFIX: &[u8] = &[
14545        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
14546    ];
14547    let entry = &item.entry;
14548    let public_der = URL_SAFE_NO_PAD
14549        .decode(&entry.public_key)
14550        .map_err(|_| invalid_feed("public key is not base64url"))?;
14551    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
14552        || !public_der.starts_with(ED25519_SPKI_PREFIX)
14553    {
14554        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
14555    }
14556    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
14557    if entry.brain != format!("ed25519:{fingerprint}") {
14558        return Err(invalid_feed(
14559            "brain fingerprint does not match its public key",
14560        ));
14561    }
14562    // Verify the complete rotation authority before using any previous key.
14563    let _ = verify_identity_chain(identity, None)?;
14564    let mut chain: Vec<(&str, &str)> = identity
14565        .previous
14566        .iter()
14567        .rev()
14568        .map(|previous| {
14569            (
14570                previous.fingerprint.as_str(),
14571                previous.public_key_spki.as_str(),
14572            )
14573        })
14574        .collect();
14575    chain.push((&identity.fingerprint, &identity.public_key_spki));
14576    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
14577        *known_fingerprint == fingerprint && *spki == entry.public_key
14578    });
14579    let Some(signer_index) = signer_index else {
14580        return Err(invalid_feed(
14581            "entry signer is not this brain's identity (current or rotated-from)",
14582        ));
14583    };
14584    let lower_boundary = if signer_index == 0 {
14585        None
14586    } else {
14587        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
14588            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
14589        Some(prior.prior_head_seq)
14590    };
14591    let upper_boundary = if signer_index == identity.rotations.len() {
14592        None
14593    } else {
14594        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
14595            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
14596        Some(next.prior_head_seq)
14597    };
14598    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
14599        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
14600    {
14601        return Err(invalid_feed(
14602            "entry signer is outside its authenticated rotation epoch",
14603        ));
14604    }
14605    let unsigned = UnsignedFeedEntry {
14606        v: entry.v,
14607        seq: entry.seq,
14608        ts: &entry.ts,
14609        brain: &entry.brain,
14610        public_key: &entry.public_key,
14611        kind: &entry.kind,
14612        op: &entry.op,
14613        pack_sha256: &entry.pack_sha256,
14614        files: &entry.files,
14615        removed: &entry.removed,
14616        prev_entry_hash: &entry.prev_entry_hash,
14617    };
14618    let message =
14619        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
14620    let signature = URL_SAFE_NO_PAD
14621        .decode(&entry.sig)
14622        .map_err(|_| invalid_feed("signature is not base64url"))?;
14623    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
14624        .verify(&message, &signature)
14625        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
14626
14627    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
14628    exact.push(b'\n');
14629    let actual_hash = format!("{:x}", Sha256::digest(&exact));
14630    if actual_hash != item.hash {
14631        return Err(invalid_feed("entry SHA-256 does not match"));
14632    }
14633    Ok(())
14634}
14635
14636// ─────────────────────────────────────────────────────────────────────────────
14637// key rotation — link.md §9.1: the new key, signed by the old one
14638// ─────────────────────────────────────────────────────────────────────────────
14639
14640/// The unsigned rotation statement in its normative field order.
14641#[derive(Serialize)]
14642struct UnsignedRotation<'a> {
14643    v: u8,
14644    op: &'a str,
14645    brain: &'a str,
14646    public_key: &'a str,
14647    new_brain: &'a str,
14648    new_public_key: &'a str,
14649    prior_head_seq: u64,
14650    prior_feed_hash: Option<&'a str>,
14651    ts: String,
14652}
14653
14654/// Durable intent for an in-flight key rotation. The hub's recovery contract
14655/// identifies an ambiguous retry by the exact statement bytes, so the
14656/// statement cannot be reconstructed from the key and feed boundary later:
14657/// its timestamp and signature would differ.
14658#[derive(Debug, Deserialize, Serialize)]
14659#[serde(deny_unknown_fields)]
14660struct RotationJournal {
14661    v: u8,
14662    origin: String,
14663    brain: String,
14664    old_brain: String,
14665    new_brain: String,
14666    prior_head_seq: u64,
14667    prior_feed_hash: Option<String>,
14668    statement: String,
14669}
14670
14671fn rotation_journal_path(key_path: &Path) -> PathBuf {
14672    let mut path = key_path.as_os_str().to_os_string();
14673    path.push(".rotation.json");
14674    PathBuf::from(path)
14675}
14676
14677fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
14678    #[cfg(unix)]
14679    let file = {
14680        use std::os::fd::{AsRawFd as _, FromRawFd as _};
14681        use std::os::unix::ffi::OsStrExt as _;
14682        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
14683            .map_err(|error| {
14684                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
14685            })?;
14686        let leaf_name = path
14687            .file_name()
14688            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
14689        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
14690        let fd = unsafe {
14691            libc::openat(
14692                parent.as_raw_fd(),
14693                leaf.as_ptr(),
14694                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
14695            )
14696        };
14697        if fd < 0 {
14698            return Err(bad_agent_key(
14699                "the rotation journal must be an existing regular file without symlink ancestors",
14700            ));
14701        }
14702        unsafe { std::fs::File::from_raw_fd(fd) }
14703    };
14704    #[cfg(not(unix))]
14705    let file = std::fs::File::open(path)
14706        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
14707    let metadata = file
14708        .metadata()
14709        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
14710    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
14711        return Err(bad_agent_key(
14712            "the rotation journal must be a bounded regular file",
14713        ));
14714    }
14715    #[cfg(unix)]
14716    {
14717        use std::os::unix::fs::PermissionsExt as _;
14718        if metadata.permissions().mode() & 0o077 != 0 {
14719            return Err(bad_agent_key(
14720                "the rotation journal is accessible to group/other; set mode 0600",
14721            ));
14722        }
14723    }
14724    serde_json::from_reader(file)
14725        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
14726}
14727
14728fn remove_rotation_journal(path: &Path) {
14729    #[cfg(unix)]
14730    {
14731        use std::os::fd::AsRawFd as _;
14732        use std::os::unix::ffi::OsStrExt as _;
14733        let Ok(parent) =
14734            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
14735        else {
14736            return;
14737        };
14738        let Some(leaf_name) = path.file_name() else {
14739            return;
14740        };
14741        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
14742            return;
14743        };
14744        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
14745            let _ = parent.sync_all();
14746        }
14747    }
14748    #[cfg(not(unix))]
14749    {
14750        let _ = std::fs::remove_file(path);
14751    }
14752}
14753
14754fn validate_rotation_journal(
14755    journal: &RotationJournal,
14756    cfg: &HubConfig,
14757    canonical_brain: &str,
14758    old_key: &AgentSigningKey,
14759    new_key: &AgentSigningKey,
14760    head: &Head,
14761) -> LinkResult<()> {
14762    if journal.v != 1
14763        || journal.origin != normalized_origin(&cfg.hub)?
14764        || journal.brain != canonical_brain
14765        || journal.old_brain != old_key.multikey
14766        || journal.new_brain != new_key.multikey
14767        || journal.prior_head_seq != head.seq
14768        || journal.prior_feed_hash != head.feed_hash
14769    {
14770        return Err(invalid_feed(
14771            "rotation journal does not match the verified key and feed boundary",
14772        ));
14773    }
14774    let statement: RotationStatement = serde_json::from_str(&journal.statement)
14775        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
14776    if statement.prior_head_seq != journal.prior_head_seq
14777        || statement.prior_feed_hash != journal.prior_feed_hash
14778        || statement.brain != old_key.multikey
14779        || statement.public_key != old_key.public_key_spki
14780        || statement.new_brain != new_key.multikey
14781        || statement.new_public_key != new_key.public_key_spki
14782    {
14783        return Err(invalid_feed(
14784            "rotation journal statement does not match its durable intent",
14785        ));
14786    }
14787    let identity = FeedIdentity {
14788        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
14789        public_key_spki: new_key.public_key_spki.clone(),
14790        previous: vec![PreviousIdentity {
14791            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
14792            public_key_spki: old_key.public_key_spki.clone(),
14793        }],
14794        rotations: vec![journal.statement.clone()],
14795    };
14796    verify_identity_chain(&identity, None)?;
14797    Ok(())
14798}
14799
14800/// What `dbmd key rotate` returns.
14801#[derive(Debug, Serialize)]
14802pub struct RotationReport {
14803    /// The brain id rotated.
14804    pub brain: String,
14805    /// The NEW identity the hub now serves.
14806    pub multikey: String,
14807    /// Where the new PKCS#8 secret landed (0600).
14808    #[serde(rename = "keyFile")]
14809    pub key_file: String,
14810    /// Prior identities (newest first) the feed still verifies against.
14811    pub previous: Vec<String>,
14812}
14813
14814/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
14815/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
14816/// OLD key in normative serialization — and send it to the hub. The new secret
14817/// is durably created at 0600 before the POST; an existing output is reused for
14818/// idempotent retry/reconciliation. The old key is left untouched.
14819pub fn rotate_brain_key(
14820    cfg: &HubConfig,
14821    brain: &str,
14822    old_key: &AgentSigningKey,
14823    out: &Path,
14824) -> LinkResult<RotationReport> {
14825    require_hardened_filesystem("key rotation")?;
14826    require_safe_ref(brain)?;
14827    // The new private key must be durable *before* the hub can accept its
14828    // public half. An existing file is the retry/reconciliation path after an
14829    // ambiguous network failure: reuse it, never generate another identity.
14830    let new_key = if out.exists() {
14831        load_signing_key(out)?
14832    } else {
14833        let rng = ring::rand::SystemRandom::new();
14834        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
14835            .map_err(|_| bad_agent_key("key generation failed"))?;
14836        let pair = agent_keypair(pkcs8.as_ref())?;
14837        let (public_key_spki, multikey) = public_identity_for(&pair);
14838        write_secret_new(
14839            out,
14840            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
14841        )?;
14842        AgentSigningKey {
14843            pkcs8: pkcs8.as_ref().to_vec(),
14844            multikey,
14845            public_key_spki,
14846        }
14847    };
14848    let new_spki = new_key.public_key_spki.clone();
14849    let new_multikey = new_key.multikey.clone();
14850    let journal_path = rotation_journal_path(out);
14851    let before_v2 = v2_verified_head(cfg, brain)?;
14852    let (canonical_brain, served_identity, observed_head, v2_profile) =
14853        if let Some(head) = before_v2 {
14854            let observed = Head {
14855                brain: head.brain_id.clone(),
14856                seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14857                updated_at: head
14858                    .pointer
14859                    .as_ref()
14860                    .map(|pointer| pointer.signed_at.clone()),
14861                feed_hash: head
14862                    .pointer
14863                    .as_ref()
14864                    .map(|pointer| pointer.feed_hash.clone()),
14865                verified: true,
14866            };
14867            let identity = v2_identity(&head.identity);
14868            let canonical = head.brain_id.clone();
14869            accept_v2_head(cfg, &head)?;
14870            (canonical, identity, observed, true)
14871        } else {
14872            let remote = verified_remote_head(cfg, brain, false)?;
14873            let identity = remote
14874                .identity
14875                .clone()
14876                .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
14877            (remote.head.brain.clone(), identity, remote.head, false)
14878        };
14879    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
14880    let already_rotated = served_multikey == new_multikey;
14881    // A completed invocation removes the journal only after the server has
14882    // acknowledged its recovery boundary. If the verified identity advanced
14883    // but the journal remains, the prior response was ambiguous: replay the
14884    // exact signed statement so a v2 hub can repair/confirm its checkpoint.
14885    if already_rotated && !journal_path.exists() {
14886        remove_rotation_journal(&journal_path);
14887        return Ok(RotationReport {
14888            brain: brain.to_string(),
14889            multikey: new_multikey,
14890            key_file: out.display().to_string(),
14891            previous: served_identity
14892                .previous
14893                .iter()
14894                .map(|identity| format!("ed25519:{}", identity.fingerprint))
14895                .collect(),
14896        });
14897    }
14898    if !already_rotated && served_multikey != old_key.multikey {
14899        return Err(invalid_feed(
14900            "the supplied old key is not the brain's verified current identity",
14901        ));
14902    }
14903
14904    let journal = if journal_path.exists() {
14905        read_rotation_journal(&journal_path)?
14906    } else {
14907        let ts = crate::now()
14908            .with_timezone(&chrono::Utc)
14909            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
14910            .to_string();
14911        let unsigned = serde_json::to_string(&UnsignedRotation {
14912            v: 1,
14913            op: "rotate",
14914            brain: &old_key.multikey,
14915            public_key: &old_key.public_key_spki,
14916            new_brain: &new_multikey,
14917            new_public_key: &new_spki,
14918            prior_head_seq: observed_head.seq,
14919            prior_feed_hash: observed_head.feed_hash.as_deref(),
14920            ts,
14921        })
14922        .expect("serialize rotation");
14923        let old_pair = agent_keypair(&old_key.pkcs8)?;
14924        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
14925        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
14926        let journal = RotationJournal {
14927            v: 1,
14928            origin: normalized_origin(&cfg.hub)?,
14929            brain: canonical_brain.clone(),
14930            old_brain: old_key.multikey.clone(),
14931            new_brain: new_multikey.clone(),
14932            prior_head_seq: observed_head.seq,
14933            prior_feed_hash: observed_head.feed_hash.clone(),
14934            statement,
14935        };
14936        let mut exact = serde_json::to_vec(&journal)
14937            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
14938        exact.push(b'\n');
14939        if write_secret_new(&journal_path, &exact).is_err() {
14940            // A concurrent retry may have won the O_EXCL race. Only an exact,
14941            // fully validated journal is allowed to recover that race.
14942            read_rotation_journal(&journal_path)?
14943        } else {
14944            journal
14945        }
14946    };
14947    validate_rotation_journal(
14948        &journal,
14949        cfg,
14950        &canonical_brain,
14951        old_key,
14952        &new_key,
14953        &observed_head,
14954    )?;
14955
14956    let body = json!({ "statement": journal.statement });
14957    let path = format!("/api/hub/brains/{brain}/rotate");
14958    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
14959    let attempted_failure = match attempted {
14960        Ok(response) if (200..300).contains(&response.status) => None,
14961        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
14962        Err(error) => Some(error),
14963    };
14964
14965    // A 2xx body is not authority, and a failed response may have followed a
14966    // committed mutation. In both cases success means the normal verifier sees
14967    // an append-only rotation chain ending at the durable new key.
14968    let identity = if v2_profile {
14969        match v2_verified_head(cfg, brain) {
14970            Ok(Some(after)) => {
14971                let identity = v2_identity(&after.identity);
14972                accept_v2_head(cfg, &after)?;
14973                identity
14974            }
14975            Ok(None) => {
14976                return Err(attempted_failure.unwrap_or_else(|| {
14977                    invalid_feed("rotated v2 brain no longer serves a v2 head")
14978                }));
14979            }
14980            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14981        }
14982    } else {
14983        match verified_remote_head(cfg, brain, false) {
14984            Ok(after) => after
14985                .identity
14986                .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
14987            Err(error) => return Err(attempted_failure.unwrap_or(error)),
14988        }
14989    };
14990    if format!("ed25519:{}", identity.fingerprint) != new_multikey
14991        || identity.public_key_spki != new_spki
14992    {
14993        return Err(attempted_failure.unwrap_or_else(|| {
14994            invalid_feed("hub acknowledged rotation without committing the verified new identity")
14995        }));
14996    }
14997    if v2_profile {
14998        if let Some(error) = attempted_failure {
14999            // Seeing the new public identity is insufficient: v2 rotation is
15000            // successful only after the endpoint has forced and acknowledged
15001            // the recovery checkpoint that contains it. Keep the journal for
15002            // an exact retry after any non-2xx or transport ambiguity.
15003            return Err(error);
15004        }
15005    }
15006    let previous = identity
15007        .previous
15008        .iter()
15009        .map(|prior| format!("ed25519:{}", prior.fingerprint))
15010        .collect();
15011    remove_rotation_journal(&journal_path);
15012
15013    Ok(RotationReport {
15014        brain: brain.to_string(),
15015        multikey: new_multikey,
15016        key_file: out.display().to_string(),
15017        previous,
15018    })
15019}
15020
15021// ─────────────────────────────────────────────────────────────────────────────
15022// mirror — verified replication: the whole feed + files, re-servable
15023// ─────────────────────────────────────────────────────────────────────────────
15024
15025/// What `dbmd mirror` materialized.
15026#[derive(Debug, Serialize)]
15027pub struct MirrorReport {
15028    /// The brain id.
15029    pub brain: String,
15030    /// The mirrored feed head.
15031    #[serde(rename = "headSeq")]
15032    pub head_seq: u64,
15033    /// The head entry hash (the feed's advertised converged state).
15034    #[serde(rename = "feedHash")]
15035    pub feed_hash: Option<String>,
15036    /// Signed feed entries verified and stored.
15037    pub entries: u64,
15038    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
15039    pub pinned: String,
15040    /// Store files materialized by the pull.
15041    pub files: usize,
15042}
15043
15044/// The mirror state directory, relative to the mirror root.
15045pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
15046
15047/// Fully re-verified mirror material suitable for a read-only re-server.
15048#[derive(Debug)]
15049pub struct VerifiedMirrorMaterial {
15050    pub brain: String,
15051    pub head_seq: u64,
15052    pub feed_hash: Option<String>,
15053    pub identity: serde_json::Value,
15054    /// Sequence, exact normative entry JSON without newline, and entry hash.
15055    pub entries: Vec<(u64, String, String)>,
15056    pub pack_sha256: Option<String>,
15057}
15058
15059#[derive(Deserialize)]
15060#[serde(deny_unknown_fields)]
15061struct StoredMirrorHead {
15062    brain: String,
15063    #[serde(rename = "headSeq")]
15064    head_seq: u64,
15065    #[serde(rename = "feedHash")]
15066    feed_hash: Option<String>,
15067}
15068
15069/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
15070/// exact snapshot pack before `dbmd serve` exposes any bytes.
15071pub fn verify_mirror_material(
15072    head_bytes: &[u8],
15073    identity_bytes: &[u8],
15074    feed_bytes: &[Vec<u8>],
15075    snapshot_pack: Option<&[u8]>,
15076    expected_anchor: &str,
15077) -> LinkResult<VerifiedMirrorMaterial> {
15078    let snapshot_hash = snapshot_pack
15079        .filter(|pack| !pack.is_empty())
15080        .map(content_sha256);
15081    verify_mirror_material_with_pack_hash(
15082        head_bytes,
15083        identity_bytes,
15084        feed_bytes,
15085        snapshot_hash.as_deref(),
15086        expected_anchor,
15087    )
15088}
15089
15090/// Re-verify mirror metadata against a snapshot digest computed from a held
15091/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
15092/// large pack without ever buffering the pack in process memory.
15093pub fn verify_mirror_material_with_pack_hash(
15094    head_bytes: &[u8],
15095    identity_bytes: &[u8],
15096    feed_bytes: &[Vec<u8>],
15097    snapshot_pack_sha256: Option<&str>,
15098    expected_anchor: &str,
15099) -> LinkResult<VerifiedMirrorMaterial> {
15100    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
15101        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
15102    require_safe_ref(&head.brain)?;
15103    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
15104        return Err(invalid_feed(
15105            "stored mirror feed count does not match its bounded head sequence",
15106        ));
15107    }
15108    let aggregate = feed_bytes
15109        .iter()
15110        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
15111        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
15112    if aggregate > MAX_FEED_REPLAY_BYTES {
15113        return Err(invalid_feed(
15114            "stored mirror feed metadata exceeds the aggregate limit",
15115        ));
15116    }
15117    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
15118        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
15119    let anchor = verify_identity_chain(&identity, None)?;
15120    if anchor != expected_anchor {
15121        return Err(invalid_feed(
15122            "stored mirror identity does not descend from the explicitly trusted anchor",
15123        ));
15124    }
15125
15126    let mut entries = Vec::with_capacity(feed_bytes.len());
15127    let mut items = Vec::with_capacity(feed_bytes.len());
15128    let mut previous_hash = None;
15129    let mut pack_sha256 = None;
15130    for (index, bytes) in feed_bytes.iter().enumerate() {
15131        let exact = bytes
15132            .strip_suffix(b"\n")
15133            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
15134        if exact.ends_with(b"\n") {
15135            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
15136        }
15137        let entry: FeedEntry = serde_json::from_slice(exact)
15138            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
15139        let expected_seq = index as u64 + 1;
15140        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
15141            return Err(invalid_feed(
15142                "stored mirror feed is not contiguous and hash-chained",
15143            ));
15144        }
15145        let canonical = serde_json::to_vec(&entry)
15146            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
15147        if canonical != exact {
15148            return Err(invalid_feed(
15149                "stored feed entry is not in normative serialization",
15150            ));
15151        }
15152        let hash = content_sha256(bytes);
15153        let item = FeedItem {
15154            hash: hash.clone(),
15155            entry,
15156        };
15157        verify_feed_item(&item, &identity)?;
15158        previous_hash = Some(hash.clone());
15159        if expected_seq == head.head_seq {
15160            pack_sha256 = Some(item.entry.pack_sha256.clone());
15161        }
15162        entries.push((
15163            expected_seq,
15164            std::str::from_utf8(exact)
15165                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
15166                .to_string(),
15167            hash,
15168        ));
15169        items.push(item);
15170    }
15171    if previous_hash != head.feed_hash {
15172        return Err(invalid_feed(
15173            "stored mirror feed does not converge on its advertised head",
15174        ));
15175    }
15176    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
15177    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
15178        (0, None, None) => {}
15179        (_, Some(actual), Some(expected)) if actual == expected => {}
15180        _ => {
15181            return Err(LinkError::InvalidPack {
15182                message: "stored snapshot pack does not match the signed head digest".to_string(),
15183            });
15184        }
15185    }
15186    let identity_value = serde_json::to_value(&identity)
15187        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
15188    Ok(VerifiedMirrorMaterial {
15189        brain: head.brain,
15190        head_seq: head.head_seq,
15191        feed_hash: head.feed_hash,
15192        identity: identity_value,
15193        entries,
15194        pack_sha256,
15195    })
15196}
15197
15198/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
15199/// entry hash every consumer recomputes (SPEC §5.3).
15200pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
15201    format!(
15202        "{:x}",
15203        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
15204    )
15205}
15206
15207/// SHA-256 hex for content a signed manifest names. Exposed for the thin
15208/// `dbmd serve` adapter; cryptographic verification remains centralized here.
15209pub fn content_sha256(bytes: &[u8]) -> String {
15210    format!("{:x}", Sha256::digest(bytes))
15211}
15212
15213/// SHA-256 a stream with a fixed-size working buffer.
15214pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
15215    let mut digest = Sha256::new();
15216    let mut buffer = [0u8; 64 * 1024];
15217    loop {
15218        let read = reader.read(&mut buffer)?;
15219        if read == 0 {
15220            break;
15221        }
15222        digest.update(&buffer[..read]);
15223    }
15224    Ok(format!("{:x}", digest.finalize()))
15225}
15226
15227/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
15228/// chain, not just the head): every entry's signature, hash, sequence
15229/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
15230/// checked in a sibling staging directory. Only then is the old mirror swapped
15231/// out through an atomic directory exchange. Every stage, install, and cleanup
15232/// operation is relative to one held no-follow parent-directory capability, so
15233/// renaming an ancestor cannot redirect any write or deletion.
15234#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
15235pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
15236    require_hardened_filesystem("mirror")?;
15237    require_safe_ref(brain)?;
15238    #[cfg(windows)]
15239    {
15240        let _ = (cfg, dest);
15241        return Err(LinkError::UnsupportedPlatform {
15242            operation: "atomic whole-mirror replacement on Windows",
15243        });
15244    }
15245    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
15246    let name = dest
15247        .file_name()
15248        .and_then(|name| name.to_str())
15249        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
15250        .ok_or_else(|| LinkError::UnsafePath {
15251            path: dest.display().to_string(),
15252        })?;
15253    #[cfg(unix)]
15254    let parent_dir = open_or_create_dir_nofollow(parent)?;
15255    #[cfg(unix)]
15256    use std::os::fd::AsRawFd as _;
15257    #[cfg(unix)]
15258    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
15259    #[cfg(unix)]
15260    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
15261        None => false,
15262        Some(true) => true,
15263        Some(false) => {
15264            return Err(LinkError::UnsafePath {
15265                path: dest.display().to_string(),
15266            });
15267        }
15268    };
15269
15270    // A fixed backup was used by pre-hardening builds. Never interpret or
15271    // delete an attacker-planted entry at that name; require manual recovery.
15272    #[cfg(unix)]
15273    let legacy_backup_name = c_name(
15274        format!(".{name}.dbmd-backup").as_bytes(),
15275        &dest.display().to_string(),
15276    )?;
15277    #[cfg(unix)]
15278    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
15279        return Err(LinkError::UnsafePath {
15280            path: parent
15281                .join(format!(".{name}.dbmd-backup"))
15282                .display()
15283                .to_string(),
15284        });
15285    }
15286
15287    let nonce = std::time::SystemTime::now()
15288        .duration_since(std::time::UNIX_EPOCH)
15289        .unwrap_or_default()
15290        .as_nanos();
15291    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
15292    #[cfg(unix)]
15293    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
15294    #[cfg(unix)]
15295    let stage_dir = create_dir_exclusive_at(
15296        parent_dir.as_raw_fd(),
15297        &stage_name,
15298        &dest.display().to_string(),
15299    )?;
15300
15301    let assembled = (|| -> LinkResult<MirrorReport> {
15302        let remote = verified_remote_head(cfg, brain, true)?;
15303        let brain_id = remote.head.brain.clone();
15304        let identity = remote
15305            .identity
15306            .as_ref()
15307            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
15308        let anchor = remote
15309            .anchor
15310            .clone()
15311            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
15312        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
15313        let snapshot_entries = parse_store_pack(pack.clone())?;
15314        let snapshot_count = snapshot_entries.len();
15315        let mut staged_entries = snapshot_entries;
15316        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
15317        for item in &remote.entries {
15318            let mut exact = serde_json::to_vec(&item.entry)
15319                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
15320            exact.push(b'\n');
15321            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
15322                return Err(invalid_feed(
15323                    "serialized mirror entry differs from its verified hash",
15324                ));
15325            }
15326            staged_entries.push((
15327                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
15328                exact,
15329            ));
15330        }
15331        let mut identity_bytes = serde_json::to_vec(identity)
15332            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
15333        identity_bytes.push(b'\n');
15334        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
15335        let mut head_bytes = serde_json::to_vec(&json!({
15336            "brain": brain_id,
15337            "headSeq": remote.head.seq,
15338            "feedHash": remote.head.feed_hash,
15339        }))
15340        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
15341        head_bytes.push(b'\n');
15342        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
15343        staged_entries.push((
15344            CONFIG_REL_PATH.to_string(),
15345            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
15346        ));
15347        #[cfg(unix)]
15348        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
15349
15350        Ok(MirrorReport {
15351            brain: brain_id,
15352            head_seq: remote.head.seq,
15353            feed_hash: remote.head.feed_hash,
15354            entries: remote.entries.len() as u64,
15355            pinned: anchor,
15356            files: snapshot_count,
15357        })
15358    })();
15359
15360    let report = match assembled {
15361        Ok(report) => report,
15362        Err(error) => {
15363            #[cfg(unix)]
15364            let _ = remove_tree_at(
15365                parent_dir.as_raw_fd(),
15366                &stage_name,
15367                &dest.display().to_string(),
15368            );
15369            return Err(error);
15370        }
15371    };
15372
15373    #[cfg(unix)]
15374    if let Err(error) =
15375        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
15376    {
15377        let _ = remove_tree_at(
15378            parent_dir.as_raw_fd(),
15379            &stage_name,
15380            &dest.display().to_string(),
15381        );
15382        return Err(error);
15383    }
15384    // An exchange leaves the old mirror at the unique stage name. Cleanup is
15385    // capability-relative and never follows symlinks in hostile old content.
15386    #[cfg(unix)]
15387    if dest_exists {
15388        remove_tree_at(
15389            parent_dir.as_raw_fd(),
15390            &stage_name,
15391            &dest.display().to_string(),
15392        )?;
15393    }
15394    #[cfg(unix)]
15395    parent_dir.sync_all()?;
15396    Ok(report)
15397}
15398
15399fn verified_remote_head(
15400    cfg: &HubConfig,
15401    brain: &str,
15402    require_full_chain: bool,
15403) -> LinkResult<VerifiedRemote> {
15404    require_hardened_filesystem("verified link.md state")?;
15405    require_safe_ref(brain)?;
15406    // Refuse an unsafe state root before sending credentials or consulting an
15407    // untrusted card, and retain this exact directory inode for the complete
15408    // checkpoint transaction.
15409    let trust_directory = open_trust_dir(cfg)?;
15410    let path = format!("/api/hub/brains/{brain}");
15411    let body = ensure_ok(
15412        request(cfg, "GET", &path, None, Auth::Required)?,
15413        "subscribe",
15414    )?;
15415    let resolved_brain = body
15416        .get("id")
15417        .and_then(Value::as_str)
15418        .filter(|id| crate::ulid::is_ulid(id))
15419        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
15420        .to_string();
15421    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
15422        return Err(invalid_feed(
15423            "brain card id differs from the explicitly requested brain id",
15424        ));
15425    }
15426    // The card supplies the canonical ULID. Lock alias + canonical keys in
15427    // deterministic filename order, then hold both through verify + save.
15428    // Concurrent aliases for the same brain therefore converge on one
15429    // checkpoint instead of establishing independent TOFU universes.
15430    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
15431    let (pinned, alias_binding) =
15432        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
15433    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
15434    let advertised_hash = body
15435        .get("feedHash")
15436        .and_then(Value::as_str)
15437        .map(str::to_string);
15438    let updated_at = body
15439        .get("updatedAt")
15440        .and_then(Value::as_str)
15441        .map(str::to_string);
15442    if let Some(pin) = &pinned {
15443        if seq < pin.head_seq {
15444            return Err(invalid_feed(format!(
15445                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
15446                pin.head_seq
15447            )));
15448        }
15449        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
15450            return Err(invalid_feed(
15451                "feed equivocation: the checkpoint sequence now has a different hash",
15452            ));
15453        }
15454    }
15455    if seq == 0 {
15456        if advertised_hash.is_some() {
15457            return Err(invalid_feed("an empty feed advertised a head hash"));
15458        }
15459        let identity: FeedIdentity = serde_json::from_value(
15460            body.get("identity")
15461                .cloned()
15462                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
15463        )
15464        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
15465        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
15466        // A valid old-key-signed rotation is still inconsistent if it commits
15467        // to history that the same card now claims never existed. Run the
15468        // identical feed-boundary proof used by non-empty heads before this
15469        // identity can become a durable TOFU checkpoint.
15470        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
15471        save_canonical_pin_and_alias(
15472            cfg,
15473            &trust_directory,
15474            brain,
15475            &resolved_brain,
15476            TrustState {
15477                v: 2,
15478                origin: normalized_origin(&cfg.hub)?,
15479                requested: resolved_brain.clone(),
15480                brain: resolved_brain.clone(),
15481                home: None,
15482                anchor: anchor.clone(),
15483                current: format!("ed25519:{}", identity.fingerprint),
15484                head_seq: 0,
15485                feed_hash: None,
15486                rotations: identity.rotations.clone(),
15487                hub_signer: None,
15488                protocol_profile: None,
15489            },
15490            alias_binding.as_ref(),
15491        )?;
15492        return Ok(VerifiedRemote {
15493            head: Head {
15494                brain: resolved_brain,
15495                seq,
15496                updated_at,
15497                feed_hash: None,
15498                verified: true,
15499            },
15500            identity: Some(identity),
15501            head_entry: None,
15502            entries: Vec::new(),
15503            anchor: Some(anchor),
15504        });
15505    }
15506    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
15507        return Err(invalid_feed(
15508            "non-empty feed did not advertise a valid SHA-256 head",
15509        ));
15510    }
15511
15512    // On first contact the signed head itself is the TOFU checkpoint. A full
15513    // history replay cannot add authority before an anchor exists; mirrors
15514    // still request the complete chain because they promise an archival copy.
15515    let replay_head_only = !require_full_chain
15516        && pinned
15517            .as_ref()
15518            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
15519    let mut after = if replay_head_only {
15520        seq - 1
15521    } else if require_full_chain || pinned.is_none() {
15522        0
15523    } else {
15524        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
15525    };
15526    let mut expected_seq = after + 1;
15527    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
15528        None
15529    } else {
15530        pinned
15531            .as_ref()
15532            .and_then(|checkpoint| checkpoint.feed_hash.clone())
15533    };
15534    let mut identity: Option<FeedIdentity> = None;
15535    let mut anchor: Option<String> = None;
15536    let mut head_entry: Option<FeedItem> = None;
15537    let mut all_entries = Vec::new();
15538    let mut observed_entries = Vec::new();
15539    let replay_count = seq
15540        .checked_sub(after)
15541        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
15542    if replay_count > MAX_FEED_REPLAY_ENTRIES {
15543        return Err(invalid_feed(format!(
15544            "feed replay requires {replay_count} entries, over the client cap"
15545        )));
15546    }
15547    let mut replay_bytes = 0u64;
15548
15549    loop {
15550        let feed_bytes = ensure_raw_ok(
15551            request_raw(
15552                cfg,
15553                "GET",
15554                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
15555                None,
15556                Auth::Required,
15557                MAX_FEED_RESPONSE_BYTES,
15558            )?,
15559            "subscribe feed",
15560        )?;
15561        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
15562            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
15563        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
15564            return Err(invalid_feed("brain card and feed head disagree"));
15565        }
15566        if feed.entries.len() > FEED_PAGE_LIMIT {
15567            return Err(invalid_feed("feed page exceeds the requested entry limit"));
15568        }
15569        if feed.scope_limited {
15570            if require_full_chain {
15571                return Err(invalid_feed(
15572                    "path-scoped grants cannot verify a full snapshot chain",
15573                ));
15574            }
15575            return Ok(VerifiedRemote {
15576                head: Head {
15577                    brain: resolved_brain,
15578                    seq,
15579                    updated_at,
15580                    feed_hash: advertised_hash,
15581                    verified: false,
15582                },
15583                identity: None,
15584                head_entry: None,
15585                entries: Vec::new(),
15586                anchor: None,
15587            });
15588        }
15589        let page_identity = feed
15590            .identity
15591            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
15592        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
15593        if identity
15594            .as_ref()
15595            .is_some_and(|existing| existing != &page_identity)
15596        {
15597            return Err(invalid_feed("identity changed while reading the feed"));
15598        }
15599        if anchor
15600            .as_ref()
15601            .is_some_and(|existing| existing != &page_anchor)
15602        {
15603            return Err(invalid_feed(
15604                "identity anchor changed while reading the feed",
15605            ));
15606        }
15607        identity = Some(page_identity.clone());
15608        if anchor.is_none() {
15609            anchor = Some(page_anchor);
15610        }
15611        if feed.entries.is_empty() {
15612            return Err(invalid_feed("feed page was empty before the signed head"));
15613        }
15614
15615        for item in feed.entries {
15616            if item.entry.seq != expected_seq {
15617                return Err(invalid_feed(format!(
15618                    "expected entry {expected_seq}, feed served {}",
15619                    item.entry.seq
15620                )));
15621            }
15622            if item.entry.seq > seq {
15623                return Err(invalid_feed("feed advanced past the card snapshot"));
15624            }
15625            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
15626                return Err(invalid_feed(format!(
15627                    "entry {} does not chain to the local checkpoint",
15628                    item.entry.seq
15629                )));
15630            }
15631            verify_feed_item(&item, &page_identity)?;
15632            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
15633            replay_bytes = replay_bytes.saturating_add(
15634                serde_json::to_vec(&item)
15635                    .map_err(|_| invalid_feed("could not size feed entry"))?
15636                    .len() as u64,
15637            );
15638            if replay_bytes > MAX_FEED_REPLAY_BYTES {
15639                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
15640            }
15641            previous_hash = Some(item.hash.clone());
15642            after = item.entry.seq;
15643            expected_seq = expected_seq
15644                .checked_add(1)
15645                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
15646            if require_full_chain {
15647                all_entries.push(item.clone());
15648            }
15649            observed_entries.push(item.clone());
15650            head_entry = Some(item);
15651        }
15652        if after == seq {
15653            break;
15654        }
15655    }
15656
15657    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
15658        return Err(invalid_feed(
15659            "verified chain does not converge on the advertised head",
15660        ));
15661    }
15662    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
15663    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
15664    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
15665    save_canonical_pin_and_alias(
15666        cfg,
15667        &trust_directory,
15668        brain,
15669        &resolved_brain,
15670        TrustState {
15671            v: 2,
15672            origin: normalized_origin(&cfg.hub)?,
15673            requested: resolved_brain.clone(),
15674            brain: resolved_brain.clone(),
15675            home: None,
15676            anchor: anchor.clone(),
15677            current: format!("ed25519:{}", identity.fingerprint),
15678            head_seq: seq,
15679            feed_hash: advertised_hash.clone(),
15680            rotations: identity.rotations.clone(),
15681            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
15682            protocol_profile: pinned
15683                .as_ref()
15684                .and_then(|state| state.protocol_profile.clone()),
15685        },
15686        alias_binding.as_ref(),
15687    )?;
15688    Ok(VerifiedRemote {
15689        head: Head {
15690            brain: resolved_brain,
15691            seq,
15692            updated_at,
15693            feed_hash: advertised_hash,
15694            verified: true,
15695        },
15696        identity: Some(identity),
15697        head_entry,
15698        entries: all_entries,
15699        anchor: Some(anchor),
15700    })
15701}
15702
15703/// Read and locally verify the brain's current signed feed head. Identity
15704/// rotation is accepted only through an old-key-signed chain rooted at the
15705/// local TOFU anchor; sequence and hash checkpoints reject rollback and
15706/// equivocation across invocations.
15707pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
15708    if let Some(verified) = v2_verified_head(cfg, brain)? {
15709        let observation = Head {
15710            brain: verified.brain_id.clone(),
15711            seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
15712            updated_at: verified
15713                .pointer
15714                .as_ref()
15715                .map(|pointer| pointer.signed_at.clone()),
15716            feed_hash: verified
15717                .pointer
15718                .as_ref()
15719                .map(|pointer| pointer.feed_hash.clone()),
15720            verified: true,
15721        };
15722        accept_v2_head(cfg, &verified)?;
15723        return Ok(observation);
15724    }
15725    Ok(verified_remote_head(cfg, brain, false)?.head)
15726}
15727
15728#[cfg(test)]
15729mod tests {
15730    use super::*;
15731
15732    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
15733
15734    fn drain_test_http_request(stream: &mut std::net::TcpStream) {
15735        use std::io::Read as _;
15736
15737        stream
15738            .set_read_timeout(Some(std::time::Duration::from_secs(2)))
15739            .unwrap();
15740        let mut request = Vec::new();
15741        let mut chunk = [0_u8; 4096];
15742        loop {
15743            let read = stream.read(&mut chunk).unwrap();
15744            assert!(read > 0, "test client closed before its request completed");
15745            request.extend_from_slice(&chunk[..read]);
15746            let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") else {
15747                continue;
15748            };
15749            let headers = String::from_utf8_lossy(&request[..header_end]);
15750            let content_length = headers
15751                .lines()
15752                .find_map(|line| {
15753                    let (name, value) = line.split_once(':')?;
15754                    name.eq_ignore_ascii_case("content-length")
15755                        .then(|| value.trim().parse::<usize>().unwrap())
15756                })
15757                .unwrap_or(0);
15758            if request.len() >= header_end + 4 + content_length {
15759                return;
15760            }
15761        }
15762    }
15763
15764    fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
15765        json!({
15766            "sha256": "a".repeat(64),
15767            "bytes": 10,
15768            "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
15769        })
15770    }
15771
15772    #[test]
15773    fn upload_reservations_batch_by_count_and_by_size() {
15774        // A real store's first push declares one blob per changed file, which
15775        // does not fit in one request. Every batch has to stay inside both
15776        // bounds, and concatenating them has to reproduce the original order.
15777        let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
15778        let batches = batch_upload_declarations(declarations.clone());
15779
15780        assert!(batches.len() > 1, "5,000 blobs must not ride one request");
15781        for batch in &batches {
15782            assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
15783            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15784                .expect("batch serializes")
15785                .len();
15786            assert!(
15787                bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
15788                "batch body {bytes} exceeds the reservation budget"
15789            );
15790        }
15791        let flattened: Vec<Value> = batches.into_iter().flatten().collect();
15792        assert_eq!(
15793            flattened, declarations,
15794            "batching must preserve the set and order"
15795        );
15796    }
15797
15798    #[test]
15799    fn only_load_shaped_hub_answers_are_worth_asking_again() {
15800        // A migration opens dozens of staging windows, so a busy hub's 429 or
15801        // 5xx must not end the push — while a refusal that states something
15802        // about the request itself has to surface immediately rather than
15803        // being repeated six times behind a minute of waiting.
15804        for status in [408, 429, 500, 502, 503, 504] {
15805            assert!(is_retryable_hub_status(status), "{status} is load-shaped");
15806        }
15807        for status in [400, 401, 403, 404, 409, 413, 422] {
15808            assert!(
15809                !is_retryable_hub_status(status),
15810                "{status} states something about the request"
15811            );
15812        }
15813        // The waits must reach past a whole limiter window to be worth taking.
15814        let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
15815        assert!(total >= 60_000, "backoff totals only {total}ms");
15816    }
15817
15818    #[test]
15819    fn a_batch_shares_a_connection_only_within_one_authority() {
15820        // Sharing a pooled agent is what makes a migration stage in minutes,
15821        // but it must never widen where bytes can go: a batch that spans two
15822        // authorities, or names an unsafe URL, falls back to per-object agents
15823        // rather than reusing a connection pinned to somewhere else.
15824        let cfg = HubConfig {
15825            hub: "https://www.sevrahq.com".to_string(),
15826            key: Some("k".to_string()),
15827            agent_key: None,
15828            brain_key: None,
15829            state_dir: PathBuf::from("."),
15830            store_selected: false,
15831        };
15832        assert!(shared_staging_agent(&cfg, &[]).is_none());
15833        assert!(
15834            shared_staging_agent(
15835                &cfg,
15836                &[
15837                    "https://one.example.com/a?sig=1",
15838                    "https://two.example.com/b?sig=2",
15839                ]
15840            )
15841            .is_none(),
15842            "two authorities must not share a pinned pool"
15843        );
15844        assert!(
15845            shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
15846            "an unsafe object-store URL must not produce an agent"
15847        );
15848        assert!(
15849            shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
15850            "credentials in the URL must not produce an agent"
15851        );
15852    }
15853
15854    #[test]
15855    fn a_staged_change_states_only_operations_and_blobs() {
15856        // The hub refuses a manifest with any other key, so the two sides have
15857        // to agree exactly. A drifting extra field would fail a whole migration
15858        // at the commit, after every byte had already been staged.
15859        let operations = vec![json!({
15860            "op": "put",
15861            "path": "records/a.md",
15862            "blob": "a".repeat(64),
15863            "bytes": 3,
15864        })];
15865        let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
15866        let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
15867        let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
15868        let keys: Vec<&str> = parsed
15869            .as_object()
15870            .expect("manifest is an object")
15871            .keys()
15872            .map(String::as_str)
15873            .collect();
15874        assert_eq!(keys, ["blobs", "operations"]);
15875        assert_eq!(parsed["operations"], Value::Array(operations));
15876        assert_eq!(parsed["blobs"], blobs);
15877    }
15878
15879    #[test]
15880    fn a_staged_push_signs_the_change_not_the_transport() {
15881        // Self-custody signs a request hash the hub recomputes from the parsed
15882        // change. A staged body no longer states its operations, so the signed
15883        // view has to restore them or every large self-custodied push would
15884        // sign a different request than the one the hub validated.
15885        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15886        let staged = json!({
15887            "mutation_id": "dbmd-1",
15888            "rebase": "strict",
15889            "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
15890        });
15891        let view = v2_signed_request_view(&staged, &operations);
15892        assert_eq!(view["operations"], Value::Array(operations.clone()));
15893        assert!(view.get("staged_change").is_none());
15894        assert_eq!(view["mutation_id"], staged["mutation_id"]);
15895
15896        let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
15897        assert_eq!(v2_signed_request_view(&inline, &[]), inline);
15898    }
15899
15900    #[test]
15901    fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
15902        let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
15903        let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
15904            .expect_err("an oversized change must not be staged");
15905        assert!(
15906            matches!(error, LinkError::PushTooLarge { .. }),
15907            "expected a size refusal, got {error:?}"
15908        );
15909    }
15910
15911    #[test]
15912    fn a_push_that_fits_the_request_is_left_inline() {
15913        // The staged lane costs a reservation and a round trip, so it must
15914        // engage only when the body genuinely does not fit. The hub here is a
15915        // closed port: reaching for it at all would fail this test.
15916        let cfg = HubConfig {
15917            hub: "http://127.0.0.1:9".to_string(),
15918            key: Some("k".to_string()),
15919            agent_key: None,
15920            brain_key: None,
15921            state_dir: PathBuf::from("."),
15922            store_selected: false,
15923        };
15924        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
15925        let mut body = json!({
15926            "mutation_id": "dbmd-1",
15927            "operations": operations,
15928            "blobs": [],
15929        });
15930        stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
15931        assert!(body.get("staged_change").is_none());
15932        assert_eq!(body["operations"], Value::Array(operations));
15933    }
15934
15935    #[test]
15936    fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
15937        // Byte budget, not just count. One hash can carry many coordinates
15938        // (the same bytes reachable at several paths), so a batch sized only by
15939        // count would blow the body budget on a deduplicated store.
15940        let declarations: Vec<Value> = (0..2_000)
15941            .map(|index| {
15942                json!({
15943                    "sha256": "a".repeat(64),
15944                    "bytes": 10,
15945                    "coordinates": (0..24)
15946                        .map(|slot| format!(
15947                            "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
15948                        ))
15949                        .collect::<Vec<_>>(),
15950                })
15951            })
15952            .collect();
15953        let batches = batch_upload_declarations(declarations);
15954        assert!(
15955            batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
15956            "wide coordinate sets must bound the batch by size"
15957        );
15958        for batch in &batches {
15959            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
15960                .expect("batch serializes")
15961                .len();
15962            assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
15963        }
15964    }
15965
15966    #[test]
15967    fn a_small_push_still_rides_exactly_one_request() {
15968        let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
15969        assert_eq!(batch_upload_declarations(declarations).len(), 1);
15970        assert!(batch_upload_declarations(Vec::new()).is_empty());
15971    }
15972
15973    #[test]
15974    fn exact_source_move_becomes_one_provenance_preserving_rename() {
15975        let hash = "a".repeat(64);
15976        let operations = vec![
15977            json!({
15978                "op": "put",
15979                "path": "sources/curated/item.md",
15980                "expected": { "kind": "absent" },
15981                "blob": hash,
15982                "bytes": 19,
15983            }),
15984            json!({
15985                "op": "delete",
15986                "path": "sources/inbox/item.md",
15987                "expected": { "kind": "blob", "hash": hash },
15988            }),
15989        ];
15990
15991        assert_eq!(
15992            infer_exact_source_promotions(operations),
15993            vec![json!({
15994                "op": "rename",
15995                "from": "sources/inbox/item.md",
15996                "to": "sources/curated/item.md",
15997                "expected_from": { "kind": "blob", "hash": hash },
15998                "expected_to": { "kind": "absent" },
15999                "blob": hash,
16000                "bytes": 19,
16001            })]
16002        );
16003    }
16004
16005    #[test]
16006    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
16007        let hash = "b".repeat(64);
16008        let operations = vec![
16009            json!({
16010                "op": "delete",
16011                "path": "sources/inbox/a.md",
16012                "expected": { "kind": "blob", "hash": hash },
16013            }),
16014            json!({
16015                "op": "delete",
16016                "path": "sources/inbox/b.md",
16017                "expected": { "kind": "blob", "hash": hash },
16018            }),
16019            json!({
16020                "op": "put",
16021                "path": "sources/curated/item.md",
16022                "expected": { "kind": "absent" },
16023                "blob": hash,
16024                "bytes": 19,
16025            }),
16026        ];
16027
16028        assert_eq!(
16029            infer_exact_source_promotions(operations.clone()),
16030            operations,
16031            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
16032        );
16033    }
16034
16035    #[test]
16036    fn an_exact_dual_plane_source_move_remains_a_provenance_preserving_rename() {
16037        let hash = "c".repeat(64);
16038        let operations = vec![
16039            json!({
16040                "op": "delete",
16041                "path": "sources/inbox/item.md",
16042                "expected": { "kind": "blob", "hash": hash },
16043            }),
16044            json!({
16045                "op": "put_asset_content",
16046                "path": "sources/archive/item.md",
16047                "expected": { "kind": "absent" },
16048                "blob": hash,
16049                "bytes": 19,
16050            }),
16051        ];
16052        assert_eq!(
16053            infer_exact_source_promotions(operations),
16054            vec![json!({
16055                "op": "rename",
16056                "from": "sources/inbox/item.md",
16057                "to": "sources/archive/item.md",
16058                "expected_from": { "kind": "blob", "hash": hash },
16059                "expected_to": { "kind": "absent" },
16060                "blob": hash,
16061                "bytes": 19,
16062            })]
16063        );
16064    }
16065
16066    #[test]
16067    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
16068        let hash = "c".repeat(64);
16069        let mut candidate = std::collections::BTreeMap::from([(
16070            "sources/inbox/item.md".to_string(),
16071            V2BaselineFile {
16072                sha256: hash.clone(),
16073                bytes: 19,
16074                proof: None,
16075            },
16076        )]);
16077        let mut candidate_assets = std::collections::BTreeMap::new();
16078        let operations = vec![
16079            json!({
16080                "op": "rename",
16081                "from": "sources/inbox/item.md",
16082                "to": "sources/curated/item.md",
16083                "expected_from": { "kind": "blob", "hash": hash },
16084                "expected_to": { "kind": "absent" },
16085                "blob": hash,
16086                "bytes": 19,
16087            }),
16088            json!({
16089                "op": "put",
16090                "path": "records/rsvps/item.md",
16091                "expected": { "kind": "absent" },
16092                "blob": "d".repeat(64),
16093                "bytes": 23,
16094            }),
16095        ];
16096
16097        assert!(!apply_generated_v2_operations(
16098            &operations,
16099            &std::collections::BTreeMap::new(),
16100            &mut candidate,
16101            &mut candidate_assets,
16102        )
16103        .unwrap());
16104        assert!(!candidate.contains_key("sources/inbox/item.md"));
16105        assert_eq!(
16106            candidate
16107                .get("sources/curated/item.md")
16108                .map(|file| (&file.sha256, file.bytes)),
16109            Some((&hash, 19))
16110        );
16111        assert_eq!(
16112            candidate
16113                .get("records/rsvps/item.md")
16114                .map(|file| (file.sha256.as_str(), file.bytes)),
16115            Some((
16116                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
16117                23
16118            ))
16119        );
16120    }
16121
16122    fn merge_fixture(
16123        base: Option<&str>,
16124        remote: Option<&str>,
16125        local: Option<&str>,
16126        keep_local: bool,
16127    ) -> V2PulledMerge<String> {
16128        let map = |value: Option<&str>| {
16129            value
16130                .map(|value| [("records/a.md".to_string(), value.to_string())])
16131                .into_iter()
16132                .flatten()
16133                .collect::<std::collections::BTreeMap<_, _>>()
16134        };
16135        merge_v2_pulled_records(
16136            &map(base),
16137            &map(remote),
16138            &map(local),
16139            |value, _| value.clone(),
16140            |value, _| value.clone(),
16141            |_| keep_local,
16142        )
16143    }
16144
16145    #[test]
16146    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
16147        let path = "records/a.md".to_string();
16148
16149        let local_add = merge_fixture(None, None, Some("local"), false);
16150        assert_eq!(
16151            local_add.records.get(&path).map(String::as_str),
16152            Some("local")
16153        );
16154        assert!(local_add.accept_remote.is_empty());
16155        assert!(local_add.conflicts.is_empty());
16156
16157        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
16158        assert_eq!(
16159            local_edit.records.get(&path).map(String::as_str),
16160            Some("local")
16161        );
16162        assert!(local_edit.accept_remote.is_empty());
16163        assert!(local_edit.conflicts.is_empty());
16164
16165        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
16166        assert!(!local_delete.records.contains_key(&path));
16167        assert!(local_delete.accept_remote.is_empty());
16168        assert!(local_delete.conflicts.is_empty());
16169
16170        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
16171        assert_eq!(
16172            remote_edit.records.get(&path).map(String::as_str),
16173            Some("remote")
16174        );
16175        assert!(remote_edit.accept_remote.contains(&path));
16176        assert!(remote_edit.conflicts.is_empty());
16177
16178        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
16179        assert!(!remote_delete.records.contains_key(&path));
16180        assert!(remote_delete.accept_remote.contains(&path));
16181        assert!(remote_delete.conflicts.is_empty());
16182
16183        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
16184        assert_eq!(
16185            same_edit.records.get(&path).map(String::as_str),
16186            Some("same")
16187        );
16188        assert!(same_edit.accept_remote.contains(&path));
16189        assert!(same_edit.conflicts.is_empty());
16190
16191        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
16192        assert_eq!(conflict.conflicts, vec![path.clone()]);
16193        assert_eq!(
16194            conflict.records.get(&path).map(String::as_str),
16195            Some("local")
16196        );
16197        assert!(conflict.accept_remote.is_empty());
16198
16199        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
16200        assert_eq!(
16201            kept_home.records.get(&path).map(String::as_str),
16202            Some("local")
16203        );
16204        assert!(kept_home.accept_remote.is_empty());
16205        assert!(kept_home.conflicts.is_empty());
16206    }
16207
16208    #[test]
16209    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
16210        let path = "sources/report.pdf";
16211        let record = crate::AssetRecord {
16212            path: path.to_string(),
16213            sha256: "a".repeat(64),
16214            bytes: 42,
16215            media_type: "application/pdf".to_string(),
16216            wrappers: vec!["gzip".to_string()],
16217            required: true,
16218        };
16219        let mut remote = V2BaselineAsset {
16220            blob_sha256: record.sha256.clone(),
16221            bytes: record.bytes,
16222            media_type: record.media_type.clone(),
16223            wrappers: record.wrappers.clone(),
16224            required: record.required,
16225            disposition: "withheld".to_string(),
16226            leaf_hash: "b".repeat(64),
16227        };
16228
16229        assert!(v2_asset_resumes_hosting(
16230            Some(&remote),
16231            path,
16232            &record,
16233            "hosted"
16234        ));
16235        assert!(!v2_asset_resumes_hosting(
16236            Some(&remote),
16237            path,
16238            &record,
16239            "withheld"
16240        ));
16241
16242        remote.disposition = "hosted".to_string();
16243        assert!(!v2_asset_resumes_hosting(
16244            Some(&remote),
16245            path,
16246            &record,
16247            "hosted"
16248        ));
16249
16250        remote.disposition = "withheld".to_string();
16251        remote.blob_sha256 = "c".repeat(64);
16252        assert!(!v2_asset_resumes_hosting(
16253            Some(&remote),
16254            path,
16255            &record,
16256            "hosted"
16257        ));
16258        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
16259    }
16260
16261    #[test]
16262    fn v2_fresh_clone_preserves_only_exact_inherited_withheld_asset_absence() {
16263        let path = "sources/report.pdf";
16264        let record = crate::AssetRecord {
16265            path: path.to_string(),
16266            sha256: "a".repeat(64),
16267            bytes: 42,
16268            media_type: "application/pdf".to_string(),
16269            wrappers: vec!["records/report.md".to_string()],
16270            required: true,
16271        };
16272        let mut base = V2BaselineAsset {
16273            blob_sha256: record.sha256.clone(),
16274            bytes: record.bytes,
16275            media_type: record.media_type.clone(),
16276            wrappers: record.wrappers.clone(),
16277            required: record.required,
16278            disposition: "withheld".to_string(),
16279            leaf_hash: "b".repeat(64),
16280        };
16281
16282        assert!(v2_asset_inherits_withheld_absence(
16283            Some(&base),
16284            Some(&record),
16285            Some(&record),
16286            false,
16287        ));
16288        assert!(!v2_asset_inherits_withheld_absence(
16289            Some(&base),
16290            Some(&record),
16291            Some(&record),
16292            true,
16293        ));
16294
16295        base.disposition = "hosted".to_string();
16296        assert!(!v2_asset_inherits_withheld_absence(
16297            Some(&base),
16298            Some(&record),
16299            Some(&record),
16300            false,
16301        ));
16302
16303        base.disposition = "withheld".to_string();
16304        let mut changed = record.clone();
16305        changed.bytes += 1;
16306        assert!(!v2_asset_inherits_withheld_absence(
16307            Some(&base),
16308            Some(&record),
16309            Some(&changed),
16310            false,
16311        ));
16312        assert!(!v2_asset_inherits_withheld_absence(
16313            None,
16314            None,
16315            Some(&record),
16316            false,
16317        ));
16318    }
16319
16320    #[test]
16321    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
16322        let path = "records/team/alpha.md".to_string();
16323        let deleted_path = "records/team/deleted.md".to_string();
16324        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
16325            sha256,
16326            bytes,
16327            file: None,
16328        };
16329        let files = vec![
16330            V2ConflictFile {
16331                path: path.clone(),
16332                base: coordinate(None, None),
16333                local: coordinate(Some("b".repeat(64)), Some(7)),
16334                remote: coordinate(Some("a".repeat(64)), Some(5)),
16335            },
16336            V2ConflictFile {
16337                path: deleted_path.clone(),
16338                base: coordinate(Some("c".repeat(64)), Some(9)),
16339                local: coordinate(Some("d".repeat(64)), Some(11)),
16340                remote: coordinate(None, None),
16341            },
16342        ];
16343        let proven = V2BaselineFile {
16344            sha256: "a".repeat(64),
16345            bytes: 5,
16346            proof: None,
16347        };
16348        let current = [(path.clone(), proven.clone())]
16349            .into_iter()
16350            .collect::<std::collections::BTreeMap<_, _>>();
16351
16352        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
16353        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
16354        assert_eq!(deleted, vec![deleted_path.clone()]);
16355
16356        let changed = [(
16357            path.clone(),
16358            V2BaselineFile {
16359                sha256: "e".repeat(64),
16360                bytes: 5,
16361                proof: None,
16362            },
16363        )]
16364        .into_iter()
16365        .collect::<std::collections::BTreeMap<_, _>>();
16366        assert!(v2_take_remote_selection(&files, &changed).is_err());
16367
16368        let resurrected = [
16369            (path, proven),
16370            (
16371                deleted_path,
16372                V2BaselineFile {
16373                    sha256: "f".repeat(64),
16374                    bytes: 13,
16375                    proof: None,
16376                },
16377            ),
16378        ]
16379        .into_iter()
16380        .collect::<std::collections::BTreeMap<_, _>>();
16381        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
16382    }
16383
16384    #[cfg(target_os = "linux")]
16385    #[test]
16386    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
16387        use std::os::fd::AsRawFd as _;
16388
16389        let sandbox = tempfile::TempDir::new().unwrap();
16390        let parent = std::fs::File::open(sandbox.path()).unwrap();
16391        let stage = std::ffi::CString::new("stage").unwrap();
16392        let destination = std::ffi::CString::new("brain").unwrap();
16393
16394        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
16395        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
16396        install_stage_at(
16397            parent.as_raw_fd(),
16398            stage.as_c_str(),
16399            destination.as_c_str(),
16400            false,
16401        )
16402        .unwrap();
16403        assert!(!sandbox.path().join("stage").exists());
16404        assert_eq!(
16405            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
16406            b"created"
16407        );
16408
16409        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
16410        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
16411        install_stage_at(
16412            parent.as_raw_fd(),
16413            stage.as_c_str(),
16414            destination.as_c_str(),
16415            true,
16416        )
16417        .unwrap();
16418        assert_eq!(
16419            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
16420            b"replacement"
16421        );
16422        assert_eq!(
16423            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
16424            b"created",
16425            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
16426        );
16427    }
16428
16429    struct SignedRemoteFixture {
16430        card: String,
16431        feed: String,
16432        key: AgentSigningKey,
16433        identity: FeedIdentity,
16434    }
16435
16436    fn signed_remote_fixture() -> SignedRemoteFixture {
16437        let rng = ring::rand::SystemRandom::new();
16438        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16439        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16440        let (public_key, multikey) = public_identity_for(&pair);
16441        let identity = FeedIdentity {
16442            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16443            public_key_spki: public_key.clone(),
16444            previous: Vec::new(),
16445            rotations: Vec::new(),
16446        };
16447        let mut entry = FeedEntry {
16448            v: 1,
16449            seq: 1,
16450            ts: "2026-07-30T12:00:00.000Z".to_string(),
16451            brain: multikey.clone(),
16452            public_key: public_key.clone(),
16453            kind: "push".to_string(),
16454            op: "snapshot".to_string(),
16455            pack_sha256: "a".repeat(64),
16456            files: Vec::new(),
16457            removed: Vec::new(),
16458            prev_entry_hash: None,
16459            sig: String::new(),
16460        };
16461        let unsigned = UnsignedFeedEntry {
16462            v: entry.v,
16463            seq: entry.seq,
16464            ts: &entry.ts,
16465            brain: &entry.brain,
16466            public_key: &entry.public_key,
16467            kind: &entry.kind,
16468            op: &entry.op,
16469            pack_sha256: &entry.pack_sha256,
16470            files: &entry.files,
16471            removed: &entry.removed,
16472            prev_entry_hash: &entry.prev_entry_hash,
16473        };
16474        entry.sig =
16475            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16476        let mut exact = serde_json::to_vec(&entry).unwrap();
16477        exact.push(b'\n');
16478        let hash = content_sha256(&exact);
16479        let card = json!({
16480            "id": TEST_BRAIN_ID,
16481            "headSeq": 1,
16482            "feedHash": hash,
16483            "identity": identity.clone(),
16484        })
16485        .to_string();
16486        let feed = json!({
16487            "headSeq": 1,
16488            "feedHash": hash,
16489            "identity": identity.clone(),
16490            "entries": [{"hash": hash, "entry": entry}],
16491            "scopeLimited": false,
16492        })
16493        .to_string();
16494        SignedRemoteFixture {
16495            card,
16496            feed,
16497            key: AgentSigningKey {
16498                pkcs8: pkcs8.as_ref().to_vec(),
16499                multikey,
16500                public_key_spki: public_key,
16501            },
16502            identity,
16503        }
16504    }
16505
16506    #[test]
16507    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
16508        let file = |path: &str, byte: char| FeedFile {
16509            path: path.to_string(),
16510            sha256: byte.to_string().repeat(64),
16511            bytes: 1,
16512        };
16513        let a0 = file("records/a.md", 'a');
16514        let a1 = file("records/a.md", 'b');
16515        let stable = file("records/stable.md", 'c');
16516        let added = file("records/added.md", 'd');
16517        let removed_file = file("records/removed.md", 'e');
16518        let previous = vec![a0, stable.clone(), removed_file.clone()];
16519        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
16520        let removed = vec![removed_file.path.clone()];
16521
16522        assert_eq!(
16523            verify_v1_manifest_disclosure(
16524                "edit",
16525                &previous,
16526                &resulting,
16527                &[a1.clone(), added.clone()],
16528                &removed,
16529            ),
16530            Ok(())
16531        );
16532        assert_eq!(
16533            verify_v1_manifest_disclosure(
16534                "edit",
16535                &previous,
16536                &resulting,
16537                &[stable.clone(), added.clone(), a1.clone()],
16538                &removed,
16539            ),
16540            Ok(())
16541        );
16542        assert_eq!(
16543            verify_v1_manifest_disclosure(
16544                "edit",
16545                &previous,
16546                &resulting,
16547                std::slice::from_ref(&added),
16548                &removed,
16549            ),
16550            Err(V1DisclosureError::EditMissingChange)
16551        );
16552        assert_eq!(
16553            verify_v1_manifest_disclosure(
16554                "edit",
16555                &previous,
16556                &resulting,
16557                &[file("records/a.md", 'f'), added.clone()],
16558                &removed,
16559            ),
16560            Err(V1DisclosureError::EditFalseFile)
16561        );
16562        assert_eq!(
16563            verify_v1_manifest_disclosure(
16564                "edit",
16565                &previous,
16566                &resulting,
16567                &[a1.clone(), added.clone()],
16568                &[],
16569            ),
16570            Err(V1DisclosureError::RemovedMismatch)
16571        );
16572        assert_eq!(
16573            verify_v1_manifest_disclosure(
16574                "push",
16575                &previous,
16576                &resulting,
16577                &[added.clone(), stable, a1],
16578                &removed,
16579            ),
16580            Ok(())
16581        );
16582        assert_eq!(
16583            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
16584            Err(V1DisclosureError::PushManifestMismatch)
16585        );
16586    }
16587
16588    #[test]
16589    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
16590        let fixture = signed_remote_fixture();
16591        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
16592        let item = feed["entries"][0].to_string();
16593        let oversized_page = format!(
16594            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
16595            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
16596                .collect::<Vec<_>>()
16597                .join(",")
16598        );
16599        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
16600
16601        let oversized_identity = format!(
16602            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
16603            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
16604                .collect::<Vec<_>>()
16605                .join(",")
16606        );
16607        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
16608
16609        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
16610        let oversized_entry = format!(
16611            "{{\"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\"}}",
16612            "a".repeat(64),
16613            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
16614                .collect::<Vec<_>>()
16615                .join(",")
16616        );
16617        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
16618    }
16619
16620    #[test]
16621    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
16622        let id = "01arz3ndektsv4rrffq69g5fav";
16623        let digest = "a".repeat(64);
16624        assert_eq!(
16625            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
16626            V2BulkConfirmation {
16627                id: id.to_string(),
16628                digest,
16629            }
16630        );
16631        for invalid in [
16632            "",
16633            "01arz3ndektsv4rrffq69g5fav",
16634            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
16635            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
16636            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
16637        ] {
16638            assert!(matches!(
16639                V2BulkConfirmation::parse(invalid),
16640                Err(LinkError::InvalidPack { .. })
16641            ));
16642        }
16643    }
16644
16645    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
16646        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
16647        use std::net::TcpListener;
16648
16649        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16650        let url = format!("http://{}", listener.local_addr().unwrap());
16651        let handle = std::thread::spawn(move || {
16652            for (status, body) in responses {
16653                let (stream, _) = listener.accept().unwrap();
16654                let mut reader = BufReader::new(stream);
16655                let mut line = String::new();
16656                reader.read_line(&mut line).unwrap();
16657                let mut content_length = 0usize;
16658                loop {
16659                    line.clear();
16660                    reader.read_line(&mut line).unwrap();
16661                    if line == "\r\n" || line == "\n" || line.is_empty() {
16662                        break;
16663                    }
16664                    if let Some((name, value)) = line.split_once(':') {
16665                        if name.eq_ignore_ascii_case("content-length") {
16666                            content_length = value.trim().parse().unwrap();
16667                        }
16668                    }
16669                }
16670                let mut request_body = vec![0_u8; content_length];
16671                reader.read_exact(&mut request_body).unwrap();
16672                let response = format!(
16673                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
16674                    body.len()
16675                );
16676                reader.get_mut().write_all(response.as_bytes()).unwrap();
16677            }
16678        });
16679        (url, handle)
16680    }
16681
16682    fn routed_json_hub(
16683        requests: usize,
16684        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
16685    ) -> (String, std::thread::JoinHandle<()>) {
16686        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
16687        use std::net::TcpListener;
16688
16689        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
16690        let url = format!("http://{}", listener.local_addr().unwrap());
16691        let handle = std::thread::spawn(move || {
16692            for _ in 0..requests {
16693                let (stream, _) = listener.accept().unwrap();
16694                let mut reader = BufReader::new(stream);
16695                let mut line = String::new();
16696                reader.read_line(&mut line).unwrap();
16697                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
16698                let mut content_length = 0usize;
16699                loop {
16700                    line.clear();
16701                    reader.read_line(&mut line).unwrap();
16702                    if line == "\r\n" || line == "\n" || line.is_empty() {
16703                        break;
16704                    }
16705                    if let Some((name, value)) = line.split_once(':') {
16706                        if name.eq_ignore_ascii_case("content-length") {
16707                            content_length = value.trim().parse().unwrap();
16708                        }
16709                    }
16710                }
16711                let mut request_body = vec![0_u8; content_length];
16712                reader.read_exact(&mut request_body).unwrap();
16713                let (status, body) = respond(&path);
16714                let response = format!(
16715                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
16716                    body.len()
16717                );
16718                reader.get_mut().write_all(response.as_bytes()).unwrap();
16719            }
16720        });
16721        (url, handle)
16722    }
16723
16724    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
16725        HubConfig {
16726            hub,
16727            key: Some("test-key".to_string()),
16728            agent_key: None,
16729            brain_key: None,
16730            state_dir,
16731            store_selected: false,
16732        }
16733    }
16734
16735    #[cfg(any(unix, windows))]
16736    #[test]
16737    fn an_expired_asset_capability_is_refreshed_without_losing_cached_progress() {
16738        use std::sync::{Arc, Mutex};
16739
16740        let bytes = b"immutable asset bytes".to_vec();
16741        let sha256 = content_sha256(&bytes);
16742        let commit_hash = "c".repeat(64);
16743        let base_url = Arc::new(Mutex::new(String::new()));
16744        let server_base = Arc::clone(&base_url);
16745        let object_attempt = Arc::new(std::sync::atomic::AtomicUsize::new(0));
16746        let server_attempt = Arc::clone(&object_attempt);
16747        let response_bytes = bytes.clone();
16748        let response_sha = sha256.clone();
16749        let response_commit = commit_hash.clone();
16750        let (hub, server) = routed_json_hub(4, move |path| {
16751            if path.contains("/v2/assets/downloads") {
16752                let attempt = server_attempt.load(std::sync::atomic::Ordering::SeqCst) + 1;
16753                let url = format!("{}/asset/{attempt}", server_base.lock().unwrap());
16754                return (
16755                    200,
16756                    json!({
16757                        "v": 2,
16758                        "commit": response_commit,
16759                        "downloads": [{
16760                            "path": "assets/proof.bin",
16761                            "sha256": response_sha,
16762                            "bytes": response_bytes.len(),
16763                            "url": url,
16764                            "method": "GET"
16765                        }]
16766                    })
16767                    .to_string(),
16768                );
16769            }
16770            let attempt = server_attempt.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
16771            if attempt == 0 {
16772                (403, "{}".to_string())
16773            } else {
16774                (200, String::from_utf8(response_bytes.clone()).unwrap())
16775            }
16776        });
16777        *base_url.lock().unwrap() = hub.clone();
16778
16779        let temp = tempfile::tempdir().unwrap();
16780        let cache = temp.path().join("cache");
16781        std::fs::create_dir(&cache).unwrap();
16782        let cfg = test_hub_config(hub, temp.path().to_path_buf());
16783        let pointer = V2PointerBody {
16784            v: 2,
16785            brain: TEST_BRAIN_ID.to_string(),
16786            seq: 1,
16787            commit_hash,
16788            feed_hash: "f".repeat(64),
16789            content_root: Some("a".repeat(64)),
16790            asset_root: Some("b".repeat(64)),
16791            materializer: "m".repeat(64),
16792            signer_epoch: 1,
16793            control_revision: "d".repeat(64),
16794            backup_preparation: "ready".to_string(),
16795            prior_pointer_hash: None,
16796            signed_at: "2026-08-23T00:00:00Z".to_string(),
16797        };
16798        let path = "assets/proof.bin".to_string();
16799        let asset = V2BaselineAsset {
16800            blob_sha256: sha256.clone(),
16801            bytes: bytes.len() as u64,
16802            media_type: "application/octet-stream".to_string(),
16803            wrappers: Vec::new(),
16804            required: true,
16805            disposition: "hosted".to_string(),
16806            leaf_hash: "e".repeat(64),
16807        };
16808
16809        let staged = stage_v2_asset_download_window(
16810            &cfg,
16811            TEST_BRAIN_ID,
16812            &pointer,
16813            &cache,
16814            &[(&path, &asset)],
16815        )
16816        .expect("a fresh authority-checked capability recovers an expired one");
16817        assert_eq!(staged.len(), 1);
16818        assert_eq!(staged[0].path, path);
16819        assert_eq!(std::fs::read(cache.join(sha256)).unwrap(), bytes);
16820        assert_eq!(object_attempt.load(std::sync::atomic::Ordering::SeqCst), 2);
16821        server.join().unwrap();
16822    }
16823
16824    #[cfg(any(unix, windows))]
16825    #[test]
16826    fn asset_capability_windows_cannot_exceed_the_worker_bound() {
16827        let temp = tempfile::tempdir().unwrap();
16828        let cfg = test_hub_config("http://127.0.0.1:1".to_string(), temp.path().to_path_buf());
16829        let pointer = V2PointerBody {
16830            v: 2,
16831            brain: TEST_BRAIN_ID.to_string(),
16832            seq: 1,
16833            commit_hash: "c".repeat(64),
16834            feed_hash: "f".repeat(64),
16835            content_root: Some("a".repeat(64)),
16836            asset_root: Some("b".repeat(64)),
16837            materializer: "m".repeat(64),
16838            signer_epoch: 1,
16839            control_revision: "d".repeat(64),
16840            backup_preparation: "ready".to_string(),
16841            prior_pointer_hash: None,
16842            signed_at: "2026-08-23T00:00:00Z".to_string(),
16843        };
16844        let paths = (0..=V2_DOWNLOAD_CAPABILITY_FILES)
16845            .map(|index| format!("assets/{index}.bin"))
16846            .collect::<Vec<_>>();
16847        let assets = paths
16848            .iter()
16849            .map(|_| V2BaselineAsset {
16850                blob_sha256: "a".repeat(64),
16851                bytes: 1,
16852                media_type: "application/octet-stream".to_string(),
16853                wrappers: Vec::new(),
16854                required: true,
16855                disposition: "hosted".to_string(),
16856                leaf_hash: "b".repeat(64),
16857            })
16858            .collect::<Vec<_>>();
16859        let pending = paths.iter().zip(&assets).collect::<Vec<_>>();
16860
16861        let error =
16862            stage_v2_asset_download_window(&cfg, TEST_BRAIN_ID, &pointer, temp.path(), &pending)
16863                .expect_err("an oversized window must fail before any network request");
16864        assert!(matches!(error, LinkError::InvalidFeed { .. }));
16865    }
16866
16867    #[test]
16868    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
16869        use ring::signature::KeyPair as _;
16870
16871        let rng = ring::rand::SystemRandom::new();
16872        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16873        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16874        let (spki, multikey) = public_identity_for(&pair);
16875        let key = AgentSigningKey {
16876            pkcs8: pkcs8.as_ref().to_vec(),
16877            multikey,
16878            public_key_spki: spki,
16879        };
16880        let header = linkmd_sig_header(
16881            &key,
16882            "https://hub-a.example",
16883            "post",
16884            "/api/hub/brains/brain/push?mode=exact",
16885            Some("{\"ok\":true}"),
16886        )
16887        .unwrap();
16888        assert!(header.starts_with("LinkMD-Sig v2,"));
16889        let ts = header
16890            .split(",ts=")
16891            .nth(1)
16892            .unwrap()
16893            .split(',')
16894            .next()
16895            .unwrap();
16896        let signature = URL_SAFE_NO_PAD
16897            .decode(header.rsplit(",sig=").next().unwrap())
16898            .unwrap();
16899        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
16900        let accepted = format!(
16901            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16902        );
16903        let replayed = format!(
16904            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
16905        );
16906        let public = pair.public_key().as_ref();
16907        assert!(UnparsedPublicKey::new(&ED25519, public)
16908            .verify(accepted.as_bytes(), &signature)
16909            .is_ok());
16910        assert!(
16911            UnparsedPublicKey::new(&ED25519, public)
16912                .verify(replayed.as_bytes(), &signature)
16913                .is_err(),
16914            "a proof captured at hub A must not authenticate at hub B"
16915        );
16916    }
16917
16918    #[test]
16919    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
16920        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
16921        let card = json!({
16922            "id": other,
16923            "headSeq": 0,
16924            "identity": signed_remote_fixture().identity,
16925        })
16926        .to_string();
16927        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
16928        let state = tempfile::tempdir().unwrap();
16929        let cfg = test_hub_config(hub, state.path().to_path_buf());
16930        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16931        assert!(
16932            error.contains("differs from the explicitly requested"),
16933            "{error}"
16934        );
16935        server.join().unwrap();
16936    }
16937
16938    #[test]
16939    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
16940        let first = signed_remote_fixture().identity;
16941        let second = signed_remote_fixture().identity;
16942        let card = |identity: FeedIdentity| {
16943            json!({
16944                "id": TEST_BRAIN_ID,
16945                "headSeq": 0,
16946                "identity": identity,
16947            })
16948            .to_string()
16949        };
16950        let (hub, server) = scripted_json_hub(vec![
16951            (404, "{}".to_string()),
16952            (200, card(first)),
16953            (404, "{}".to_string()),
16954            (200, card(second)),
16955        ]);
16956        let state = tempfile::tempdir().unwrap();
16957        let cfg = test_hub_config(hub, state.path().to_path_buf());
16958        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
16959        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
16960        assert!(
16961            error.contains("pinned anchor") || error.contains("forked away"),
16962            "{error}"
16963        );
16964        server.join().unwrap();
16965    }
16966
16967    #[test]
16968    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
16969        let old = signed_remote_fixture();
16970        let new = signed_remote_fixture();
16971        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
16972        let unsigned = serde_json::to_string(&UnsignedRotation {
16973            v: 1,
16974            op: "rotate",
16975            brain: &old.key.multikey,
16976            public_key: &old.key.public_key_spki,
16977            new_brain: &new.key.multikey,
16978            new_public_key: &new.key.public_key_spki,
16979            prior_head_seq: 1,
16980            prior_feed_hash: Some(&"a".repeat(64)),
16981            ts: "2026-07-30T12:00:00.000Z".to_string(),
16982        })
16983        .unwrap();
16984        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
16985        let rotation = format!(
16986            "{},\"sig\":\"{}\"}}",
16987            &unsigned[..unsigned.len() - 1],
16988            signature
16989        );
16990        let identity = FeedIdentity {
16991            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
16992            public_key_spki: new.key.public_key_spki,
16993            previous: vec![PreviousIdentity {
16994                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
16995                public_key_spki: old.key.public_key_spki,
16996            }],
16997            rotations: vec![rotation],
16998        };
16999        let card = json!({
17000            "id": TEST_BRAIN_ID,
17001            "headSeq": 0,
17002            "feedHash": null,
17003            "identity": identity,
17004        })
17005        .to_string();
17006        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
17007        let state = tempfile::tempdir().unwrap();
17008        let cfg = test_hub_config(hub, state.path().to_path_buf());
17009        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
17010        assert!(
17011            error.contains("rotation claims a feed boundary beyond the advertised head"),
17012            "{error}"
17013        );
17014        assert!(
17015            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
17016            "an inconsistent empty-head identity must not become the TOFU checkpoint"
17017        );
17018        server.join().unwrap();
17019    }
17020
17021    #[test]
17022    fn trust_checkpoint_rejects_a_later_fork() {
17023        let fixture = signed_remote_fixture();
17024        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
17025        fork["feedHash"] = Value::String("b".repeat(64));
17026        let (hub, server) = scripted_json_hub(vec![
17027            (404, "{}".to_string()),
17028            (200, fixture.card),
17029            (200, fixture.feed),
17030            (404, "{}".to_string()),
17031            (200, fork.to_string()),
17032        ]);
17033        let state = tempfile::tempdir().unwrap();
17034        let cfg = test_hub_config(hub, state.path().to_path_buf());
17035        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
17036        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
17037        server.join().unwrap();
17038    }
17039
17040    #[test]
17041    fn alias_and_canonical_id_share_one_identity_checkpoint() {
17042        let trusted = signed_remote_fixture();
17043        let attacker = signed_remote_fixture();
17044        let (hub, server) = scripted_json_hub(vec![
17045            (404, "{}".to_string()),
17046            (200, trusted.card),
17047            (200, trusted.feed),
17048            (404, "{}".to_string()),
17049            (200, attacker.card),
17050        ]);
17051        let state = tempfile::tempdir().unwrap();
17052        let cfg = test_hub_config(hub, state.path().to_path_buf());
17053        assert!(head(&cfg, "trusted-slug").unwrap().verified);
17054        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
17055        assert!(
17056            error.contains("equivocation")
17057                || error.contains("pinned")
17058                || error.contains("identity"),
17059            "{error}"
17060        );
17061        server.join().unwrap();
17062    }
17063
17064    #[test]
17065    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
17066        let state = tempfile::tempdir().unwrap();
17067        let cfg = test_hub_config(
17068            "https://hub.example".to_string(),
17069            state.path().to_path_buf(),
17070        );
17071        let directory = open_trust_dir(&cfg).unwrap();
17072        let old = TEST_BRAIN_ID;
17073        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17074        save_alias_in(
17075            &cfg,
17076            &directory,
17077            &AliasBinding {
17078                v: 1,
17079                origin: normalized_origin(&cfg.hub).unwrap(),
17080                requested: "company-brain".to_string(),
17081                brain: old.to_string(),
17082                home: Some("company-brain".to_string()),
17083            },
17084        )
17085        .unwrap();
17086
17087        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
17088        assert!(matches!(
17089            error,
17090            LinkError::AliasRebindRequired {
17091                alias,
17092                from,
17093                to
17094            } if alias == "company-brain" && from == old && to == new
17095        ));
17096        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
17097            .unwrap()
17098            .unwrap();
17099        assert_eq!(unchanged.brain, old);
17100        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
17101    }
17102
17103    #[test]
17104    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
17105        let alpha = signed_remote_fixture();
17106        let beta = signed_remote_fixture();
17107        let alpha_card = alpha.card.clone();
17108        let alpha_feed = alpha.feed.clone();
17109        let beta_card = beta.card.clone();
17110        let beta_feed = beta.feed.clone();
17111        let (hub, server) = routed_json_hub(5, move |path| {
17112            if path.ends_with("/v2/head") {
17113                (404, "{}".to_string())
17114            } else if path.contains("/alpha/feed?") {
17115                (200, alpha_feed.clone())
17116            } else if path.contains("/beta/feed?") {
17117                (200, beta_feed.clone())
17118            } else if path.ends_with("/alpha") {
17119                (200, alpha_card.clone())
17120            } else if path.ends_with("/beta") {
17121                (200, beta_card.clone())
17122            } else {
17123                (500, r#"{"error":"unexpected path"}"#.to_string())
17124            }
17125        });
17126        let state = tempfile::tempdir().unwrap();
17127        let cfg = test_hub_config(hub, state.path().to_path_buf());
17128        let alpha_cfg = cfg.clone();
17129        let beta_cfg = cfg;
17130        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
17131        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
17132        let results = [first.join().unwrap(), second.join().unwrap()];
17133        assert_eq!(
17134            results.iter().filter(|result| result.is_ok()).count(),
17135            1,
17136            "only one alias identity may establish canonical TOFU: {results:?}"
17137        );
17138        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
17139        server.join().unwrap();
17140    }
17141
17142    #[cfg(unix)]
17143    #[test]
17144    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
17145        use std::os::unix::fs::symlink;
17146
17147        let fixture = signed_remote_fixture();
17148        let card = json!({
17149            "id": TEST_BRAIN_ID,
17150            "headSeq": 0,
17151            "feedHash": Value::Null,
17152            "identity": fixture.identity,
17153        })
17154        .to_string();
17155        let work = tempfile::tempdir().unwrap();
17156        let outside = tempfile::tempdir().unwrap();
17157        let state = work.path().join("state");
17158        let moved = work.path().join("state-held");
17159        let swap_state = state.clone();
17160        let swap_moved = moved.clone();
17161        let outside_path = outside.path().to_path_buf();
17162        let (hub, server) = routed_json_hub(1, move |_| {
17163            // The client has already opened state/trust before this response.
17164            std::fs::rename(&swap_state, &swap_moved).unwrap();
17165            symlink(&outside_path, &swap_state).unwrap();
17166            (200, card.clone())
17167        });
17168        let cfg = test_hub_config(hub, state);
17169
17170        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
17171        assert_eq!(verified.head.seq, 0);
17172        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
17173        assert!(std::fs::read_dir(moved.join("trust"))
17174            .unwrap()
17175            .flatten()
17176            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
17177        server.join().unwrap();
17178    }
17179
17180    #[test]
17181    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
17182        let remote = signed_remote_fixture();
17183        let unrelated = signed_remote_fixture().key;
17184        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
17185        let state = tempfile::tempdir().unwrap();
17186        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
17187        cfg.brain_key = Some(unrelated);
17188        let error = sync_push(
17189            &cfg,
17190            TEST_BRAIN_ID,
17191            &[("DB.md".to_string(), "signed local content".to_string())],
17192        )
17193        .unwrap_err()
17194        .to_string();
17195        assert!(
17196            error.contains("not the verified current brain identity"),
17197            "{error}"
17198        );
17199        server.join().unwrap();
17200    }
17201
17202    #[test]
17203    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
17204        let remote = signed_remote_fixture();
17205        let new = signed_remote_fixture().key;
17206        let state = tempfile::tempdir().unwrap();
17207        let new_file = state.path().join("new.key");
17208        std::fs::write(
17209            &new_file,
17210            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
17211        )
17212        .unwrap();
17213        #[cfg(unix)]
17214        {
17215            use std::os::unix::fs::PermissionsExt as _;
17216            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
17217        }
17218        let forged = json!({
17219            "brain": TEST_BRAIN_ID,
17220            "identity": {
17221                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
17222                "publicKeySpki": new.public_key_spki,
17223            }
17224        })
17225        .to_string();
17226        let (hub, server) = scripted_json_hub(vec![
17227            (404, "{}".to_string()),
17228            (200, remote.card.clone()),
17229            (200, remote.feed.clone()),
17230            (200, forged),
17231            (200, remote.card),
17232            (200, remote.feed),
17233        ]);
17234        let cfg = test_hub_config(hub, state.path().to_path_buf());
17235        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
17236            .unwrap_err()
17237            .to_string();
17238        assert!(
17239            error.contains("without committing the verified new identity"),
17240            "{error}"
17241        );
17242        server.join().unwrap();
17243    }
17244
17245    #[test]
17246    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
17247        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17248        let raw = format!(
17249            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
17250        );
17251        let pack = build_store_pack(&[
17252            (
17253                "DB.md".to_string(),
17254                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
17255            ),
17256            ("records/clients/truth.md".to_string(), raw.clone()),
17257        ])
17258        .unwrap();
17259        let by_id = resolve_from_verified_pack(
17260            "01j5qc3v9k4ym8rwbn2tqe6f7d",
17261            &AddressTarget::Id(record_id.to_string()),
17262            pack.clone(),
17263        )
17264        .unwrap();
17265        assert_eq!(by_id["document"]["summary"], "Signed truth");
17266        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
17267        assert_eq!(
17268            by_id["document"]["contentSha"],
17269            content_sha256(raw.as_bytes())
17270        );
17271
17272        let by_path = resolve_from_verified_pack(
17273            "01j5qc3v9k4ym8rwbn2tqe6f7d",
17274            &AddressTarget::Path("records/clients/truth.md".to_string()),
17275            pack,
17276        )
17277        .unwrap();
17278        assert_eq!(by_path["document"]["id"], record_id);
17279        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
17280
17281        let wrong_id = resolve_from_verified_record_bytes(
17282            TEST_BRAIN_ID,
17283            &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
17284            "records/clients/truth.md".to_string(),
17285            raw.as_bytes().to_vec(),
17286        )
17287        .unwrap_err()
17288        .to_string();
17289        assert!(wrong_id.contains("id differs"), "{wrong_id}");
17290
17291        let wrong_path = resolve_from_verified_record_bytes(
17292            TEST_BRAIN_ID,
17293            &AddressTarget::Path("records/clients/other.md".to_string()),
17294            "records/clients/truth.md".to_string(),
17295            raw.into_bytes(),
17296        )
17297        .unwrap_err()
17298        .to_string();
17299        assert!(wrong_path.contains("path differs"), "{wrong_path}");
17300    }
17301
17302    #[test]
17303    fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
17304        let path = "records/clients/truth.md";
17305        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
17306        let raw = format!(
17307            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
17308        );
17309        let sha256 = content_sha256(raw.as_bytes());
17310        let mut nonce = 0_u128;
17311        let tree = crate::linkmd_v2::build_content_tree(
17312            &[crate::linkmd_v2::ContentFile {
17313                path: path.to_string(),
17314                blob_hash: sha256.clone(),
17315                bytes: raw.len() as u64,
17316            }],
17317            None,
17318            &mut || {
17319                nonce += 1;
17320                format!("{nonce:032x}")
17321            },
17322        )
17323        .unwrap();
17324        let root = tree.root.clone().unwrap();
17325        let mut directory_root = root.clone();
17326        let mut proof = Vec::new();
17327        for component in path.split('/') {
17328            let inclusion =
17329                crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
17330            let child = match &inclusion {
17331                crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
17332                crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
17333                    panic!("fixture path must have an inclusion proof")
17334                }
17335            };
17336            proof.push(json!({
17337                "directory_root": directory_root,
17338                "component": component,
17339                "proof": inclusion,
17340            }));
17341            directory_root = child;
17342        }
17343        let commit_hash = "c".repeat(64);
17344        let pointer = V2PointerBody {
17345            v: 2,
17346            brain: TEST_BRAIN_ID.to_string(),
17347            seq: 1,
17348            commit_hash: commit_hash.clone(),
17349            feed_hash: "f".repeat(64),
17350            content_root: Some(root.clone()),
17351            asset_root: None,
17352            materializer: "dbmd-projection-v1".to_string(),
17353            signer_epoch: 1,
17354            control_revision: "d".repeat(64),
17355            backup_preparation: "e".repeat(64),
17356            prior_pointer_hash: None,
17357            signed_at: "2026-08-21T12:00:00.000Z".to_string(),
17358        };
17359        let manifest = json!({
17360            "v": 2,
17361            "commit": commit_hash,
17362            "content_root": root,
17363            "files": [{
17364                "path": path,
17365                "sha256": sha256,
17366                "bytes": raw.len(),
17367                "proof": proof,
17368            }],
17369            "next_cursor": Value::Null,
17370        })
17371        .to_string();
17372
17373        let path_manifest = manifest.clone();
17374        let (hub, server) = routed_json_hub(1, move |request| {
17375            assert_eq!(
17376                request,
17377                format!(
17378                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
17379                    "c".repeat(64)
17380                )
17381            );
17382            (200, path_manifest.clone())
17383        });
17384        let state = tempfile::tempdir().unwrap();
17385        let cfg = test_hub_config(hub, state.path().to_path_buf());
17386        let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
17387            .unwrap()
17388            .unwrap();
17389        assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
17390        assert!(by_path.proof.is_some());
17391        server.join().unwrap();
17392
17393        let (hub, server) = routed_json_hub(1, move |request| {
17394            assert_eq!(
17395                request,
17396                format!(
17397                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
17398                    "c".repeat(64)
17399                )
17400            );
17401            (404, r#"{"error":"File not found"}"#.to_string())
17402        });
17403        let state = tempfile::tempdir().unwrap();
17404        let cfg = test_hub_config(hub, state.path().to_path_buf());
17405        assert!(
17406            v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
17407                .unwrap()
17408                .is_none()
17409        );
17410        server.join().unwrap();
17411
17412        let id_manifest = manifest;
17413        let (hub, server) = routed_json_hub(1, move |request| {
17414            assert_eq!(
17415                request,
17416                format!(
17417                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
17418                    "c".repeat(64)
17419                )
17420            );
17421            (200, id_manifest.clone())
17422        });
17423        let state = tempfile::tempdir().unwrap();
17424        let cfg = test_hub_config(hub, state.path().to_path_buf());
17425        let (located_path, by_id) =
17426            v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
17427        assert_eq!(located_path, path);
17428        assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
17429        server.join().unwrap();
17430    }
17431
17432    #[test]
17433    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
17434        let unsorted = vec![
17435            ("records/a.md".to_string(), "alpha\n".to_string()),
17436            ("DB.md".to_string(), "# db\n".to_string()),
17437        ];
17438        let sorted = vec![
17439            ("DB.md".to_string(), "# db\n".to_string()),
17440            ("records/a.md".to_string(), "alpha\n".to_string()),
17441        ];
17442        let pack = build_store_pack(&unsorted).unwrap();
17443
17444        // This digest is shared with the hub implementation. It locks every
17445        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
17446        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
17447        // absence of descriptors/extras/comments/ZIP64.
17448        assert_eq!(pack.len(), 219);
17449        assert_eq!(
17450            content_sha256(&pack),
17451            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
17452        );
17453        assert_eq!(pack, build_store_pack(&sorted).unwrap());
17454        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
17455        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
17456        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
17457
17458        assert_eq!(
17459            parse_store_pack(pack).unwrap(),
17460            vec![
17461                ("DB.md".to_string(), b"# db\n".to_vec()),
17462                ("records/a.md".to_string(), b"alpha\n".to_vec()),
17463            ]
17464        );
17465    }
17466
17467    #[test]
17468    fn canonical_store_pack_validates_every_path_before_writing() {
17469        let duplicate = vec![
17470            ("DB.md".to_string(), "first".to_string()),
17471            ("DB.md".to_string(), "second".to_string()),
17472        ];
17473        assert!(build_store_pack(&duplicate)
17474            .unwrap_err()
17475            .to_string()
17476            .contains("duplicate path"));
17477        assert!(matches!(
17478            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
17479            Err(LinkError::UnsafePath { .. })
17480        ));
17481    }
17482
17483    #[test]
17484    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
17485        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
17486        // One byte stands in for the central directory. The trailer itself is
17487        // structurally valid; the count is the sole reason for refusal.
17488        let mut bytes = vec![0_u8];
17489        let zip64_offset = bytes.len() as u64;
17490        bytes.extend_from_slice(b"PK\x06\x06");
17491        bytes.extend_from_slice(&44_u64.to_le_bytes());
17492        bytes.extend_from_slice(&[0_u8; 12]);
17493        bytes.extend_from_slice(&COUNT.to_le_bytes());
17494        bytes.extend_from_slice(&COUNT.to_le_bytes());
17495        bytes.extend_from_slice(&1_u64.to_le_bytes());
17496        bytes.extend_from_slice(&0_u64.to_le_bytes());
17497        bytes.extend_from_slice(b"PK\x06\x07");
17498        bytes.extend_from_slice(&0_u32.to_le_bytes());
17499        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
17500        bytes.extend_from_slice(&1_u32.to_le_bytes());
17501        bytes.extend_from_slice(b"PK\x05\x06");
17502        bytes.extend_from_slice(&0_u16.to_le_bytes());
17503        bytes.extend_from_slice(&0_u16.to_le_bytes());
17504        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17505        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17506        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17507        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17508        bytes.extend_from_slice(&0_u16.to_le_bytes());
17509
17510        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
17511            .unwrap_err()
17512            .to_string();
17513        assert!(error.contains("invalid file count"), "{error}");
17514    }
17515
17516    #[test]
17517    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
17518        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
17519        let mut bytes = vec![0_u8];
17520        let zip64_offset = bytes.len() as u64;
17521        bytes.extend_from_slice(b"PK\x06\x06");
17522        bytes.extend_from_slice(&44_u64.to_le_bytes());
17523        bytes.extend_from_slice(&[0_u8; 12]);
17524        bytes.extend_from_slice(&COUNT.to_le_bytes());
17525        bytes.extend_from_slice(&COUNT.to_le_bytes());
17526        bytes.extend_from_slice(&1_u64.to_le_bytes());
17527        bytes.extend_from_slice(&0_u64.to_le_bytes());
17528        bytes.extend_from_slice(b"PK\x06\x07");
17529        bytes.extend_from_slice(&0_u32.to_le_bytes());
17530        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
17531        bytes.extend_from_slice(&1_u32.to_le_bytes());
17532        bytes.extend_from_slice(b"PK\x05\x06");
17533        bytes.extend_from_slice(&0_u16.to_le_bytes());
17534        bytes.extend_from_slice(&0_u16.to_le_bytes());
17535        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17536        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
17537        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17538        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
17539        bytes.extend_from_slice(&0_u16.to_le_bytes());
17540        // The old parser trusted this last low-count signature, while
17541        // ZipArchive fell back to the real Zip64 directory and allocated for
17542        // COUNT entries. Its central-directory offsets are deliberately fake.
17543        let fake_eocd = bytes.len() as u32;
17544        bytes.extend_from_slice(b"PK\x05\x06");
17545        bytes.extend_from_slice(&0_u16.to_le_bytes());
17546        bytes.extend_from_slice(&0_u16.to_le_bytes());
17547        bytes.extend_from_slice(&1_u16.to_le_bytes());
17548        bytes.extend_from_slice(&1_u16.to_le_bytes());
17549        bytes.extend_from_slice(&0_u32.to_le_bytes());
17550        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
17551        bytes.extend_from_slice(&0_u16.to_le_bytes());
17552
17553        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
17554            .unwrap_err()
17555            .to_string();
17556        assert!(error.contains("central directory"), "{error}");
17557    }
17558
17559    #[test]
17560    fn strict_http_status_handling_rejects_redirects_without_panicking() {
17561        let error = ensure_ok(
17562            HubResponse {
17563                status: 302,
17564                body: Some(json!({"redirect": "/elsewhere"})),
17565            },
17566            "mutation",
17567        )
17568        .unwrap_err();
17569        assert!(matches!(error, LinkError::Http { status: 302, .. }));
17570
17571        let error = ensure_raw_ok(
17572            RawHubResponse {
17573                status: 302,
17574                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
17575            },
17576            "feed",
17577        )
17578        .unwrap_err();
17579        assert!(matches!(error, LinkError::Http { status: 302, .. }));
17580    }
17581
17582    #[cfg(unix)]
17583    #[test]
17584    fn collect_push_files_refuses_external_symlink_and_nested_store() {
17585        use std::os::unix::fs::symlink;
17586
17587        let root = tempfile::tempdir().unwrap();
17588        std::fs::write(
17589            root.path().join("DB.md"),
17590            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
17591        )
17592        .unwrap();
17593        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
17594
17595        let external = tempfile::tempdir().unwrap();
17596        let secret = external.path().join("secret.md");
17597        std::fs::write(&secret, "TOP SECRET").unwrap();
17598        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
17599
17600        let store = Store::open_strict(root.path()).unwrap();
17601        let err = collect_push_files(&store).unwrap_err().to_string();
17602        assert!(err.contains("cannot push"), "{err}");
17603        assert!(
17604            !err.contains("TOP SECRET"),
17605            "external bytes must never leak"
17606        );
17607
17608        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
17609        let nested = root.path().join("records/nested");
17610        std::fs::create_dir_all(&nested).unwrap();
17611        std::fs::write(
17612            nested.join("DB.md"),
17613            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
17614        )
17615        .unwrap();
17616        let err = collect_push_files(&store).unwrap_err().to_string();
17617        assert!(err.contains("nested db.md store"), "{err}");
17618    }
17619
17620    #[test]
17621    fn collect_push_files_carries_curator_history_but_not_derived_catalogs() {
17622        let root = tempfile::tempdir().unwrap();
17623        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
17624        std::fs::create_dir_all(root.path().join("log")).unwrap();
17625        std::fs::write(
17626            root.path().join("DB.md"),
17627            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
17628        )
17629        .unwrap();
17630        std::fs::write(root.path().join("index.md"), "derived root catalog\n").unwrap();
17631        std::fs::write(
17632            root.path().join("records/notes/index.md"),
17633            "derived type catalog\n",
17634        )
17635        .unwrap();
17636        std::fs::write(
17637            root.path().join("records/notes/owned.md"),
17638            "---\ntype: note\nsummary: owned\ncreated: 2026-08-26T00:00:00Z\nupdated: 2026-08-26T00:00:00Z\n---\n",
17639        )
17640        .unwrap();
17641        std::fs::write(
17642            root.path().join("log.md"),
17643            "---\ntype: log\n---\n\n# Curator log\n",
17644        )
17645        .unwrap();
17646        std::fs::write(
17647            root.path().join("log/2026-07.md"),
17648            "---\ntype: log\n---\n\n# Curator log — 2026-07\n",
17649        )
17650        .unwrap();
17651        std::fs::write(root.path().join("log/README.txt"), "not a log archive\n").unwrap();
17652
17653        let store = Store::open_strict(root.path()).unwrap();
17654        let paths: Vec<String> = collect_push_files(&store)
17655            .unwrap()
17656            .into_iter()
17657            .map(|(path, _)| path)
17658            .collect();
17659
17660        assert!(paths.contains(&"DB.md".to_string()));
17661        assert!(paths.contains(&"records/notes/owned.md".to_string()));
17662        assert!(paths.contains(&"log.md".to_string()));
17663        assert!(paths.contains(&"log/2026-07.md".to_string()));
17664        assert!(!paths.contains(&"index.md".to_string()));
17665        assert!(!paths.contains(&"records/notes/index.md".to_string()));
17666        assert!(!paths.contains(&"log/README.txt".to_string()));
17667    }
17668
17669    #[cfg(unix)]
17670    #[test]
17671    fn remote_push_uses_opened_root_after_path_replacement() {
17672        use std::os::unix::fs::symlink;
17673
17674        let sandbox = tempfile::tempdir().unwrap();
17675        let root = sandbox.path().join("store");
17676        std::fs::create_dir_all(root.join("records/notes")).unwrap();
17677        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17678        std::fs::write(
17679            root.join("records/notes/owned.md"),
17680            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
17681        )
17682        .unwrap();
17683        let store = Store::open_strict(&root).unwrap();
17684        let detached = sandbox.path().join("detached");
17685        std::fs::rename(&root, &detached).unwrap();
17686
17687        let replacement = sandbox.path().join("replacement");
17688        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
17689        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17690        std::fs::write(
17691            replacement.join("records/notes/secret.md"),
17692            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
17693        )
17694        .unwrap();
17695        symlink(&replacement, &root).unwrap();
17696
17697        let files = collect_push_files(&store).unwrap();
17698        let wire_text = files
17699            .iter()
17700            .map(|(path, content)| format!("{path}\n{content}"))
17701            .collect::<Vec<_>>()
17702            .join("\n");
17703        assert!(wire_text.contains("owned upload"));
17704        assert!(!wire_text.contains("replacement sentinel"));
17705        assert!(!wire_text.contains("records/notes/secret.md"));
17706
17707        let remote = signed_remote_fixture();
17708        let (hub, server) = scripted_json_hub(vec![
17709            (200, remote.card),
17710            (200, remote.feed),
17711            (200, json!({"ok": true}).to_string()),
17712        ]);
17713        let state = tempfile::tempdir().unwrap();
17714        let cfg = test_hub_config(hub, state.path().to_path_buf());
17715        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
17716        assert_eq!(pushed, json!({"ok": true}));
17717        server.join().unwrap();
17718    }
17719
17720    #[test]
17721    fn signed_feed_item_verifies_identity_hash_and_signature() {
17722        use ring::rand::SystemRandom;
17723        use ring::signature::{Ed25519KeyPair, KeyPair};
17724
17725        const PREFIX: &[u8] = &[
17726            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
17727        ];
17728        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
17729        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17730        let mut spki = PREFIX.to_vec();
17731        spki.extend_from_slice(pair.public_key().as_ref());
17732        let public_key = URL_SAFE_NO_PAD.encode(&spki);
17733        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
17734        let mut entry = FeedEntry {
17735            v: 1,
17736            seq: 1,
17737            ts: "2026-07-14T00:00:00.000Z".to_string(),
17738            brain: format!("ed25519:{fingerprint}"),
17739            public_key: public_key.clone(),
17740            kind: "push".to_string(),
17741            op: "snapshot".to_string(),
17742            pack_sha256: "a".repeat(64),
17743            files: vec![FeedFile {
17744                path: "DB.md".to_string(),
17745                sha256: "b".repeat(64),
17746                bytes: 3,
17747            }],
17748            removed: vec![],
17749            prev_entry_hash: None,
17750            sig: String::new(),
17751        };
17752        let unsigned = UnsignedFeedEntry {
17753            v: entry.v,
17754            seq: entry.seq,
17755            ts: &entry.ts,
17756            brain: &entry.brain,
17757            public_key: &entry.public_key,
17758            kind: &entry.kind,
17759            op: &entry.op,
17760            pack_sha256: &entry.pack_sha256,
17761            files: &entry.files,
17762            removed: &entry.removed,
17763            prev_entry_hash: &entry.prev_entry_hash,
17764        };
17765        entry.sig =
17766            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
17767        let mut exact = serde_json::to_vec(&entry).unwrap();
17768        exact.push(b'\n');
17769        let item = FeedItem {
17770            hash: format!("{:x}", Sha256::digest(&exact)),
17771            entry,
17772        };
17773        let identity = FeedIdentity {
17774            fingerprint,
17775            public_key_spki: public_key,
17776            previous: Vec::new(),
17777            rotations: Vec::new(),
17778        };
17779        assert!(verify_feed_item(&item, &identity).is_ok());
17780        let mut tampered = item;
17781        tampered.entry.pack_sha256 = "c".repeat(64);
17782        assert!(verify_feed_item(&tampered, &identity).is_err());
17783    }
17784
17785    #[test]
17786    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
17787        let rng = ring::rand::SystemRandom::new();
17788        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
17789        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
17790        let (spki, multikey) = public_identity_for(&pair);
17791        let identity = V2HeadIdentity {
17792            custody: "self".to_string(),
17793            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
17794            public_key_spki: spki.clone(),
17795            previous: Vec::new(),
17796            rotations: Vec::new(),
17797        };
17798        let unsigned = json!({
17799            "actor_ref": "a".repeat(64),
17800            "asset_root": Value::Null,
17801            "brain": multikey,
17802            "changes_sha256": "b".repeat(64),
17803            "control_revision": "c".repeat(64),
17804            "materializer": "dbmd-projection-v1",
17805            "op": "changeset",
17806            "parent_asset_root": Value::Null,
17807            "parent_commit": Value::Null,
17808            "parent_root": Value::Null,
17809            "prev_entry_hash": Value::Null,
17810            "public_key": spki,
17811            "seq": 1,
17812            "signer_epoch": 1,
17813            "state_root": "d".repeat(64),
17814            "ts": "2026-08-19T12:00:00.000Z",
17815            "v": 2,
17816            "v1_bridge": {
17817                "feed_hash": "e".repeat(64),
17818                "head_seq": 7,
17819                "pack_sha256": "f".repeat(64),
17820            },
17821        });
17822        let sign_value = |value: Value| {
17823            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
17824            let mut object = value.as_object().unwrap().clone();
17825            object.insert(
17826                "sig".to_string(),
17827                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17828            );
17829            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
17830        };
17831        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
17832
17833        let mut extra = unsigned.clone();
17834        extra
17835            .as_object_mut()
17836            .unwrap()
17837            .insert("future".to_string(), Value::Bool(true));
17838        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
17839
17840        let mut missing = unsigned.clone();
17841        missing.as_object_mut().unwrap().remove("v1_bridge");
17842        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
17843
17844        let mut invalid_bridge = unsigned;
17845        invalid_bridge.as_object_mut().unwrap().insert(
17846            "v1_bridge".to_string(),
17847            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
17848        );
17849        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
17850    }
17851
17852    #[test]
17853    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
17854        let vector: Value = serde_json::from_str(include_str!(
17855            "../tests/vectors/linkmd-v2-commit-bridge.json"
17856        ))
17857        .unwrap();
17858        let identity_value = vector.get("identity").unwrap();
17859        let identity = V2HeadIdentity {
17860            custody: "self".to_string(),
17861            fingerprint: identity_value
17862                .get("fingerprint")
17863                .and_then(Value::as_str)
17864                .unwrap()
17865                .to_string(),
17866            public_key_spki: identity_value
17867                .get("public_key_spki")
17868                .and_then(Value::as_str)
17869                .unwrap()
17870                .to_string(),
17871            previous: Vec::new(),
17872            rotations: Vec::new(),
17873        };
17874        let private = URL_SAFE_NO_PAD
17875            .decode(
17876                identity_value
17877                    .get("private_key_pkcs8")
17878                    .and_then(Value::as_str)
17879                    .unwrap(),
17880            )
17881            .unwrap();
17882        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
17883            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
17884            .unwrap();
17885        let base = vector.get("body").unwrap().as_object().unwrap();
17886
17887        for item in vector.get("valid").unwrap().as_array().unwrap() {
17888            let mut body = base.clone();
17889            body.insert(
17890                "v1_bridge".to_string(),
17891                item.get("v1_bridge").unwrap().clone(),
17892            );
17893            body.insert(
17894                "sig".to_string(),
17895                item.get("signature_base64url").unwrap().clone(),
17896            );
17897            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17898            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
17899            assert_eq!(
17900                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
17901                item.get("commit_hash").and_then(Value::as_str).unwrap()
17902            );
17903            assert_eq!(
17904                format!("{:x}", Sha256::digest(&signed)),
17905                item.get("feed_hash").and_then(Value::as_str).unwrap()
17906            );
17907        }
17908
17909        for item in vector.get("invalid").unwrap().as_array().unwrap() {
17910            let mut body = base.clone();
17911            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
17912                for field in remove {
17913                    body.remove(field.as_str().unwrap());
17914                }
17915            }
17916            if let Some(set) = item.get("set").and_then(Value::as_object) {
17917                for (field, value) in set {
17918                    body.insert(field.clone(), value.clone());
17919                }
17920            }
17921            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
17922            body.insert(
17923                "sig".to_string(),
17924                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17925            );
17926            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
17927            assert!(
17928                verified_v2_commit_object(&signed, &identity).is_err(),
17929                "accepted invalid shared vector {}",
17930                item.get("reason").and_then(Value::as_str).unwrap()
17931            );
17932        }
17933    }
17934
17935    #[test]
17936    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
17937        let vector: Value = serde_json::from_str(include_str!(
17938            "../tests/vectors/linkmd-v2-changeset-withheld.json"
17939        ))
17940        .unwrap();
17941        assert_eq!(
17942            vector.get("profile").and_then(Value::as_str),
17943            Some("link.md-v2-changeset-withheld")
17944        );
17945        let canonical =
17946            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
17947        let expected = STANDARD
17948            .decode(
17949                vector
17950                    .get("canonical_base64")
17951                    .and_then(Value::as_str)
17952                    .unwrap(),
17953            )
17954            .unwrap();
17955        assert_eq!(canonical, expected);
17956        assert_eq!(
17957            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
17958            vector.get("domain_hash").and_then(Value::as_str).unwrap()
17959        );
17960    }
17961
17962    #[test]
17963    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
17964        let remote = signed_remote_fixture();
17965        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
17966        let legacy_item = legacy.entries.first().unwrap();
17967        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
17968        let body = json!({
17969            "actor_ref": "a".repeat(64),
17970            "asset_root": Value::Null,
17971            "brain": remote.key.multikey,
17972            "changes_sha256": "b".repeat(64),
17973            "control_revision": "c".repeat(64),
17974            "materializer": "dbmd-projection-v1",
17975            "op": "changeset",
17976            "parent_asset_root": Value::Null,
17977            "parent_commit": Value::Null,
17978            "parent_root": Value::Null,
17979            "prev_entry_hash": Value::Null,
17980            "public_key": remote.key.public_key_spki,
17981            "seq": 1,
17982            "signer_epoch": 1,
17983            "state_root": "d".repeat(64),
17984            "ts": "2026-08-19T12:00:00.000Z",
17985            "v": 2,
17986            "v1_bridge": {
17987                "feed_hash": legacy_item.hash,
17988                "head_seq": legacy_item.entry.seq,
17989                "pack_sha256": legacy_item.entry.pack_sha256,
17990            },
17991        });
17992        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
17993        let mut signed = body.as_object().unwrap().clone();
17994        signed.insert(
17995            "sig".to_string(),
17996            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
17997        );
17998        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
17999        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
18000        let feed_hash = content_sha256(&raw);
18001        let pointer = V2PointerBody {
18002            v: 2,
18003            brain: TEST_BRAIN_ID.to_string(),
18004            seq: 1,
18005            commit_hash: commit_hash.clone(),
18006            feed_hash: feed_hash.clone(),
18007            content_root: Some("d".repeat(64)),
18008            asset_root: None,
18009            materializer: "dbmd-projection-v1".to_string(),
18010            signer_epoch: 1,
18011            control_revision: "c".repeat(64),
18012            backup_preparation: "e".repeat(64),
18013            prior_pointer_hash: None,
18014            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
18015        };
18016        let v2_page = json!({
18017            "v": 2,
18018            "head_seq": 1,
18019            "head_commit_hash": commit_hash,
18020            "head_feed_hash": feed_hash,
18021            "entries": [{
18022                "seq": 1,
18023                "commit_hash": pointer.commit_hash,
18024                "feed_hash": pointer.feed_hash,
18025                "bytes_base64": STANDARD.encode(&raw),
18026            }],
18027            "next_after": 1,
18028            "complete": true,
18029        })
18030        .to_string();
18031        let identity = V2HeadIdentity {
18032            custody: "self".to_string(),
18033            fingerprint: remote.identity.fingerprint.clone(),
18034            public_key_spki: remote.identity.public_key_spki.clone(),
18035            previous: Vec::new(),
18036            rotations: Vec::new(),
18037        };
18038        let checkpoint = TrustState {
18039            v: 2,
18040            origin: "unused".to_string(),
18041            requested: TEST_BRAIN_ID.to_string(),
18042            brain: TEST_BRAIN_ID.to_string(),
18043            home: None,
18044            anchor: remote.key.multikey.clone(),
18045            current: remote.key.multikey,
18046            head_seq: legacy_item.entry.seq,
18047            feed_hash: Some(legacy_item.hash.clone()),
18048            rotations: Vec::new(),
18049            hub_signer: None,
18050            protocol_profile: None,
18051        };
18052        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
18053        let state = tempfile::tempdir().unwrap();
18054        let cfg = test_hub_config(hub, state.path().to_path_buf());
18055        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
18056        server.join().unwrap();
18057
18058        let mut wrong = checkpoint;
18059        wrong.feed_hash = Some("0".repeat(64));
18060        let (hub, server) = scripted_json_hub(vec![(
18061            200,
18062            json!({
18063                "v": 2,
18064                "head_seq": 1,
18065                "head_commit_hash": pointer.commit_hash,
18066                "head_feed_hash": pointer.feed_hash,
18067                "entries": [{
18068                    "seq": 1,
18069                    "commit_hash": pointer.commit_hash,
18070                    "feed_hash": pointer.feed_hash,
18071                    "bytes_base64": STANDARD.encode(&raw),
18072                }],
18073                "next_after": 1,
18074                "complete": true,
18075            })
18076            .to_string(),
18077        )]);
18078        let state = tempfile::tempdir().unwrap();
18079        let cfg = test_hub_config(hub, state.path().to_path_buf());
18080        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
18081        server.join().unwrap();
18082    }
18083
18084    #[test]
18085    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
18086        let rng = ring::rand::SystemRandom::new();
18087        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18088        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
18089        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18090        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
18091        let (old_spki, old_multikey) = public_identity_for(&old);
18092        let (new_spki, new_multikey) = public_identity_for(&new);
18093        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
18094            v: 1,
18095            op: "rotate",
18096            brain: &old_multikey,
18097            public_key: &old_spki,
18098            new_brain: &new_multikey,
18099            new_public_key: &new_spki,
18100            prior_head_seq: 1,
18101            prior_feed_hash: Some(&"9".repeat(64)),
18102            ts: "2026-08-19T12:01:00.000Z".to_string(),
18103        })
18104        .unwrap();
18105        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
18106        let rotation = format!(
18107            "{},\"sig\":\"{}\"}}",
18108            &rotation_unsigned[..rotation_unsigned.len() - 1],
18109            rotation_sig
18110        );
18111        let identity = V2HeadIdentity {
18112            custody: "self".to_string(),
18113            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
18114            public_key_spki: new_spki.clone(),
18115            previous: vec![V2PreviousIdentity {
18116                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
18117                public_key_spki: old_spki.clone(),
18118            }],
18119            rotations: vec![rotation],
18120        };
18121        let commit = |seq: u64,
18122                      epoch: u64,
18123                      multikey: &str,
18124                      spki: &str,
18125                      pair: &ring::signature::Ed25519KeyPair| {
18126            let value = json!({
18127                "actor_ref": "a".repeat(64),
18128                "asset_root": Value::Null,
18129                "brain": multikey,
18130                "changes_sha256": "b".repeat(64),
18131                "control_revision": "c".repeat(64),
18132                "materializer": "dbmd-projection-v1",
18133                "op": "changeset",
18134                "parent_asset_root": Value::Null,
18135                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
18136                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
18137                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
18138                "public_key": spki,
18139                "seq": seq,
18140                "signer_epoch": epoch,
18141                "state_root": "1".repeat(64),
18142                "ts": "2026-08-19T12:00:00.000Z",
18143                "v": 2,
18144                "v1_bridge": Value::Null,
18145            });
18146            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
18147            let mut object = value.as_object().unwrap().clone();
18148            object.insert(
18149                "sig".to_string(),
18150                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
18151            );
18152            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
18153        };
18154
18155        assert!(verified_v2_commit_object(
18156            &commit(1, 1, &old_multikey, &old_spki, &old),
18157            &identity,
18158        )
18159        .is_ok());
18160        assert!(verified_v2_commit_object(
18161            &commit(2, 2, &new_multikey, &new_spki, &new),
18162            &identity,
18163        )
18164        .is_ok());
18165        assert!(verified_v2_commit_object(
18166            &commit(2, 1, &old_multikey, &old_spki, &old),
18167            &identity,
18168        )
18169        .is_err());
18170        assert!(verified_v2_commit_object(
18171            &commit(1, 2, &new_multikey, &new_spki, &new),
18172            &identity,
18173        )
18174        .is_err());
18175    }
18176
18177    #[test]
18178    fn a_self_custody_entry_verifies_like_any_hub_entry() {
18179        let rng = ring::rand::SystemRandom::new();
18180        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18181        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18182        let (spki, multikey) = public_identity_for(&pair);
18183        let key = AgentSigningKey {
18184            pkcs8: pkcs8.as_ref().to_vec(),
18185            multikey: multikey.clone(),
18186            public_key_spki: spki.clone(),
18187        };
18188        let files = vec![WireFeedFile {
18189            path: "DB.md".to_string(),
18190            sha256: "a".repeat(64),
18191            bytes: 3,
18192        }];
18193        let raw = self_custody_entry(
18194            &key,
18195            1,
18196            "2026-07-23T12:00:00.000Z".to_string(),
18197            &"c".repeat(64),
18198            &files,
18199            None,
18200        )
18201        .unwrap();
18202        // The exact client serialization parses as a feed entry and passes the
18203        // SAME verifier every subscribe read runs — the self-custody path
18204        // produces first-class wire-profile-v1 entries.
18205        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
18206        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
18207        let item = FeedItem { hash, entry };
18208        let identity = FeedIdentity {
18209            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
18210            public_key_spki: spki,
18211            previous: Vec::new(),
18212            rotations: Vec::new(),
18213        };
18214        assert!(verify_feed_item(&item, &identity).is_ok());
18215    }
18216
18217    #[test]
18218    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
18219        let rng = ring::rand::SystemRandom::new();
18220        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18221        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
18222        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18223        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
18224        let (old_spki, old_multikey) = public_identity_for(&old);
18225        let (new_spki, new_multikey) = public_identity_for(&new);
18226        let unsigned = serde_json::to_string(&UnsignedRotation {
18227            v: 1,
18228            op: "rotate",
18229            brain: &old_multikey,
18230            public_key: &old_spki,
18231            new_brain: &new_multikey,
18232            new_public_key: &new_spki,
18233            prior_head_seq: 1,
18234            prior_feed_hash: Some(&"a".repeat(64)),
18235            ts: "2026-07-30T12:00:00.000Z".to_string(),
18236        })
18237        .unwrap();
18238        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
18239        let rotation = format!(
18240            "{},\"sig\":\"{}\"}}",
18241            &unsigned[..unsigned.len() - 1],
18242            signature
18243        );
18244        let identity = FeedIdentity {
18245            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
18246            public_key_spki: new_spki,
18247            previous: vec![PreviousIdentity {
18248                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
18249                public_key_spki: old_spki,
18250            }],
18251            rotations: vec![rotation],
18252        };
18253        let pin = TrustState {
18254            v: 2,
18255            origin: "https://hub.example".to_string(),
18256            requested: "brain".to_string(),
18257            brain: "brain".to_string(),
18258            home: None,
18259            anchor: old_multikey.clone(),
18260            current: old_multikey.clone(),
18261            head_seq: 1,
18262            feed_hash: Some("a".repeat(64)),
18263            rotations: Vec::new(),
18264            hub_signer: None,
18265            protocol_profile: None,
18266        };
18267        assert_eq!(
18268            verify_identity_chain(&identity, Some(&pin)).unwrap(),
18269            old_multikey
18270        );
18271        let mut accepted = pin.clone();
18272        accepted.current = new_multikey.clone();
18273        accepted.rotations = identity.rotations.clone();
18274        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
18275            v: 1,
18276            op: "rotate",
18277            brain: &old_multikey,
18278            public_key: &identity.previous[0].public_key_spki,
18279            new_brain: &new_multikey,
18280            new_public_key: &identity.public_key_spki,
18281            prior_head_seq: 1,
18282            prior_feed_hash: Some(&"a".repeat(64)),
18283            ts: "2026-07-30T12:00:01.000Z".to_string(),
18284        })
18285        .unwrap();
18286        let alternate_signature =
18287            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
18288        let mut rewritten = identity.clone();
18289        rewritten.rotations[0] = format!(
18290            "{},\"sig\":\"{}\"}}",
18291            &alternate_unsigned[..alternate_unsigned.len() - 1],
18292            alternate_signature
18293        );
18294        assert!(
18295            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
18296            "an alternate valid statement must not rewrite accepted history"
18297        );
18298
18299        let mut stale_entry = FeedEntry {
18300            v: 1,
18301            seq: 2,
18302            ts: "2026-07-30T12:01:00.000Z".to_string(),
18303            brain: pin.current.clone(),
18304            public_key: identity.previous[0].public_key_spki.clone(),
18305            kind: "push".to_string(),
18306            op: "snapshot".to_string(),
18307            pack_sha256: "b".repeat(64),
18308            files: Vec::new(),
18309            removed: Vec::new(),
18310            prev_entry_hash: pin.feed_hash.clone(),
18311            sig: String::new(),
18312        };
18313        let stale_unsigned = UnsignedFeedEntry {
18314            v: stale_entry.v,
18315            seq: stale_entry.seq,
18316            ts: &stale_entry.ts,
18317            brain: &stale_entry.brain,
18318            public_key: &stale_entry.public_key,
18319            kind: &stale_entry.kind,
18320            op: &stale_entry.op,
18321            pack_sha256: &stale_entry.pack_sha256,
18322            files: &stale_entry.files,
18323            removed: &stale_entry.removed,
18324            prev_entry_hash: &stale_entry.prev_entry_hash,
18325        };
18326        stale_entry.sig = URL_SAFE_NO_PAD.encode(
18327            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
18328                .as_ref(),
18329        );
18330        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
18331        stale_exact.push(b'\n');
18332        let stale_item = FeedItem {
18333            hash: content_sha256(&stale_exact),
18334            entry: stale_entry,
18335        };
18336        assert!(
18337            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
18338                .is_err(),
18339            "a key retired before the checkpoint must never append after it"
18340        );
18341        assert!(
18342            verify_feed_item(&stale_item, &identity).is_err(),
18343            "an old key must never append after its signed rotation boundary"
18344        );
18345
18346        let mut missing = identity.clone();
18347        missing.rotations.clear();
18348        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
18349
18350        let mut tampered = identity;
18351        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
18352        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
18353    }
18354
18355    #[cfg(unix)]
18356    #[test]
18357    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
18358        use std::os::unix::fs::symlink;
18359
18360        let dir = tempfile::tempdir().unwrap();
18361        let target = dir.path().join("valuable.txt");
18362        let planted = dir.path().join("agent.key");
18363        std::fs::write(&target, "do not overwrite").unwrap();
18364        symlink(&target, &planted).unwrap();
18365
18366        assert!(matches!(
18367            generate_agent_key(&planted),
18368            Err(LinkError::BadAgentKey { .. })
18369        ));
18370        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
18371    }
18372
18373    #[cfg(unix)]
18374    #[test]
18375    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
18376        use std::os::unix::fs::symlink;
18377
18378        let root = tempfile::tempdir().unwrap();
18379        let outside = tempfile::tempdir().unwrap();
18380        symlink(outside.path(), root.path().join("redirect")).unwrap();
18381
18382        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
18383        assert!(!outside.path().join("agent.key").exists());
18384    }
18385
18386    // ── Address parsing ─────────────────────────────────────────────────────
18387
18388    #[test]
18389    fn address_bare_brain_with_and_without_sigil() {
18390        for raw in ["@acme-ops", "acme-ops"] {
18391            let a = Address::parse(raw).expect(raw);
18392            assert_eq!(a.brain, "acme-ops");
18393            assert_eq!(a.target, None);
18394        }
18395    }
18396
18397    #[test]
18398    fn address_ulid_target_parses_as_id() {
18399        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
18400        assert_eq!(a.brain, "acme");
18401        assert_eq!(
18402            a.target,
18403            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
18404        );
18405    }
18406
18407    #[test]
18408    fn address_md_path_target_parses_as_path() {
18409        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
18410        assert_eq!(
18411            a.target,
18412            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
18413        );
18414    }
18415
18416    #[test]
18417    fn address_rejects_malformed_forms() {
18418        for raw in [
18419            "",
18420            "@",
18421            "@/x",
18422            "@acme/",
18423            "@acme/../etc/passwd",
18424            "@acme/records/.hidden.md",
18425            "@ACME",             // uppercase is not a hub ref shape
18426            "@acme/notes/x.txt", // target is neither ULID nor .md path
18427            "@a b",              // whitespace
18428        ] {
18429            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
18430        }
18431    }
18432
18433    // ── Path safety ─────────────────────────────────────────────────────────
18434
18435    #[test]
18436    fn safe_paths_accept_store_shapes_and_reject_escapes() {
18437        for ok in [
18438            "DB.md",
18439            "assets.jsonl",
18440            "records/clients/lumio.md",
18441            "sources/emails/2026/07/x.md",
18442        ] {
18443            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
18444        }
18445        for bad in [
18446            "",
18447            "/etc/passwd",
18448            "../up.md",
18449            "records/../../up.md",
18450            "records//x.md",
18451            ".dbmd/config",
18452            "records/.hidden/x.md",
18453            "records/a b.md",
18454            "records\\win.md",
18455        ] {
18456            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
18457        }
18458    }
18459
18460    #[cfg(unix)]
18461    #[test]
18462    fn opened_destination_capability_survives_an_ancestor_path_swap() {
18463        use std::os::unix::fs::symlink;
18464
18465        let work = tempfile::tempdir().unwrap();
18466        let outside = tempfile::tempdir().unwrap();
18467        let original = work.path().join("destination");
18468        let moved = work.path().join("destination-moved");
18469        let directory = open_or_create_dir_nofollow(&original).unwrap();
18470
18471        std::fs::rename(&original, &moved).unwrap();
18472        symlink(outside.path(), &original).unwrap();
18473        write_pull_entries_beneath_dir(
18474            &directory,
18475            &[("records/note.md".to_string(), b"held inode".to_vec())],
18476        )
18477        .unwrap();
18478
18479        assert_eq!(
18480            std::fs::read(moved.join("records/note.md")).unwrap(),
18481            b"held inode"
18482        );
18483        assert!(!outside.path().join("records/note.md").exists());
18484    }
18485
18486    // ── Config resolution (flag + file precedence; env is covered by the CLI
18487    //    integration tests, where a child process isolates it) ───────────────
18488
18489    #[test]
18490    fn hub_config_flag_beats_file_and_requires_some_source() {
18491        let dir = tempfile::tempdir().unwrap();
18492        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
18493        std::fs::write(
18494            dir.path().join(CONFIG_REL_PATH),
18495            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
18496        )
18497        .unwrap();
18498
18499        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
18500        assert_eq!(from_flag.hub, "https://flag.example.com");
18501
18502        let from_file = hub_config(None, dir.path()).unwrap();
18503        assert_eq!(from_file.hub, "https://file.example.com");
18504
18505        let none = hub_config(None, tempfile::tempdir().unwrap().path());
18506        assert!(matches!(none, Err(LinkError::NoHub)));
18507    }
18508
18509    #[test]
18510    fn https_guard_allows_loopback_only_for_plain_http() {
18511        assert!(assert_safe_hub("https://hub.example.com").is_ok());
18512        assert!(assert_safe_hub("http://localhost:3000").is_ok());
18513        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
18514        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
18515        assert!(matches!(
18516            assert_safe_hub("http://hub.example.com"),
18517            Err(LinkError::UnsafeHub { .. })
18518        ));
18519        assert!(matches!(
18520            assert_safe_hub("hub.example.com"),
18521            Err(LinkError::UnsafeHub { .. })
18522        ));
18523        assert!(matches!(
18524            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
18525            Err(LinkError::UnsafeHub { .. })
18526        ));
18527        assert!(matches!(
18528            assert_safe_hub("https://hub.example.com@attacker.example"),
18529            Err(LinkError::UnsafeHub { .. })
18530        ));
18531        assert!(matches!(
18532            assert_safe_hub("https://hub.example.com/base"),
18533            Err(LinkError::UnsafeHub { .. })
18534        ));
18535    }
18536
18537    #[test]
18538    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
18539        for blocked in [
18540            "127.0.0.1",
18541            "10.0.0.1",
18542            "100.64.0.1",
18543            "169.254.169.254",
18544            "172.16.0.1",
18545            "192.168.0.1",
18546            "192.88.99.1",
18547            "198.18.0.1",
18548            "203.0.113.1",
18549            "::1",
18550            "fe80::1",
18551            "fd00::1",
18552            "2001:db8::1",
18553            "2001:1::1",
18554            "2002:7f00:1::",
18555            "3fff::1",
18556        ] {
18557            assert!(
18558                !is_public_registry_ip(blocked.parse().unwrap()),
18559                "must block {blocked}"
18560            );
18561        }
18562        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
18563        assert!(is_public_registry_ip(
18564            "2606:4700:4700::1111".parse().unwrap()
18565        ));
18566        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
18567    }
18568
18569    #[test]
18570    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
18571        use ureq::Resolver as _;
18572
18573        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
18574        let resolver = PinnedRegistryResolver {
18575            netloc: "home.example:443".to_string(),
18576            addresses: vec![pinned],
18577        };
18578        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
18579        assert!(resolver.resolve("127.0.0.1:443").is_err());
18580        assert_eq!(
18581            resolver.resolve("home.example:443").unwrap(),
18582            vec![pinned],
18583            "subsequent connects reuse the validated answer instead of DNS"
18584        );
18585    }
18586
18587    #[test]
18588    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
18589        let cfg = HubConfig {
18590            hub: "https://hub.example".to_string(),
18591            key: None,
18592            agent_key: None,
18593            brain_key: None,
18594            state_dir: tempfile::tempdir().unwrap().keep(),
18595            store_selected: false,
18596        };
18597        assert!(
18598            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
18599            "a production hub must not turn its presigned URL into an SSRF primitive"
18600        );
18601
18602        let store_selected = HubConfig {
18603            hub: "https://127.0.0.1".to_string(),
18604            store_selected: true,
18605            ..cfg
18606        };
18607        assert!(
18608            hub_agent(&store_selected).is_err(),
18609            "bytes in a cloned store must not select a private-network hub"
18610        );
18611    }
18612
18613    #[test]
18614    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
18615        assert_eq!(
18616            one_past_bounded_limit(MAX_PACK_BYTES),
18617            Some(MAX_PACK_BYTES + 1),
18618            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
18619        );
18620        assert_eq!(
18621            presigned_download_read_limit(),
18622            MAX_PACK_BYTES + 1,
18623            "the presigned reader is capped by the client constant, not a hub response"
18624        );
18625        assert_eq!(
18626            one_past_bounded_limit(u64::MAX),
18627            None,
18628            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
18629        );
18630    }
18631
18632    #[test]
18633    fn https_guard_matches_the_scheme_case_insensitively() {
18634        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
18635        // hub is still HTTPS, never a misleading non-HTTPS refusal.
18636        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
18637        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
18638        // And an uppercase plain-HTTP hub is still refused outside loopback.
18639        assert!(matches!(
18640            assert_safe_hub("HTTP://hub.example.com"),
18641            Err(LinkError::UnsafeHub { .. })
18642        ));
18643    }
18644
18645    #[test]
18646    fn clean_key_refuses_paste_artifacts_without_echoing() {
18647        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
18648        for bad in ["vc account", "vc\naccount", "ключ", ""] {
18649            let err = clean_key(bad).unwrap_err();
18650            assert!(matches!(err, LinkError::BadKey));
18651            assert!(
18652                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
18653                "error must not echo the key"
18654            );
18655        }
18656    }
18657
18658    // ── Verb entry gates: refs must never reshape the request path ──────────
18659
18660    /// A config whose hub passes the loopback guard but is never listened on:
18661    /// every refusal below must come from the entry gate BEFORE a request
18662    /// exists — a dial on this dead port would surface `Transport` instead.
18663    fn dead_hub() -> HubConfig {
18664        HubConfig {
18665            hub: "http://127.0.0.1:9".to_string(),
18666            key: Some("k".to_string()),
18667            agent_key: None,
18668            brain_key: None,
18669            state_dir: PathBuf::from("."),
18670            store_selected: false,
18671        }
18672    }
18673
18674    #[test]
18675    fn request_retries_a_connection_failure_before_sending() {
18676        use std::io::{Read as _, Write as _};
18677        use std::net::TcpListener;
18678        use std::thread;
18679        use std::time::Duration;
18680
18681        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
18682        let address = probe.local_addr().unwrap();
18683        drop(probe);
18684        let server = thread::spawn(move || {
18685            thread::sleep(Duration::from_millis(40));
18686            let listener = TcpListener::bind(address).unwrap();
18687            let (mut stream, _) = listener.accept().unwrap();
18688            let mut request_bytes = [0_u8; 1024];
18689            let _ = stream.read(&mut request_bytes).unwrap();
18690            stream
18691                .write_all(
18692                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18693                )
18694                .unwrap();
18695        });
18696        let cfg = HubConfig {
18697            hub: format!("http://{address}"),
18698            key: None,
18699            agent_key: None,
18700            brain_key: None,
18701            state_dir: tempfile::tempdir().unwrap().keep(),
18702            store_selected: false,
18703        };
18704
18705        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
18706        assert_eq!(response.status, 200);
18707        assert_eq!(response.body, Some(json!({ "ok": true })));
18708        server.join().unwrap();
18709    }
18710
18711    #[test]
18712    fn a_commit_goes_back_for_a_receipt_it_lost() {
18713        use std::io::Write as _;
18714        use std::net::TcpListener;
18715        use std::thread;
18716
18717        // The mutation coordinate is derived from the observed base and the
18718        // exact operation set so a repeat is the SAME effect. A commit that
18719        // lands durably and then loses its connection must therefore be asked
18720        // after, not reported as a failure — which is what happened to a
18721        // 32,338-file store that had already committed.
18722        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18723        let address = listener.local_addr().unwrap();
18724        let server = thread::spawn(move || {
18725            // First attempt: promise a body, hang up mid-way.
18726            let (mut first, _) = listener.accept().unwrap();
18727            drain_test_http_request(&mut first);
18728            first
18729                .write_all(
18730                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
18731                )
18732                .unwrap();
18733            drop(first);
18734            // Second attempt: the receipt for the commit that already landed.
18735            let (mut second, _) = listener.accept().unwrap();
18736            drain_test_http_request(&mut second);
18737            second
18738                .write_all(
18739                    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\"}",
18740                )
18741                .unwrap();
18742        });
18743        let cfg = HubConfig {
18744            hub: format!("http://{address}"),
18745            key: Some("k".to_string()),
18746            agent_key: None,
18747            brain_key: None,
18748            state_dir: tempfile::tempdir().unwrap().keep(),
18749            store_selected: false,
18750        };
18751
18752        let response = request_patient(
18753            &cfg,
18754            "POST",
18755            "/api/hub/brains/b/v2/commits",
18756            Some(&json!({ "mutation_id": "dbmd-1" })),
18757            Auth::Required,
18758        )
18759        .expect("the receipt is collected on the second ask");
18760        assert_eq!(response.status, 200);
18761        assert_eq!(
18762            response
18763                .body
18764                .as_ref()
18765                .and_then(|value| value.get("outcome"))
18766                .and_then(Value::as_str),
18767            Some("converged"),
18768            "an already-applied mutation answers with its receipt"
18769        );
18770        server.join().unwrap();
18771    }
18772
18773    #[test]
18774    fn a_patient_commit_waits_for_its_typed_post_acceptance_receipt_lag() {
18775        use std::io::Write as _;
18776        use std::net::TcpListener;
18777        use std::thread;
18778
18779        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18780        let address = listener.local_addr().unwrap();
18781        let server = thread::spawn(move || {
18782            let lag = br#"{"error":"validation recovery state is not at the source head","details":{"code":"validation_index_catching_up"}}"#;
18783            let receipt = br#"{"v":2,"outcome":"converged"}"#;
18784            for (status, body) in [
18785                ("422 Unprocessable Entity", lag.as_slice()),
18786                ("200 OK", receipt.as_slice()),
18787            ] {
18788                let (mut stream, _) = listener.accept().unwrap();
18789                drain_test_http_request(&mut stream);
18790                write!(
18791                    stream,
18792                    "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
18793                    body.len()
18794                )
18795                .unwrap();
18796                stream.write_all(body).unwrap();
18797            }
18798        });
18799        let cfg = HubConfig {
18800            hub: format!("http://{address}"),
18801            key: Some("k".to_string()),
18802            agent_key: None,
18803            brain_key: None,
18804            state_dir: tempfile::tempdir().unwrap().keep(),
18805            store_selected: false,
18806        };
18807
18808        let response = request_patient(
18809            &cfg,
18810            "POST",
18811            "/api/hub/brains/b/v2/commits",
18812            Some(&json!({ "mutation_id": "dbmd-1" })),
18813            Auth::Required,
18814        )
18815        .expect("typed projection lag is retried until the exact receipt is available");
18816        assert_eq!(response.status, 200);
18817        assert_eq!(
18818            response
18819                .body
18820                .as_ref()
18821                .and_then(|value| value.get("outcome"))
18822                .and_then(Value::as_str),
18823            Some("converged")
18824        );
18825        server.join().unwrap();
18826    }
18827
18828    #[test]
18829    fn a_mutation_body_that_dies_mid_stream_is_a_transport_failure() {
18830        use std::io::{Read as _, Write as _};
18831        use std::net::TcpListener;
18832        use std::thread;
18833
18834        // A connection that fails while the body is arriving is the same class
18835        // of problem as one that fails before it arrives at all. Typed as a
18836        // bare io error it matched no retry path and named no peer, and one
18837        // stalled body ended a whole migration reporting only "timed out
18838        // reading response".
18839        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18840        let address = listener.local_addr().unwrap();
18841        let server = thread::spawn(move || {
18842            let (mut stream, _) = listener.accept().unwrap();
18843            let mut request_bytes = [0_u8; 1024];
18844            let _ = stream.read(&mut request_bytes).unwrap();
18845            // Promise a hundred bytes, send ten, hang up.
18846            stream
18847                .write_all(
18848                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18849                )
18850                .unwrap();
18851        });
18852        let cfg = HubConfig {
18853            hub: format!("http://{address}"),
18854            key: None,
18855            agent_key: None,
18856            brain_key: None,
18857            state_dir: tempfile::tempdir().unwrap().keep(),
18858            store_selected: false,
18859        };
18860
18861        let error = request(&cfg, "POST", "/truncated", None, Auth::None)
18862            .expect_err("a truncated body must not read as success");
18863        match error {
18864            LinkError::Transport { hub, .. } => {
18865                assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
18866            }
18867            other => panic!("expected a transport failure, got {other:?}"),
18868        }
18869        server.join().unwrap();
18870    }
18871
18872    #[test]
18873    fn a_safe_get_retries_when_its_body_dies_mid_stream() {
18874        use std::io::{Read as _, Write as _};
18875        use std::net::{TcpListener, TcpStream};
18876        use std::thread;
18877
18878        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18879        let address = listener.local_addr().unwrap();
18880        let server = thread::spawn(move || {
18881            let read_request = |stream: &mut TcpStream| {
18882                let mut request = Vec::new();
18883                let mut bytes = [0_u8; 1024];
18884                loop {
18885                    let read = stream.read(&mut bytes).unwrap();
18886                    if read == 0 {
18887                        break;
18888                    }
18889                    request.extend_from_slice(&bytes[..read]);
18890                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18891                    else {
18892                        continue;
18893                    };
18894                    let headers = String::from_utf8_lossy(&request[..header_end]);
18895                    let content_length = headers
18896                        .lines()
18897                        .find_map(|line| {
18898                            let (name, value) = line.split_once(':')?;
18899                            name.eq_ignore_ascii_case("content-length")
18900                                .then(|| value.trim().parse::<usize>().ok())
18901                                .flatten()
18902                        })
18903                        .unwrap_or(0);
18904                    if request.len() >= header_end + 4 + content_length {
18905                        break;
18906                    }
18907                }
18908            };
18909            let (mut first, _) = listener.accept().unwrap();
18910            read_request(&mut first);
18911            first
18912                .write_all(
18913                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18914                )
18915                .unwrap();
18916            drop(first);
18917
18918            let (mut second, _) = listener.accept().unwrap();
18919            read_request(&mut second);
18920            second
18921                .write_all(
18922                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18923                )
18924                .unwrap();
18925        });
18926        let cfg = HubConfig {
18927            hub: format!("http://{address}"),
18928            key: None,
18929            agent_key: None,
18930            brain_key: None,
18931            state_dir: tempfile::tempdir().unwrap().keep(),
18932            store_selected: false,
18933        };
18934
18935        let response = request(&cfg, "GET", "/retry-body", None, Auth::None)
18936            .expect("a safe read retries the interrupted body");
18937        assert_eq!(response.status, 200);
18938        assert_eq!(response.body, Some(json!({ "ok": true })));
18939        server.join().unwrap();
18940    }
18941
18942    #[test]
18943    fn an_explicit_read_only_post_retries_when_its_body_dies_mid_stream() {
18944        use std::io::{Read as _, Write as _};
18945        use std::net::{TcpListener, TcpStream};
18946        use std::thread;
18947
18948        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
18949        let address = listener.local_addr().unwrap();
18950        let server = thread::spawn(move || {
18951            let read_request = |stream: &mut TcpStream| {
18952                let mut request = Vec::new();
18953                let mut bytes = [0_u8; 1024];
18954                loop {
18955                    let read = stream.read(&mut bytes).unwrap();
18956                    if read == 0 {
18957                        break;
18958                    }
18959                    request.extend_from_slice(&bytes[..read]);
18960                    let Some(header_end) = request.windows(4).position(|part| part == b"\r\n\r\n")
18961                    else {
18962                        continue;
18963                    };
18964                    let headers = String::from_utf8_lossy(&request[..header_end]);
18965                    let content_length = headers
18966                        .lines()
18967                        .find_map(|line| {
18968                            let (name, value) = line.split_once(':')?;
18969                            name.eq_ignore_ascii_case("content-length")
18970                                .then(|| value.trim().parse::<usize>().ok())
18971                                .flatten()
18972                        })
18973                        .unwrap_or(0);
18974                    if request.len() >= header_end + 4 + content_length {
18975                        break;
18976                    }
18977                }
18978            };
18979            let (mut first, _) = listener.accept().unwrap();
18980            read_request(&mut first);
18981            first
18982                .write_all(
18983                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
18984                )
18985                .unwrap();
18986            drop(first);
18987
18988            let (mut second, _) = listener.accept().unwrap();
18989            read_request(&mut second);
18990            second
18991                .write_all(
18992                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
18993                )
18994                .unwrap();
18995        });
18996        let cfg = HubConfig {
18997            hub: format!("http://{address}"),
18998            key: None,
18999            agent_key: None,
19000            brain_key: None,
19001            state_dir: tempfile::tempdir().unwrap().keep(),
19002            store_selected: false,
19003        };
19004
19005        let response = request_raw_retryable_read(
19006            &cfg,
19007            "POST",
19008            "/v2/stream",
19009            Some(&json!({ "files": ["proof"] })),
19010            Auth::None,
19011            1_024,
19012        )
19013        .expect("an explicitly safe POST retries the interrupted body");
19014        assert_eq!(response.status, 200);
19015        assert_eq!(
19016            serde_json::from_slice::<Value>(&response.body).unwrap(),
19017            json!({ "ok": true })
19018        );
19019        server.join().unwrap();
19020    }
19021
19022    #[test]
19023    fn object_store_transport_errors_never_render_presigned_urls() {
19024        use std::net::TcpListener;
19025
19026        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19027        let address = listener.local_addr().unwrap();
19028        drop(listener);
19029        let signature = "do-not-render-this-presigned-signature";
19030        let raw =
19031            format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
19032        let error = ureq::get(&raw)
19033            .timeout(std::time::Duration::from_millis(250))
19034            .call()
19035            .expect_err("the closed local port must fail");
19036        let ureq::Error::Transport(transport) = error else {
19037            panic!("expected a transport failure");
19038        };
19039
19040        let rendered = object_store_transport_error(transport).to_string();
19041        assert!(rendered.contains("the object store"));
19042        assert!(rendered.contains("network error"));
19043        assert!(!rendered.contains(&raw));
19044        assert!(!rendered.contains(signature));
19045        assert!(!rendered.contains("X-Amz-"));
19046    }
19047
19048    #[test]
19049    fn endpoint_cap_refuses_a_body_before_json_parsing() {
19050        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
19051        let cfg = HubConfig {
19052            hub,
19053            key: None,
19054            agent_key: None,
19055            brain_key: None,
19056            state_dir: tempfile::tempdir().unwrap().keep(),
19057            store_selected: false,
19058        };
19059
19060        assert!(matches!(
19061            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
19062            Err(LinkError::ResponseTooLarge { .. })
19063        ));
19064        server.join().unwrap();
19065    }
19066
19067    #[test]
19068    fn overall_deadline_stops_a_dribbled_response_body() {
19069        use std::io::{Read as _, Write as _};
19070        use std::net::TcpListener;
19071        use std::time::{Duration, Instant};
19072
19073        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19074        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
19075        let server = std::thread::spawn(move || {
19076            let (mut stream, _) = listener.accept().unwrap();
19077            let mut request = [0_u8; 1024];
19078            let _ = stream.read(&mut request);
19079            stream
19080                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
19081                .unwrap();
19082            for byte in [b'x'; 32] {
19083                if stream.write_all(&[byte]).is_err() {
19084                    break;
19085                }
19086                std::thread::sleep(Duration::from_millis(40));
19087            }
19088        });
19089        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
19090        let started = Instant::now();
19091        let response = http.get(&url).call().unwrap();
19092        let mut body = Vec::new();
19093        let error = response
19094            .into_reader()
19095            .read_to_end(&mut body)
19096            .expect_err("per-read progress must not reset the overall deadline");
19097        assert!(
19098            started.elapsed() < Duration::from_millis(700),
19099            "dribbled body exceeded the wall-clock budget: {error}"
19100        );
19101        server.join().unwrap();
19102    }
19103
19104    #[test]
19105    fn overall_deadline_stops_a_stalled_upload() {
19106        use std::net::TcpListener;
19107        use std::time::{Duration, Instant};
19108
19109        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19110        let url = format!("http://{}/upload", listener.local_addr().unwrap());
19111        let server = std::thread::spawn(move || {
19112            let (_stream, _) = listener.accept().unwrap();
19113            // Never consume the request body. Once the kernel send buffer fills,
19114            // the client must leave on its absolute write deadline.
19115            std::thread::sleep(Duration::from_millis(600));
19116        });
19117        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
19118        let body = vec![0x5a; 32 * 1024 * 1024];
19119        let started = Instant::now();
19120        let error = http
19121            .put(&url)
19122            .send_bytes(&body)
19123            .expect_err("stalled request-body writes must time out");
19124        assert!(
19125            started.elapsed() < Duration::from_millis(700),
19126            "stalled upload exceeded the wall-clock budget: {error}"
19127        );
19128        server.join().unwrap();
19129    }
19130
19131    #[test]
19132    fn presigned_source_retries_share_one_upload_deadline() {
19133        use std::net::TcpListener;
19134        use std::time::{Duration, Instant};
19135
19136        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
19137        let address = listener.local_addr().unwrap();
19138        let signature = "do-not-render-this-stalled-upload-signature";
19139        let url = format!(
19140            "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
19141        );
19142        let server = std::thread::spawn(move || {
19143            let (_stream, _) = listener.accept().unwrap();
19144            // Keep the socket open without consuming the request. The first
19145            // attempt must spend the complete shared budget; retries may not
19146            // each restart that budget.
19147            std::thread::sleep(Duration::from_millis(600));
19148        });
19149
19150        let directory = tempfile::tempdir().unwrap();
19151        std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
19152        std::fs::create_dir(directory.path().join("records")).unwrap();
19153        let relative = "records/stalled.bin";
19154        let bytes = vec![0x5a; 32 * 1024 * 1024];
19155        std::fs::write(directory.path().join(relative), &bytes).unwrap();
19156        let store = Store::open_strict(directory.path()).unwrap();
19157        let cfg = HubConfig {
19158            hub: format!("http://{address}"),
19159            key: None,
19160            agent_key: None,
19161            brain_key: None,
19162            state_dir: tempfile::tempdir().unwrap().keep(),
19163            store_selected: false,
19164        };
19165        let source = V2UploadSource {
19166            path: relative.to_string(),
19167            bytes: bytes.len() as u64,
19168        };
19169
19170        let started = Instant::now();
19171        let error = put_presigned_source_with_budget(
19172            &cfg,
19173            &url,
19174            &json!({ "content-length": source.bytes.to_string() }),
19175            &store,
19176            &source,
19177            None,
19178            Duration::from_millis(150),
19179        )
19180        .expect_err("a black-holed upload must leave at its shared deadline");
19181        assert!(
19182            started.elapsed() < Duration::from_millis(700),
19183            "presigned retries exceeded their shared budget: {error}"
19184        );
19185        let rendered = error.to_string();
19186        assert!(rendered.contains("the object store"));
19187        assert!(!rendered.contains(&url));
19188        assert!(!rendered.contains(signature));
19189        server.join().unwrap();
19190    }
19191
19192    #[test]
19193    fn verb_entry_gates_accept_the_hub_ref_shapes() {
19194        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
19195            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
19196            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
19197            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
19198        }
19199    }
19200
19201    #[test]
19202    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
19203        let cfg = dead_hub();
19204        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
19205            assert!(
19206                matches!(
19207                    sync_pull(&cfg, bad, None),
19208                    Err(LinkError::BadAddress { .. })
19209                ),
19210                "sync_pull must refuse {bad:?}"
19211            );
19212            assert!(
19213                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
19214                "sync_push must refuse {bad:?}"
19215            );
19216            assert!(
19217                matches!(
19218                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
19219                    Err(LinkError::BadAddress { .. })
19220                ),
19221                "grant_issue must refuse {bad:?}"
19222            );
19223            assert!(
19224                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
19225                "grant_list must refuse {bad:?}"
19226            );
19227            assert!(
19228                matches!(
19229                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
19230                    Err(LinkError::BadAddress { .. })
19231                ),
19232                "grant_revoke must refuse brain {bad:?}"
19233            );
19234            assert!(
19235                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
19236                "head must refuse {bad:?}"
19237            );
19238        }
19239    }
19240
19241    #[test]
19242    fn grant_revoke_refuses_url_reshaping_grant_ids() {
19243        let cfg = dead_hub();
19244        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
19245            assert!(
19246                matches!(
19247                    grant_revoke(&cfg, "acme", bad),
19248                    Err(LinkError::BadGrantId { .. })
19249                ),
19250                "grant_revoke must refuse grant id {bad:?}"
19251            );
19252        }
19253    }
19254
19255    #[test]
19256    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
19257        let cfg = dead_hub();
19258        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
19259            assert!(
19260                matches!(
19261                    propose(&cfg, bad, "intake", "hi"),
19262                    Err(LinkError::BadAddress { .. })
19263                ),
19264                "propose must refuse handle {bad:?}"
19265            );
19266        }
19267        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
19268        assert!(matches!(
19269            propose(&cfg, "acme-site", "intake", &oversize),
19270            Err(LinkError::ProposeTooLarge { .. })
19271        ));
19272        // A clean handle + in-cap body passes both gates: the failure is now
19273        // the (dead) wire, proving the gates refuse shape, not the verb.
19274        assert!(matches!(
19275            propose(&cfg, "acme-site", "intake", "hi"),
19276            Err(LinkError::Transport { .. })
19277        ));
19278    }
19279
19280    #[test]
19281    fn resolve_refuses_a_hand_built_unsafe_address() {
19282        let cfg = dead_hub();
19283        for brain in ["../up", "a/b", "a?x", "a#f"] {
19284            let addr = Address {
19285                brain: brain.to_string(),
19286                target: None,
19287            };
19288            assert!(
19289                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
19290                "resolve must refuse brain {brain:?}"
19291            );
19292        }
19293        for target in [
19294            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
19295            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
19296            AddressTarget::Path("../up.md".to_string()),
19297            AddressTarget::Path("records/x.md#frag".to_string()),
19298        ] {
19299            let addr = Address {
19300                brain: "acme".to_string(),
19301                target: Some(target.clone()),
19302            };
19303            assert!(
19304                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
19305                "resolve must refuse target {target:?}"
19306            );
19307        }
19308    }
19309
19310    #[test]
19311    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
19312        let mut local = std::collections::BTreeMap::new();
19313        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
19314        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
19315        let mut remote = std::collections::BTreeMap::new();
19316        remote.insert(
19317            "records/a.md".to_string(),
19318            V2BaselineFile {
19319                sha256: "c".repeat(64),
19320                bytes: 1,
19321                proof: None,
19322            },
19323        );
19324        remote.insert(
19325            "records/b.md".to_string(),
19326            V2BaselineFile {
19327                sha256: "b".repeat(64),
19328                bytes: 1,
19329                proof: None,
19330            },
19331        );
19332        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
19333    }
19334
19335    #[test]
19336    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
19337        let local = std::collections::BTreeMap::new();
19338        let mut remote = std::collections::BTreeMap::new();
19339        remote.insert(
19340            "private/local.md".to_string(),
19341            V2BaselineFile {
19342                sha256: "d".repeat(64),
19343                bytes: 1,
19344                proof: None,
19345            },
19346        );
19347        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
19348        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
19349    }
19350
19351    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
19352        V2VerifiedHead {
19353            requested: TEST_BRAIN_ID.to_string(),
19354            brain_id: TEST_BRAIN_ID.to_string(),
19355            view_kind: "scoped".to_string(),
19356            view_revision: revision.to_string(),
19357            control_revision: revision.to_string(),
19358            identity: V2HeadIdentity {
19359                custody: "hub".to_string(),
19360                fingerprint: "test".to_string(),
19361                public_key_spki: "test".to_string(),
19362                previous: Vec::new(),
19363                rotations: Vec::new(),
19364            },
19365            pointer: None,
19366            trust: TrustState {
19367                v: 2,
19368                origin: "https://hub.example".to_string(),
19369                requested: TEST_BRAIN_ID.to_string(),
19370                brain: TEST_BRAIN_ID.to_string(),
19371                home: None,
19372                anchor: "ed25519:test".to_string(),
19373                current: "ed25519:test".to_string(),
19374                head_seq: 0,
19375                feed_hash: None,
19376                rotations: Vec::new(),
19377                hub_signer: None,
19378                protocol_profile: Some("link-v2".to_string()),
19379            },
19380            alias: None,
19381        }
19382    }
19383
19384    #[test]
19385    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
19386        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
19387        assert!(accepted_as_v2(&trust));
19388
19389        trust.protocol_profile = None;
19390        trust.hub_signer = Some("ed25519:hub".to_string());
19391        assert!(accepted_as_v2(&trust));
19392
19393        trust.hub_signer = None;
19394        assert!(!accepted_as_v2(&trust));
19395    }
19396
19397    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
19398        V2SyncBaseline {
19399            v: 2,
19400            origin: "https://hub.example".to_string(),
19401            brain: TEST_BRAIN_ID.to_string(),
19402            checkout_id: Some("c".repeat(64)),
19403            head_seq: Some(0),
19404            commit_hash: None,
19405            content_root: None,
19406            asset_root: None,
19407            assets: std::collections::BTreeMap::new(),
19408            view_kind: Some("scoped".to_string()),
19409            view_revision: Some(revision.to_string()),
19410            control_revision: Some(revision.to_string()),
19411            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
19412            files: std::collections::BTreeMap::new(),
19413            scan_cache: std::collections::BTreeMap::new(),
19414            local_policy_digest: None,
19415            local_eligibility: std::collections::BTreeMap::new(),
19416            remote_copy_remains: std::collections::BTreeMap::new(),
19417        }
19418    }
19419
19420    #[test]
19421    fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
19422        let cfg = test_hub_config(
19423            "https://hub.example".to_string(),
19424            tempfile::tempdir().unwrap().keep(),
19425        );
19426        let mut baseline = scoped_test_baseline(&"a".repeat(64));
19427        baseline.assets.insert(
19428            "assets/archive.bin".to_string(),
19429            V2BaselineAsset {
19430                blob_sha256: "b".repeat(64),
19431                bytes: MAX_STORE_BYTES + 1,
19432                media_type: "application/octet-stream".to_string(),
19433                wrappers: vec!["records/archive.md".to_string()],
19434                required: true,
19435                disposition: "hosted".to_string(),
19436                leaf_hash: "c".repeat(64),
19437            },
19438        );
19439
19440        let accepted = serde_json::to_vec(&baseline).unwrap();
19441        assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
19442
19443        baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
19444        let refused = serde_json::to_vec(&baseline).unwrap();
19445        assert!(matches!(
19446            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
19447            Err(LinkError::InvalidFeed { .. })
19448        ));
19449    }
19450
19451    #[test]
19452    fn v2_local_view_keeps_markdown_assets_in_content_but_excludes_binary_assets() {
19453        let directory = tempfile::tempdir().unwrap();
19454        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19455        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
19456        std::fs::write(
19457            directory.path().join("DB.md"),
19458            b"---\nname: Dual plane test\n---\n",
19459        )
19460        .unwrap();
19461        let markdown = b"---\ntype: note\n---\nintegrity tracked\n";
19462        let binary = b"pdf bytes";
19463        std::fs::write(directory.path().join("records/notes/a.md"), markdown).unwrap();
19464        std::fs::write(directory.path().join("sources/files/a.pdf"), binary).unwrap();
19465        let store = Store::open_strict(directory.path()).unwrap();
19466        crate::assets::write_manifest(
19467            &store,
19468            &[
19469                crate::AssetRecord {
19470                    path: "records/notes/a.md".to_string(),
19471                    sha256: content_sha256(markdown),
19472                    bytes: markdown.len() as u64,
19473                    media_type: "text/markdown".to_string(),
19474                    wrappers: vec!["records/notes/a.md".to_string()],
19475                    required: true,
19476                },
19477                crate::AssetRecord {
19478                    path: "sources/files/a.pdf".to_string(),
19479                    sha256: content_sha256(binary),
19480                    bytes: binary.len() as u64,
19481                    media_type: "application/pdf".to_string(),
19482                    wrappers: vec!["records/notes/a.md".to_string()],
19483                    required: true,
19484                },
19485            ],
19486        )
19487        .unwrap();
19488
19489        let view = v2_local_files(&store).unwrap();
19490        assert_eq!(
19491            view.riding.get("records/notes/a.md"),
19492            Some(&(content_sha256(markdown), markdown.len() as u64))
19493        );
19494        assert!(!view.riding.contains_key("sources/files/a.pdf"));
19495    }
19496
19497    #[test]
19498    fn v2_markdown_asset_binding_requires_identical_cross_root_state() {
19499        let path = "sources/notes/a.md".to_string();
19500        let hash = "a".repeat(64);
19501        let mut content = std::collections::BTreeMap::from([(
19502            path.clone(),
19503            V2BaselineFile {
19504                sha256: hash.clone(),
19505                bytes: 7,
19506                proof: None,
19507            },
19508        )]);
19509        let mut assets = std::collections::BTreeMap::from([(
19510            path.clone(),
19511            V2BaselineAsset {
19512                blob_sha256: hash,
19513                bytes: 7,
19514                media_type: "text/markdown".to_string(),
19515                wrappers: vec![path.clone()],
19516                required: true,
19517                disposition: "hosted".to_string(),
19518                leaf_hash: "b".repeat(64),
19519            },
19520        )]);
19521        assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_ok());
19522
19523        content.get_mut(&path).unwrap().bytes = 8;
19524        assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_err());
19525        content.remove(&path);
19526        assets.get_mut(&path).unwrap().disposition = "withheld".to_string();
19527        assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_ok());
19528        content.insert(
19529            path,
19530            V2BaselineFile {
19531                sha256: "a".repeat(64),
19532                bytes: 7,
19533                proof: None,
19534            },
19535        );
19536        assert!(verify_v2_markdown_asset_content_bindings(&content, &assets).is_err());
19537    }
19538
19539    #[test]
19540    fn v2_markdown_asset_writes_use_the_typed_dual_plane_operation() {
19541        let path = "sources/notes/a.md";
19542        let assets = std::collections::BTreeMap::from([(
19543            path.to_string(),
19544            crate::AssetRecord {
19545                path: path.to_string(),
19546                sha256: "a".repeat(64),
19547                bytes: 7,
19548                media_type: "text/markdown".to_string(),
19549                wrappers: vec![path.to_string()],
19550                required: true,
19551            },
19552        )]);
19553        assert_eq!(
19554            v2_content_put_operation_kind(path, &assets),
19555            "put_asset_content"
19556        );
19557        assert_eq!(
19558            v2_content_put_operation_kind("records/ordinary.md", &assets),
19559            "put"
19560        );
19561        assert!(v2_withdrawal_includes_content(path, &assets));
19562        let mut binary_assets = assets.clone();
19563        binary_assets.insert(
19564            "sources/files/a.pdf".to_string(),
19565            crate::AssetRecord {
19566                path: "sources/files/a.pdf".to_string(),
19567                sha256: "d".repeat(64),
19568                bytes: 3,
19569                media_type: "application/pdf".to_string(),
19570                wrappers: vec![path.to_string()],
19571                required: true,
19572            },
19573        );
19574        assert!(!v2_withdrawal_includes_content(
19575            "sources/files/a.pdf",
19576            &binary_assets
19577        ));
19578
19579        let hash = "b".repeat(64);
19580        let operations = vec![json!({
19581            "op": "put_asset_content",
19582            "path": path,
19583            "expected": { "kind": "absent" },
19584            "blob": hash,
19585            "bytes": 11,
19586        })];
19587        let mut content = std::collections::BTreeMap::new();
19588        let mut remote_assets = std::collections::BTreeMap::new();
19589        apply_generated_v2_operations(&operations, &assets, &mut content, &mut remote_assets)
19590            .unwrap();
19591        assert_eq!(content.get(path).unwrap().sha256, "b".repeat(64));
19592    }
19593
19594    #[cfg(unix)]
19595    #[test]
19596    fn v2_stat_cache_waits_out_racy_files_and_binds_file_identity() {
19597        use std::os::unix::fs::MetadataExt as _;
19598
19599        let directory = tempfile::tempdir().unwrap();
19600        let first = directory.path().join("first.md");
19601        let second = directory.path().join("second.md");
19602        std::fs::write(&first, b"same").unwrap();
19603        std::fs::write(&second, b"same").unwrap();
19604        let first = std::fs::metadata(first).unwrap();
19605        let second = std::fs::metadata(second).unwrap();
19606        let observed_ns = |metadata: &std::fs::Metadata| {
19607            let mtime =
19608                i128::from(metadata.mtime()) * 1_000_000_000 + i128::from(metadata.mtime_nsec());
19609            let ctime =
19610                i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
19611            mtime.max(ctime)
19612        };
19613
19614        assert!(v2_scan_fingerprint_at(&first, observed_ns(&first) + 1_000_000_000).is_none());
19615        let first_fingerprint =
19616            v2_scan_fingerprint_at(&first, observed_ns(&first) + 3_000_000_000).unwrap();
19617        let second_fingerprint =
19618            v2_scan_fingerprint_at(&second, observed_ns(&second) + 3_000_000_000).unwrap();
19619        assert_ne!(first_fingerprint, second_fingerprint);
19620    }
19621
19622    #[test]
19623    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
19624        let directory = tempfile::tempdir().unwrap();
19625        std::fs::write(
19626            directory.path().join("DB.md"),
19627            scoped_projection_bytes(TEST_BRAIN_ID),
19628        )
19629        .unwrap();
19630        let store = Store::open_strict(directory.path()).unwrap();
19631        let head = scoped_test_head(&"a".repeat(64));
19632        let baseline = scoped_test_baseline(&"a".repeat(64));
19633        let mut view = v2_local_files(&store).unwrap();
19634        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
19635        assert!(!view.riding.contains_key("DB.md"));
19636        assert!(!view.eligibility.contains_key("DB.md"));
19637    }
19638
19639    #[test]
19640    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
19641        let directory = tempfile::tempdir().unwrap();
19642        std::fs::write(
19643            directory.path().join("DB.md"),
19644            scoped_projection_bytes(TEST_BRAIN_ID),
19645        )
19646        .unwrap();
19647        let store = Store::open_strict(directory.path()).unwrap();
19648        let head = scoped_test_head(&"a".repeat(64));
19649        let baseline = scoped_test_baseline(&"a".repeat(64));
19650
19651        let mut carried = v2_local_files(&store).unwrap();
19652        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
19653        let handed_off =
19654            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
19655        assert!(!handed_off.riding.contains_key("DB.md"));
19656
19657        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
19658        assert!(!freshly_scanned.riding.contains_key("DB.md"));
19659
19660        std::fs::write(
19661            directory.path().join("DB.md"),
19662            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
19663        )
19664        .unwrap();
19665        let tampered = Store::open_strict(directory.path()).unwrap();
19666        assert!(matches!(
19667            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
19668            Err(LinkError::ScopedProjectionModified)
19669        ));
19670    }
19671
19672    #[test]
19673    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
19674        let directory = tempfile::tempdir().unwrap();
19675        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19676        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
19677        std::fs::write(
19678            directory.path().join("DB.md"),
19679            b"---\nname: Kept home test\n---\n",
19680        )
19681        .unwrap();
19682        std::fs::write(
19683            directory.path().join("records/notes/a.md"),
19684            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
19685        )
19686        .unwrap();
19687        std::fs::write(
19688            directory.path().join("sources/private/secret.md"),
19689            b"---\ntype: note\n---\nlocal only\n",
19690        )
19691        .unwrap();
19692        std::fs::write(
19693            directory.path().join("sources/private/unlinked.md"),
19694            b"---\ntype: note\n---\nnot disclosed\n",
19695        )
19696        .unwrap();
19697        std::fs::write(
19698            directory.path().join(".sevralocal"),
19699            b"sources/private/**\n",
19700        )
19701        .unwrap();
19702
19703        let store = Store::open_strict(directory.path()).unwrap();
19704        let view = v2_local_files(&store).unwrap();
19705        assert!(!view.riding.contains_key("sources/private/secret.md"));
19706        assert_eq!(
19707            view.withheld_links,
19708            vec![V2WithheldLink {
19709                source: "records/notes/a.md".to_string(),
19710                target: "sources/private/secret.md".to_string(),
19711            }]
19712        );
19713    }
19714
19715    #[test]
19716    fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
19717        // An export omits kept-home content by construction, so a store
19718        // restored from one links to names it does not carry. Without this the
19719        // owner's own export could never be pushed back — the links would read
19720        // as broken and the commit would be refused.
19721        let directory = tempfile::tempdir().unwrap();
19722        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
19723        std::fs::write(
19724            directory.path().join("DB.md"),
19725            b"---\nname: Restored export\n---\n",
19726        )
19727        .unwrap();
19728        std::fs::write(
19729            directory.path().join("records/notes/a.md"),
19730            b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
19731        )
19732        .unwrap();
19733        std::fs::write(
19734            directory.path().join(".sevralocal"),
19735            b"sources/private/**\n",
19736        )
19737        .unwrap();
19738
19739        let store = Store::open_strict(directory.path()).unwrap();
19740        let view = v2_local_files(&store).unwrap();
19741        assert_eq!(
19742            view.withheld_links,
19743            vec![V2WithheldLink {
19744                source: "records/notes/a.md".to_string(),
19745                target: "sources/private/absent.md".to_string(),
19746            }]
19747        );
19748        // A dangling link the owner never claimed is still dangling.
19749        std::fs::write(
19750            directory.path().join("records/notes/b.md"),
19751            b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
19752        )
19753        .unwrap();
19754        let store = Store::open_strict(directory.path()).unwrap();
19755        let view = v2_local_files(&store).unwrap();
19756        assert!(
19757            !view
19758                .withheld_links
19759                .iter()
19760                .any(|link| link.target == "records/notes/nowhere.md"),
19761            "an unclaimed dangling target must not be declared withheld"
19762        );
19763    }
19764
19765    #[test]
19766    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
19767        let directory = tempfile::tempdir().unwrap();
19768        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
19769        std::fs::write(
19770            directory.path().join("DB.md"),
19771            b"---\nname: Withdrawal test\n---\n",
19772        )
19773        .unwrap();
19774        let source = b"---\ntype: note\n---\nlocal evidence\n";
19775        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
19776        std::fs::write(
19777            directory.path().join(".sevralocal"),
19778            b"sources/private/**\n",
19779        )
19780        .unwrap();
19781        let store = Store::open_strict(directory.path()).unwrap();
19782        let view = v2_local_files(&store).unwrap();
19783        let mut remote = std::collections::BTreeMap::new();
19784        remote.insert(
19785            "sources/private/evidence.md".to_string(),
19786            V2BaselineFile {
19787                sha256: content_sha256(source),
19788                bytes: source.len() as u64,
19789                proof: None,
19790            },
19791        );
19792        assert_eq!(
19793            v2_content_withdrawal_operation(
19794                &store,
19795                &view,
19796                &remote,
19797                "sources/private/evidence.md",
19798                "approved retention change",
19799            )
19800            .unwrap(),
19801            json!({
19802                "op": "withdraw_from_hosting",
19803                "path": "sources/private/evidence.md",
19804                "expected": { "kind": "blob", "hash": content_sha256(source) },
19805                "reason": "approved retention change",
19806            })
19807        );
19808
19809        std::fs::write(
19810            directory.path().join("sources/private/evidence.md"),
19811            b"changed after review",
19812        )
19813        .unwrap();
19814        assert!(matches!(
19815            v2_content_withdrawal_operation(
19816                &store,
19817                &view,
19818                &remote,
19819                "sources/private/evidence.md",
19820                "approved retention change",
19821            ),
19822            Err(LinkError::InvalidPack { .. })
19823        ));
19824    }
19825
19826    #[test]
19827    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
19828        let directory = tempfile::tempdir().unwrap();
19829        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
19830        std::fs::write(
19831            directory.path().join("DB.md"),
19832            b"---\nname: Asset withdrawal test\n---\n",
19833        )
19834        .unwrap();
19835        let bytes = b"private binary";
19836        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
19837        std::fs::write(
19838            directory.path().join(".sevralocal"),
19839            b"sources/files/private.pdf\n",
19840        )
19841        .unwrap();
19842        let store = Store::open_strict(directory.path()).unwrap();
19843        let view = v2_local_files(&store).unwrap();
19844        let local = crate::AssetRecord {
19845            path: "sources/files/private.pdf".to_string(),
19846            sha256: content_sha256(bytes),
19847            bytes: bytes.len() as u64,
19848            media_type: "application/pdf".to_string(),
19849            wrappers: vec![
19850                "sources/files/private.md".to_string(),
19851                "sources/redacted/private.md".to_string(),
19852            ],
19853            required: false,
19854        };
19855        let current = V2BaselineAsset {
19856            blob_sha256: local.sha256.clone(),
19857            bytes: local.bytes,
19858            media_type: local.media_type.clone(),
19859            wrappers: vec!["sources/files/private.md".to_string()],
19860            required: true,
19861            disposition: "hosted".to_string(),
19862            leaf_hash: "d".repeat(64),
19863        };
19864        assert_eq!(
19865            v2_asset_withdrawal_operation(
19866                &store,
19867                &view,
19868                &local.path,
19869                &local,
19870                &current,
19871                "approved retention change",
19872            )
19873            .unwrap(),
19874            json!({
19875                "op": "asset_withdraw",
19876                "path": local.path,
19877                "expected": { "kind": "asset", "hash": "d".repeat(64) },
19878                "asset": {
19879                    "blob_sha256": local.sha256.clone(),
19880                    "bytes": local.bytes,
19881                    "media_type": local.media_type.clone(),
19882                    "wrappers": local.wrappers.clone(),
19883                    "required": false,
19884                    "disposition": "withheld",
19885                },
19886                "reason": "approved retention change",
19887            })
19888        );
19889
19890        let mut mismatched = current.clone();
19891        mismatched.blob_sha256 = "f".repeat(64);
19892        assert!(matches!(
19893            v2_asset_withdrawal_operation(
19894                &store,
19895                &view,
19896                &local.path,
19897                &local,
19898                &mismatched,
19899                "approved retention change",
19900            ),
19901            Err(LinkError::InvalidPack { .. })
19902        ));
19903    }
19904
19905    #[test]
19906    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
19907        let first = v2_checkout_id(None).unwrap();
19908        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
19909        assert_ne!(first, v2_checkout_id(None).unwrap());
19910        assert!(is_sha256(&first));
19911    }
19912
19913    #[test]
19914    fn moved_checkout_relocates_its_path_bound_baseline_without_rehash_ambiguity() {
19915        let sandbox = tempfile::tempdir().unwrap();
19916        let stage_root = sandbox.path().join("stage");
19917        let live_root = sandbox.path().join("live");
19918        let from = stage_root.join("db");
19919        let to = live_root.join("db");
19920        std::fs::create_dir_all(from.join("records/items")).unwrap();
19921        std::fs::write(
19922            from.join("DB.md"),
19923            b"---\ntype: db-md\nscope: company\nowner: test\n---\n",
19924        )
19925        .unwrap();
19926        std::fs::write(
19927            from.join("records/items/example.md"),
19928            b"---\ntype: item\n---\n\n# Example\n",
19929        )
19930        .unwrap();
19931        let cfg = test_hub_config(
19932            "https://hub.example".to_string(),
19933            sandbox.path().join("state"),
19934        );
19935        let store = Store::open_strict(&from).unwrap();
19936        let local = v2_local_files(&store).unwrap();
19937        let files = local
19938            .riding
19939            .iter()
19940            .map(|(path, (sha256, bytes))| {
19941                (
19942                    path.clone(),
19943                    V2BaselineFile {
19944                        sha256: sha256.clone(),
19945                        bytes: *bytes,
19946                        proof: None,
19947                    },
19948                )
19949            })
19950            .collect();
19951        let baseline = V2SyncBaseline {
19952            v: 2,
19953            origin: "https://hub.example".to_string(),
19954            brain: TEST_BRAIN_ID.to_string(),
19955            checkout_id: Some("c".repeat(64)),
19956            head_seq: Some(7),
19957            commit_hash: Some("a".repeat(64)),
19958            content_root: Some("b".repeat(64)),
19959            asset_root: None,
19960            assets: std::collections::BTreeMap::new(),
19961            view_kind: Some("full".to_string()),
19962            view_revision: Some("d".repeat(64)),
19963            control_revision: Some("e".repeat(64)),
19964            projection_sha256: None,
19965            files,
19966            scan_cache: local.scan_cache.clone(),
19967            local_policy_digest: Some(local.policy.digest.clone()),
19968            local_eligibility: local.eligibility.clone(),
19969            remote_copy_remains: std::collections::BTreeMap::new(),
19970        };
19971        save_v2_baseline(&cfg, TEST_BRAIN_ID, &from, &baseline).unwrap();
19972        std::fs::rename(&stage_root, &live_root).unwrap();
19973
19974        let first = relocate_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from, &to).unwrap();
19975        assert_eq!(first.get("moved").and_then(Value::as_bool), Some(true));
19976        assert!(!has_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from).unwrap());
19977        assert!(has_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &to).unwrap());
19978
19979        let retry = relocate_v2_sync_baseline(&cfg, TEST_BRAIN_ID, &from, &to).unwrap();
19980        assert_eq!(retry.get("moved").and_then(Value::as_bool), Some(false));
19981    }
19982
19983    #[test]
19984    fn scoped_projection_edit_and_scope_transition_fail_closed() {
19985        let directory = tempfile::tempdir().unwrap();
19986        std::fs::write(
19987            directory.path().join("DB.md"),
19988            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
19989        )
19990        .unwrap();
19991        let store = Store::open_strict(directory.path()).unwrap();
19992        let head = scoped_test_head(&"a".repeat(64));
19993        let baseline = scoped_test_baseline(&"a".repeat(64));
19994        let mut view = v2_local_files(&store).unwrap();
19995        assert!(matches!(
19996            remove_scoped_projection(&head, Some(&baseline), &mut view),
19997            Err(LinkError::ScopedProjectionModified)
19998        ));
19999
20000        let changed = scoped_test_head(&"b".repeat(64));
20001        assert!(matches!(
20002            ensure_v2_view_compatible(&changed, Some(&baseline)),
20003            Err(LinkError::ScopedViewChanged)
20004        ));
20005
20006        let mut same_view_new_control = head.clone();
20007        same_view_new_control.control_revision = "c".repeat(64);
20008        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
20009        assert!(!same_v2_head(&head, &same_view_new_control));
20010    }
20011
20012    #[test]
20013    fn verified_baseline_reuse_requires_exact_content_assets_view_and_authority() {
20014        let mut head = scoped_test_head(&"a".repeat(64));
20015        head.control_revision = "b".repeat(64);
20016        head.pointer = Some(V2PointerBody {
20017            v: 2,
20018            brain: TEST_BRAIN_ID.to_string(),
20019            seq: 7,
20020            commit_hash: "c".repeat(64),
20021            feed_hash: "d".repeat(64),
20022            content_root: Some("e".repeat(64)),
20023            asset_root: Some("f".repeat(64)),
20024            materializer: "dbmd-projection-v1".to_string(),
20025            signer_epoch: 1,
20026            control_revision: head.control_revision.clone(),
20027            backup_preparation: "0".repeat(64),
20028            prior_pointer_hash: Some("1".repeat(64)),
20029            signed_at: "2026-08-24T00:00:00.000Z".to_string(),
20030        });
20031        let mut baseline = scoped_test_baseline(&head.view_revision);
20032        baseline.head_seq = Some(7);
20033        baseline.commit_hash = Some("c".repeat(64));
20034        baseline.content_root = Some("e".repeat(64));
20035        baseline.asset_root = Some("f".repeat(64));
20036        baseline.control_revision = Some(head.control_revision.clone());
20037        assert!(v2_baseline_matches_head(&head, &baseline));
20038
20039        let mut changed = baseline.clone();
20040        changed.head_seq = Some(8);
20041        assert!(!v2_baseline_matches_head(&head, &changed));
20042        let mut changed = baseline.clone();
20043        changed.commit_hash = Some("2".repeat(64));
20044        assert!(!v2_baseline_matches_head(&head, &changed));
20045        let mut changed = baseline.clone();
20046        changed.content_root = Some("3".repeat(64));
20047        assert!(!v2_baseline_matches_head(&head, &changed));
20048        let mut changed = baseline.clone();
20049        changed.asset_root = Some("4".repeat(64));
20050        assert!(!v2_baseline_matches_head(&head, &changed));
20051        let mut changed = baseline.clone();
20052        changed.view_revision = Some("5".repeat(64));
20053        assert!(!v2_baseline_matches_head(&head, &changed));
20054        let mut changed = baseline.clone();
20055        changed.control_revision = Some("6".repeat(64));
20056        assert!(!v2_baseline_matches_head(&head, &changed));
20057
20058        let mut changed_head = head.clone();
20059        changed_head.view_kind = "full".to_string();
20060        assert!(!v2_baseline_matches_head(&changed_head, &baseline));
20061    }
20062
20063    #[test]
20064    fn legacy_baseline_without_authority_revision_refreshes_before_reuse() {
20065        let sandbox = tempfile::tempdir().unwrap();
20066        let cfg = test_hub_config(
20067            "https://hub.example".to_string(),
20068            sandbox.path().to_path_buf(),
20069        );
20070        let head = scoped_test_head(&"a".repeat(64));
20071        let baseline = scoped_test_baseline(&head.view_revision);
20072        let mut encoded = serde_json::to_value(&baseline).unwrap();
20073        encoded.as_object_mut().unwrap().remove("control_revision");
20074        let parsed =
20075            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &serde_json::to_vec(&encoded).unwrap()).unwrap();
20076        assert!(parsed.control_revision.is_none());
20077        assert!(!v2_baseline_matches_head(&head, &parsed));
20078    }
20079
20080    #[test]
20081    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
20082        let scoped = scoped_test_head(&"a".repeat(64));
20083        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
20084        assert!(matches!(
20085            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
20086            Err(LinkError::ScopedProjectionModified)
20087        ));
20088
20089        let mut full = scoped.clone();
20090        full.view_kind = "full".to_string();
20091        let mut full_baseline = scoped_baseline.clone();
20092        full_baseline.view_kind = Some("full".to_string());
20093        full_baseline.projection_sha256 = None;
20094        assert!(matches!(
20095            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
20096            Err(LinkError::InvalidPack { .. })
20097        ));
20098
20099        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
20100        assert!(
20101            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
20102        );
20103    }
20104
20105    #[test]
20106    fn scoped_view_metadata_is_explicitly_non_authoritative() {
20107        let head = scoped_test_head(&"a".repeat(64));
20108        let value: Value =
20109            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
20110        assert_eq!(value["kind"], "link.md-scoped-view");
20111        assert_eq!(value["authoritative"], false);
20112        assert_eq!(value["visible_files"], 7);
20113        assert_eq!(value["brain"], TEST_BRAIN_ID);
20114    }
20115
20116    #[test]
20117    fn local_scoped_marker_requires_the_exact_generated_projection() {
20118        let directory = tempfile::tempdir().unwrap();
20119        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
20120        std::fs::write(
20121            directory.path().join("DB.md"),
20122            scoped_projection_bytes(TEST_BRAIN_ID),
20123        )
20124        .unwrap();
20125        let head = scoped_test_head(&"a".repeat(64));
20126        std::fs::write(
20127            directory.path().join(".dbmd/view.json"),
20128            scoped_view_metadata(&head, 0).unwrap(),
20129        )
20130        .unwrap();
20131        let store = Store::open_strict(directory.path()).unwrap();
20132        assert!(has_verified_local_scoped_view(&store));
20133
20134        std::fs::write(
20135            directory.path().join("DB.md"),
20136            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
20137        )
20138        .unwrap();
20139        let altered = Store::open_strict(directory.path()).unwrap();
20140        assert!(!has_verified_local_scoped_view(&altered));
20141    }
20142
20143    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
20144        use ring::signature::KeyPair as _;
20145
20146        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
20147        let rng = ring::rand::SystemRandom::new();
20148        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
20149        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
20150        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
20151        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
20152        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
20153        let blob = b"new";
20154        let blob_hash = content_sha256(blob);
20155        let changes = json!({
20156            "mutation_id": "sync:proposal-fixture",
20157            "operations": [{
20158                "blob": blob_hash,
20159                "bytes": blob.len(),
20160                "expected": null,
20161                "op": "put",
20162                "path": "records/new.md",
20163            }],
20164            "reason": "fixture",
20165            "v": 2,
20166        });
20167        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
20168        let changes_base64 = STANDARD.encode(&changes_bytes);
20169        let descriptor = json!({
20170            "base": null,
20171            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
20172            "changes_base64": changes_base64,
20173            "rebase": "strict",
20174            "v": 2,
20175        });
20176        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
20177        let payload_hash = "b".repeat(64);
20178        let submitted_at = "2026-08-19T12:00:00.000Z";
20179        let claim = json!({
20180            "actor_root": {
20181                "actor_class": "foreign_key",
20182                "credential": "ed25519:fixture",
20183                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
20184                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
20185                "principal": "key:fixture",
20186                "role": null,
20187            },
20188            "brain": TEST_BRAIN_ID,
20189            "clear_sha256": clear_hash,
20190            "control_revision": "c".repeat(64),
20191            "mutation_id": "sync:proposal-fixture",
20192            "payload_sha256": payload_hash,
20193            "proposal_id": proposal_id,
20194            "submitted_at": submitted_at,
20195            "v": 2,
20196        });
20197        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
20198        let envelope = json!({
20199            "claim": claim,
20200            "fingerprint": fingerprint,
20201            "public_key": public_key,
20202            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
20203        });
20204        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
20205        let submission_hash =
20206            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
20207        let mut head = scoped_test_head(&"c".repeat(64));
20208        head.view_kind = "full".to_string();
20209        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
20210        let value = json!({
20211            "proposal": {
20212                "base": null,
20213                "blobs": [{
20214                    "bytes": blob.len(),
20215                    "endpoint": format!(
20216                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
20217                    ),
20218                    "sha256": blob_hash,
20219                }],
20220                "changes_base64": changes_base64,
20221                "clear_sha256": clear_hash,
20222                "expires_at": "2026-08-26T12:00:00.000Z",
20223                "id": proposal_id,
20224                "payload_sha256": payload_hash,
20225                "proposer": { "class": "foreign_key" },
20226                "rebase": "strict",
20227                "state": "pending",
20228                "submission_claim_base64": STANDARD.encode(envelope_bytes),
20229                "submission_claim_sha256": submission_hash,
20230                "submitted_at": submitted_at,
20231            },
20232            "v": 2,
20233        });
20234        (head, proposal_id, value)
20235    }
20236
20237    #[test]
20238    fn v2_proposal_verifier_accepts_exact_signed_payload() {
20239        let (head, proposal_id, value) = signed_proposal_fixture();
20240        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
20241        assert_eq!(verified.blobs.len(), 1);
20242        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
20243    }
20244
20245    #[test]
20246    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
20247        let (head, proposal_id, value) = signed_proposal_fixture();
20248
20249        let mut changed = value.clone();
20250        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
20251        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
20252
20253        let mut redirected = value.clone();
20254        redirected["proposal"]["blobs"][0]["endpoint"] =
20255            Value::String("https://attacker.example/blob".to_string());
20256        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
20257
20258        let mut forged = value;
20259        let encoded = forged["proposal"]["submission_claim_base64"]
20260            .as_str()
20261            .unwrap();
20262        let mut envelope: Value =
20263            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
20264        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
20265        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
20266        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
20267        forged["proposal"]["submission_claim_sha256"] = Value::String(
20268            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
20269        );
20270        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
20271    }
20272
20273    #[cfg(unix)]
20274    #[test]
20275    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
20276        let sandbox = tempfile::tempdir().unwrap();
20277        let destination = sandbox.path().join("brain");
20278        let entries = vec![
20279            (
20280                "DB.md".to_string(),
20281                scoped_projection_bytes(TEST_BRAIN_ID),
20282            ),
20283            (
20284                "records/contacts/a.md".to_string(),
20285                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
20286                    .to_vec(),
20287            ),
20288        ];
20289        install_pulled_delta(&destination, &entries, &[], true).unwrap();
20290        assert!(destination.join("index.md").is_file());
20291        assert!(destination.join("records/index.md").is_file());
20292        assert!(destination.join("records/contacts/index.md").is_file());
20293        assert!(destination.join("records/contacts/index.jsonl").is_file());
20294    }
20295
20296    #[cfg(unix)]
20297    #[test]
20298    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
20299        let sandbox = tempfile::tempdir().unwrap();
20300        let destination = sandbox.path().join("brain");
20301        let cache = sandbox.path().join("cache");
20302        std::fs::create_dir(&cache).unwrap();
20303        let db = scoped_projection_bytes(TEST_BRAIN_ID);
20304        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
20305        let db_source = cache.join("db");
20306        let shared_source = cache.join("shared");
20307        crate::fsx::write_atomic(&db_source, &db).unwrap();
20308        crate::fsx::write_atomic(&shared_source, shared).unwrap();
20309        let mut entries = vec![V2StagedFile {
20310            path: "DB.md".to_string(),
20311            source: db_source,
20312            sha256: content_sha256(&db),
20313            bytes: db.len() as u64,
20314        }];
20315        for index in 0..512 {
20316            entries.push(V2StagedFile {
20317                path: format!("records/items/{index:05}.md"),
20318                source: shared_source.clone(),
20319                sha256: content_sha256(shared),
20320                bytes: shared.len() as u64,
20321            });
20322        }
20323        install_pulled_delta_sources(
20324            &destination,
20325            &entries,
20326            &[],
20327            false,
20328            None,
20329            &scoped_test_head(&"c".repeat(64)),
20330        )
20331        .unwrap();
20332        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
20333        for index in 0..512 {
20334            assert_eq!(
20335                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
20336                shared
20337            );
20338        }
20339        assert!(
20340            std::fs::read_dir(sandbox.path())
20341                .unwrap()
20342                .all(|entry| !entry
20343                    .unwrap()
20344                    .file_name()
20345                    .to_string_lossy()
20346                    .contains("pull-stage")),
20347            "the private stage must be atomically installed or removed"
20348        );
20349    }
20350
20351    #[cfg(unix)]
20352    #[test]
20353    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
20354        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
20355
20356        let sandbox = tempfile::tempdir().unwrap();
20357        let root = sandbox.path().join("brain");
20358        std::fs::create_dir_all(root.join("records/items")).unwrap();
20359        let db = scoped_projection_bytes(TEST_BRAIN_ID);
20360        let old = b"---\ntype: note\n---\n\nold\n";
20361        let new = b"---\ntype: note\n---\n\nnew\n";
20362        let removed = b"---\ntype: note\n---\n\nremove me\n";
20363        std::fs::write(root.join("DB.md"), &db).unwrap();
20364        std::fs::write(root.join("records/items/change.md"), old).unwrap();
20365        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
20366        for index in 0..512 {
20367            std::fs::write(
20368                root.join(format!("records/items/untouched-{index:04}.md")),
20369                old,
20370            )
20371            .unwrap();
20372        }
20373        let untouched = root.join("records/items/untouched-0256.md");
20374        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
20375        let source = sandbox.path().join("changed-source");
20376        crate::fsx::write_atomic(&source, new).unwrap();
20377        let same_source = sandbox.path().join("unchanged-source");
20378        crate::fsx::write_atomic(&same_source, old).unwrap();
20379        let same_entry = V2StagedFile {
20380            path: "records/items/change.md".to_string(),
20381            source: same_source,
20382            sha256: content_sha256(old),
20383            bytes: old.len() as u64,
20384        };
20385        let entry = V2StagedFile {
20386            path: "records/items/change.md".to_string(),
20387            source,
20388            sha256: content_sha256(new),
20389            bytes: new.len() as u64,
20390        };
20391        let head = scoped_test_head(&"c".repeat(64));
20392
20393        // A no-op established pull neither clones the checkout nor arms a
20394        // journal. Inode identity is a direct regression guard against the
20395        // former brain-wide private-copy implementation.
20396        install_established_v2_delta(
20397            Store::open_strict(&root).unwrap(),
20398            &[same_entry],
20399            &["records/items/already-absent.md".to_string()],
20400            true,
20401            None,
20402            &head,
20403        )
20404        .unwrap();
20405        assert_eq!(
20406            std::fs::metadata(&untouched).unwrap().ino(),
20407            untouched_inode
20408        );
20409        assert!(!root.join(V2_PULL_JOURNAL).exists());
20410
20411        install_established_v2_delta(
20412            Store::open_strict(&root).unwrap(),
20413            &[entry],
20414            &["records/items/delete.md".to_string()],
20415            false,
20416            None,
20417            &head,
20418        )
20419        .unwrap();
20420        assert_eq!(
20421            std::fs::read(root.join("records/items/change.md")).unwrap(),
20422            new
20423        );
20424        assert!(!root.join("records/items/delete.md").exists());
20425        assert_eq!(
20426            std::fs::metadata(&untouched).unwrap().ino(),
20427            untouched_inode
20428        );
20429        assert!(root.join(V2_PULL_JOURNAL).is_file());
20430        assert_eq!(
20431            std::fs::metadata(root.join(V2_PULL_JOURNAL))
20432                .unwrap()
20433                .permissions()
20434                .mode()
20435                & 0o777,
20436            0o600
20437        );
20438        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
20439            .unwrap()
20440            .unwrap();
20441        assert_eq!(
20442            std::fs::metadata(root.join(&journal.backup_dir))
20443                .unwrap()
20444                .permissions()
20445                .mode()
20446                & 0o777,
20447            0o700
20448        );
20449        for entry in &journal.entries {
20450            if let Some(backup) = &entry.backup {
20451                assert_eq!(
20452                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
20453                        .unwrap()
20454                        .permissions()
20455                        .mode()
20456                        & 0o777,
20457                    0o600
20458                );
20459            }
20460        }
20461
20462        let cfg = test_hub_config(
20463            "https://example.test".to_string(),
20464            sandbox.path().join("state"),
20465        );
20466        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20467        assert_eq!(
20468            std::fs::read(root.join("records/items/change.md")).unwrap(),
20469            old
20470        );
20471        assert_eq!(
20472            std::fs::read(root.join("records/items/delete.md")).unwrap(),
20473            removed
20474        );
20475        assert_eq!(
20476            std::fs::metadata(&untouched).unwrap().ino(),
20477            untouched_inode
20478        );
20479        assert!(!root.join(V2_PULL_JOURNAL).exists());
20480    }
20481
20482    #[test]
20483    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
20484        let body = b"bounded bytes";
20485        let path = "records/example.md".to_string();
20486        let file = V2BaselineFile {
20487            sha256: content_sha256(body),
20488            bytes: body.len() as u64,
20489            proof: None,
20490        };
20491        let header = serde_json::to_vec(&json!({
20492            "bytes": body.len(),
20493            "path": path,
20494            "sha256": file.sha256,
20495            "v": 2,
20496        }))
20497        .unwrap();
20498        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
20499        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
20500        stream.extend_from_slice(&header);
20501        stream.extend_from_slice(body);
20502        stream.extend_from_slice(&0_u32.to_be_bytes());
20503        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
20504        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
20505
20506        let mut tampered = stream.clone();
20507        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
20508        tampered[body_offset] ^= 1;
20509        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
20510
20511        let mut trailing = stream;
20512        trailing.push(0);
20513        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
20514    }
20515
20516    #[test]
20517    fn first_checkout_resolution_does_not_recreate_the_same_conflict() {
20518        let path = "records/value.md".to_string();
20519        let mut local = std::collections::BTreeMap::new();
20520        local.insert(path.clone(), (content_sha256(b"local"), 5));
20521        let mut remote = std::collections::BTreeMap::new();
20522        remote.insert(
20523            path.clone(),
20524            V2BaselineFile {
20525                sha256: content_sha256(b"remote"),
20526                bytes: 6,
20527                proof: None,
20528            },
20529        );
20530
20531        assert_eq!(
20532            v2_initial_content_conflicts(&local, &remote, false),
20533            vec![path]
20534        );
20535        assert!(v2_initial_content_conflicts(&local, &remote, true).is_empty());
20536
20537        let mut resolution = std::collections::BTreeMap::new();
20538        resolution.insert(
20539            "records/value.md".to_string(),
20540            V2ResolutionOverride {
20541                expected_remote: Some(content_sha256(b"remote")),
20542                selected_local: Some(content_sha256(b"local")),
20543            },
20544        );
20545        assert!(v2_resolution_allows_path(
20546            Some(&resolution),
20547            "records/value.md",
20548            true
20549        ));
20550        assert!(v2_resolution_allows_path(
20551            Some(&resolution),
20552            "records/new-target.md",
20553            false
20554        ));
20555        assert!(!v2_resolution_allows_path(
20556            Some(&resolution),
20557            "records/unreviewed-remote.md",
20558            true
20559        ));
20560    }
20561
20562    #[test]
20563    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
20564        let sandbox = tempfile::TempDir::new().unwrap();
20565        let root = sandbox.path().join("brain");
20566        std::fs::create_dir_all(&root).unwrap();
20567        std::fs::write(
20568            root.join("DB.md"),
20569            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20570        )
20571        .unwrap();
20572        let store = Store::open_strict(&root).unwrap();
20573        let incomplete = crate::ulid::mint();
20574        store
20575            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
20576            .unwrap();
20577        let expired = crate::ulid::mint();
20578        store
20579            .create_dir_all(&v2_conflict_relative(&expired, "files"))
20580            .unwrap();
20581        let plan = V2ConflictPlan {
20582            v: 2,
20583            class: "content_resolution_required".to_string(),
20584            bundle: expired.clone(),
20585            brain: TEST_BRAIN_ID.to_string(),
20586            origin: "https://example.test".to_string(),
20587            created_unix: 0,
20588            expires_unix: 0,
20589            base_seq: None,
20590            base_commit: None,
20591            remote_seq: 0,
20592            remote_commit: None,
20593            remote_content_root: None,
20594            view_kind: "full".to_string(),
20595            view_revision: "a".repeat(64),
20596            files: vec![V2ConflictFile {
20597                path: "records/value.md".to_string(),
20598                base: V2ConflictCoordinate {
20599                    sha256: None,
20600                    bytes: None,
20601                    file: None,
20602                },
20603                local: V2ConflictCoordinate {
20604                    sha256: None,
20605                    bytes: None,
20606                    file: None,
20607                },
20608                remote: V2ConflictCoordinate {
20609                    sha256: None,
20610                    bytes: None,
20611                    file: None,
20612                },
20613            }],
20614        };
20615        let mut bytes = serde_json::to_vec(&plan).unwrap();
20616        bytes.push(b'\n');
20617        store
20618            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
20619            .unwrap();
20620
20621        let listed = sync_conflicts(&root, false, false).unwrap();
20622        assert_eq!(listed["bundles"], 2);
20623        assert_eq!(listed["pruned"], 0);
20624        let pruned = sync_conflicts(&root, true, false).unwrap();
20625        assert_eq!(pruned["bundles"], 0);
20626        assert_eq!(pruned["pruned"], 2);
20627        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
20628        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
20629    }
20630
20631    #[test]
20632    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
20633        let sandbox = tempfile::TempDir::new().unwrap();
20634        let root = sandbox.path().join("brain");
20635        std::fs::create_dir_all(&root).unwrap();
20636        std::fs::write(
20637            root.join("DB.md"),
20638            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20639        )
20640        .unwrap();
20641        let store = Store::open_strict(&root).unwrap();
20642        let bundle = crate::ulid::mint();
20643        store
20644            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
20645            .unwrap();
20646        store
20647            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
20648            .unwrap();
20649
20650        assert!(sync_conflicts(&root, true, false).is_err());
20651        assert!(sync_conflicts(&root, false, true).is_err());
20652        let pruned = sync_conflicts(&root, true, true).unwrap();
20653        assert_eq!(pruned["pruned"], 1);
20654        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
20655    }
20656
20657    #[test]
20658    fn ready_pull_journal_rolls_back_exact_preimages() {
20659        let sandbox = tempfile::TempDir::new().unwrap();
20660        let root = sandbox.path().join("brain");
20661        std::fs::create_dir_all(root.join("records")).unwrap();
20662        std::fs::write(
20663            root.join("DB.md"),
20664            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20665        )
20666        .unwrap();
20667        let path = "records/value.md";
20668        let old = b"---\ntype: note\n---\n\nold\n";
20669        let new = b"---\ntype: note\n---\n\nnew\n";
20670        std::fs::write(root.join(path), old).unwrap();
20671        let store = Store::open_strict(&root).unwrap();
20672        let bundle = crate::ulid::mint();
20673        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20674        store
20675            .create_private_dir_all(Path::new(&backup_dir))
20676            .unwrap();
20677        store
20678            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
20679            .unwrap();
20680        let journal = V2PullJournal {
20681            v: 1,
20682            phase: V2PullPhase::Ready,
20683            brain: TEST_BRAIN_ID.to_string(),
20684            previous: V2PullCoordinate {
20685                head_seq: None,
20686                commit_hash: None,
20687                view_kind: None,
20688                view_revision: None,
20689            },
20690            next: V2PullCoordinate {
20691                head_seq: Some(2),
20692                commit_hash: Some("c".repeat(64)),
20693                view_kind: Some("full".to_string()),
20694                view_revision: Some("d".repeat(64)),
20695            },
20696            backup_dir: backup_dir.clone(),
20697            entries: vec![V2PullJournalEntry {
20698                path: path.to_string(),
20699                old: Some(V2PullFileCoordinate {
20700                    sha256: content_sha256(old),
20701                    bytes: old.len() as u64,
20702                }),
20703                new: Some(V2PullFileCoordinate {
20704                    sha256: content_sha256(new),
20705                    bytes: new.len() as u64,
20706                }),
20707                backup: Some("00000000".to_string()),
20708            }],
20709        };
20710        validate_v2_pull_journal(&journal).unwrap();
20711        store
20712            .write_private_atomic_new(
20713                Path::new(V2_PULL_JOURNAL),
20714                &v2_pull_journal_bytes(&journal).unwrap(),
20715            )
20716            .unwrap();
20717        store.write_atomic(Path::new(path), new).unwrap();
20718
20719        let cfg = test_hub_config(
20720            "https://example.test".to_string(),
20721            sandbox.path().join("state"),
20722        );
20723        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20724        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
20725        assert!(!root.join(V2_PULL_JOURNAL).exists());
20726        assert!(!root.join(backup_dir).exists());
20727    }
20728
20729    #[test]
20730    fn preparing_pull_journal_discards_only_private_staging() {
20731        let sandbox = tempfile::TempDir::new().unwrap();
20732        let root = sandbox.path().join("brain");
20733        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
20734        std::fs::write(
20735            root.join("DB.md"),
20736            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20737        )
20738        .unwrap();
20739        let store = Store::open_strict(&root).unwrap();
20740        let bundle = crate::ulid::mint();
20741        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20742        store
20743            .create_private_dir_all(Path::new(&backup_dir))
20744            .unwrap();
20745        let journal = V2PullJournal {
20746            v: 1,
20747            phase: V2PullPhase::Preparing,
20748            brain: TEST_BRAIN_ID.to_string(),
20749            previous: V2PullCoordinate {
20750                head_seq: None,
20751                commit_hash: None,
20752                view_kind: None,
20753                view_revision: None,
20754            },
20755            next: V2PullCoordinate {
20756                head_seq: Some(1),
20757                commit_hash: Some("a".repeat(64)),
20758                view_kind: Some("full".to_string()),
20759                view_revision: Some("b".repeat(64)),
20760            },
20761            backup_dir: backup_dir.clone(),
20762            entries: vec![V2PullJournalEntry {
20763                path: "records/new.md".to_string(),
20764                old: None,
20765                new: Some(V2PullFileCoordinate {
20766                    sha256: "c".repeat(64),
20767                    bytes: 1,
20768                }),
20769                backup: None,
20770            }],
20771        };
20772        store
20773            .write_private_atomic_new(
20774                Path::new(V2_PULL_JOURNAL),
20775                &v2_pull_journal_bytes(&journal).unwrap(),
20776            )
20777            .unwrap();
20778        let cfg = test_hub_config(
20779            "https://example.test".to_string(),
20780            sandbox.path().join("state"),
20781        );
20782
20783        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20784
20785        assert!(root.join("DB.md").is_file());
20786        assert!(!root.join(V2_PULL_JOURNAL).exists());
20787        assert!(!root.join(backup_dir).exists());
20788    }
20789
20790    #[test]
20791    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
20792        let sandbox = tempfile::TempDir::new().unwrap();
20793        let root = sandbox.path().join("brain");
20794        std::fs::create_dir_all(root.join("records")).unwrap();
20795        std::fs::write(
20796            root.join("DB.md"),
20797            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
20798        )
20799        .unwrap();
20800        let new = b"---\ntype: note\n---\n\nnew\n";
20801        std::fs::write(root.join("records/value.md"), new).unwrap();
20802        let store = Store::open_strict(&root).unwrap();
20803        let bundle = crate::ulid::mint();
20804        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
20805        store
20806            .create_private_dir_all(Path::new(&backup_dir))
20807            .unwrap();
20808        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
20809        store.create_private_dir_all(Path::new(&orphan)).unwrap();
20810        let next = V2PullCoordinate {
20811            head_seq: Some(2),
20812            commit_hash: Some("c".repeat(64)),
20813            view_kind: Some("full".to_string()),
20814            view_revision: Some("d".repeat(64)),
20815        };
20816        let journal = V2PullJournal {
20817            v: 1,
20818            phase: V2PullPhase::Ready,
20819            brain: TEST_BRAIN_ID.to_string(),
20820            previous: V2PullCoordinate {
20821                head_seq: Some(1),
20822                commit_hash: Some("a".repeat(64)),
20823                view_kind: Some("full".to_string()),
20824                view_revision: Some("b".repeat(64)),
20825            },
20826            next: next.clone(),
20827            backup_dir: backup_dir.clone(),
20828            entries: vec![V2PullJournalEntry {
20829                path: "records/value.md".to_string(),
20830                old: Some(V2PullFileCoordinate {
20831                    sha256: "e".repeat(64),
20832                    bytes: new.len() as u64,
20833                }),
20834                new: Some(V2PullFileCoordinate {
20835                    sha256: content_sha256(new),
20836                    bytes: new.len() as u64,
20837                }),
20838                backup: Some("00000000".to_string()),
20839            }],
20840        };
20841        store
20842            .write_private_atomic_new(
20843                Path::new(V2_PULL_JOURNAL),
20844                &v2_pull_journal_bytes(&journal).unwrap(),
20845            )
20846            .unwrap();
20847        let cfg = test_hub_config(
20848            "https://example.test".to_string(),
20849            sandbox.path().join("state"),
20850        );
20851        save_v2_baseline(
20852            &cfg,
20853            TEST_BRAIN_ID,
20854            &root,
20855            &V2SyncBaseline {
20856                v: 2,
20857                origin: "https://example.test".to_string(),
20858                brain: TEST_BRAIN_ID.to_string(),
20859                checkout_id: Some("c".repeat(64)),
20860                head_seq: next.head_seq,
20861                commit_hash: next.commit_hash.clone(),
20862                content_root: Some("f".repeat(64)),
20863                asset_root: None,
20864                assets: Default::default(),
20865                view_kind: next.view_kind.clone(),
20866                view_revision: next.view_revision.clone(),
20867                control_revision: Some("d".repeat(64)),
20868                projection_sha256: None,
20869                files: Default::default(),
20870                scan_cache: Default::default(),
20871                local_policy_digest: None,
20872                local_eligibility: Default::default(),
20873                remote_copy_remains: Default::default(),
20874            },
20875        )
20876        .unwrap();
20877
20878        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
20879
20880        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
20881        assert!(!root.join(V2_PULL_JOURNAL).exists());
20882        assert!(!root.join(backup_dir).exists());
20883        assert!(!root.join(orphan).exists());
20884    }
20885
20886    #[test]
20887    fn only_typed_validation_projection_lag_retries_v2_head() {
20888        let typed = HubResponse {
20889            status: 422,
20890            body: Some(json!({ "code": "validation_index_catching_up" })),
20891        };
20892        let nested = HubResponse {
20893            status: 422,
20894            body: Some(json!({
20895                "details": { "code": "validation_index_catching_up" }
20896            })),
20897        };
20898        let unrelated = HubResponse {
20899            status: 422,
20900            body: Some(json!({ "code": "source_immutable" })),
20901        };
20902        let wrong_status = HubResponse {
20903            status: 403,
20904            body: typed.body.clone(),
20905        };
20906        assert!(v2_validation_catching_up(&typed));
20907        assert!(v2_validation_catching_up(&nested));
20908        assert!(!v2_validation_catching_up(&unrelated));
20909        assert!(!v2_validation_catching_up(&wrong_status));
20910    }
20911
20912    #[test]
20913    fn asset_resolution_is_limited_to_explicitly_resolved_wrappers() {
20914        let wrapper = "records/operational/package.md".to_string();
20915        let mut resolution = std::collections::BTreeMap::new();
20916        resolution.insert(
20917            wrapper.clone(),
20918            V2ResolutionOverride {
20919                expected_remote: Some("a".repeat(64)),
20920                selected_local: Some("b".repeat(64)),
20921            },
20922        );
20923        let local = crate::AssetRecord {
20924            path: "sources/package/object.blob".to_string(),
20925            sha256: "c".repeat(64),
20926            bytes: 1,
20927            media_type: "application/octet-stream".to_string(),
20928            wrappers: vec![wrapper.clone()],
20929            required: true,
20930        };
20931        assert!(v2_resolution_allows_asset(
20932            Some(&resolution),
20933            None,
20934            None,
20935            Some(&local),
20936        ));
20937        let unrelated = crate::AssetRecord {
20938            wrappers: vec!["records/unrelated.md".to_string()],
20939            ..local
20940        };
20941        assert!(!v2_resolution_allows_asset(
20942            Some(&resolution),
20943            None,
20944            None,
20945            Some(&unrelated),
20946        ));
20947        assert!(!v2_resolution_allows_asset(
20948            None,
20949            None,
20950            None,
20951            Some(&unrelated),
20952        ));
20953    }
20954}