Skip to main content

dbmd_core/
linkmd.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The **link.md client** — the five interconnect verbs `dbmd` speaks against
4//! a hub: `resolve`, `sync`, `grant`, `propose`, `subscribe`.
5//!
6//! One binary, two specs (the git precedent: one binary carries both the
7//! object format and the wire protocol). The db.md FORMAT is untouched by this
8//! module: a store never needs link.md to be valid db.md, record files stay
9//! plain markdown, and SPEC.md reserves only the `@brain/id` address *shape*.
10//! Everything with a wire or a trust boundary — addressing across stores,
11//! pulling/pushing a hosted copy, capability grants, the propose door, feed
12//! polling and signed-entry verification — lives here, as a *client
13//! capability*, never a format requirement.
14//!
15//! # What this client speaks
16//!
17//! The v0 HTTP binding a hub serves under its base URL:
18//!
19//! | verb | binding |
20//! | --- | --- |
21//! | `resolve` | `GET /api/hub/brains/<brain>` (the brain card); records are read from the exact signed snapshot pack named by that card's verified feed head |
22//! | `sync` (pull) | `GET /api/hub/brains/<brain>/export?format=pack&atSeq=<n>&feedHash=<hash>` — the exact verified snapshot |
23//! | `sync` (push) | `POST /api/hub/brains/<brain>/push` for small snapshots; presign/upload/commit for large snapshots |
24//! | `grant` | `GET` / `POST /api/hub/brains/<brain>/grants`, `DELETE /api/hub/brains/<brain>/grants/<id>` |
25//! | `propose` | `POST /api/hub/sites/<handle>/inbox` — evidence in, without trust (unauthenticated by design) |
26//! | `subscribe` | `GET /api/hub/brains/<brain>` + `/feed` for a locally verified signed head |
27//!
28//! # Configuration — no default hub, credential never in the store
29//!
30//! There is **no built-in hub endpoint**: the toolkit is neutral and a hub is
31//! whatever the user points it at. Resolution order for the hub URL:
32//!
33//! 1. the `--hub <URL>` flag,
34//! 2. the `DBMD_HUB_URL` environment variable,
35//! 3. the `hub = <URL>` line in the store-local `.dbmd/config` file
36//!    (toolkit state, not store content — the walkers already skip hidden
37//!    directories, so `.dbmd/` never syncs, indexes, or validates).
38//!
39//! The credential is the `DBMD_HUB_KEY` environment variable, full stop. It is
40//! deliberately **not** read from `.dbmd/config`: a secret inside the store
41//! tree is one commit or one push away from leaking. A store-selected hub may
42//! receive an ambient bearer or agent key only when
43//! `DBMD_HUB_CREDENTIAL_ORIGIN` binds it to that exact origin. Identity pins
44//! and monotonic feed checkpoints live in the user's global state directory,
45//! never in store-controlled `.dbmd/`.
46//!
47//! Non-HTTPS hubs are refused (the bearer key must never travel in cleartext)
48//! with a loopback exemption for local development.
49//!
50//! # v0 honesty
51//!
52//! This client binds to what a hub enforces **today**: grantees are hub
53//! principals (an email), grant scopes are store-path prefixes, pushes are
54//! whole-store snapshots, and `subscribe` reports feed-head movement. The hub
55//! signs each committed snapshot in a hash-chained feed with a per-brain
56//! Ed25519 identity. This client verifies the rotation chain, signer epochs,
57//! monotonic feed checkpoint, snapshot token, and content-addressed pack before
58//! untrusted bytes touch their destination.
59
60use std::collections::{BTreeMap, BTreeSet};
61use std::io::{Cursor, Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{SystemTime, UNIX_EPOCH};
64
65use base64::{
66    engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
67    Engine as _,
68};
69use ring::signature::{UnparsedPublicKey, ED25519};
70use serde::{Deserialize, Serialize};
71use serde_json::{json, Value};
72use sha2::{Digest, Sha256};
73
74use crate::store::Store;
75
76/// Environment variable naming the hub base URL (e.g. `https://hub.example.com`).
77pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
78
79/// Environment variable carrying the hub bearer credential. The bearer
80/// credential source — see the module docs for why it is never store-based.
81pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
82
83/// Explicit origin binding required before an ambient bearer or agent key may
84/// be sent to a hub selected by untrusted store-local configuration. The value
85/// is an origin (`https://host[:port]`), not an arbitrary URL path.
86pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
87
88/// Override for dbmd's user-owned trust/checkpoint root. Must be absolute.
89/// Intended for managed installations and hermetic tests; untrusted stores
90/// cannot select it.
91pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
92
93/// Explicit test/development escape hatch for registry homes on loopback or
94/// private networks. Production federation rejects every non-public resolved
95/// address and pins the validated DNS answer into the HTTP agent.
96pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
97
98/// Explicit development escape hatch for object-store URLs on loopback/private
99/// networks. Production hubs must return public HTTPS URLs. A local loopback hub
100/// is allowed to use local object URLs without this switch.
101pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
102
103/// Environment variable naming the PATH of a self-custodied BRAIN key file
104/// (link.md §2.4). When set, `sync --push` signs each feed entry locally and
105/// ships it through the pack flow — the hub verifies and stores the exact
106/// client bytes and can never sign for the brain. Same file format as agent
107/// keys (`dbmd key generate`).
108pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
109
110/// Environment variable naming the PATH of an agent signing key file
111/// (link.md §8 `LinkMD-Sig` proof of possession). When set, authenticated
112/// requests are signed per-request with the agent's Ed25519 key instead of
113/// carrying a bearer: the signature binds method + path + body + a ±60s
114/// window, so nothing reusable ever crosses the wire or lands in a log or an
115/// agent transcript. The file holds the base64url PKCS#8 key minted by
116/// `dbmd key generate`; the path is not a secret, the file is (mode 0600).
117pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
118
119/// The store-local config file, relative to the store root. Holds non-secret
120/// toolkit state (`hub = <URL>`); hidden, so every store walk skips it.
121pub const CONFIG_REL_PATH: &str = ".dbmd/config";
122
123/// Control-plane JSON is metadata, never the store itself. Exact snapshot
124/// bytes travel through the separately bounded pack lane.
125const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
126/// A feed page can legitimately carry a large snapshot manifest, but remains
127/// far below a full pack and is parsed with count-bounded sequence visitors.
128const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
129/// Foreign registry cards are identity metadata only.
130const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
131
132/// Direct JSON pushes stay below the serverless request-body cap. Larger
133/// snapshots switch to the bounded object-store pack lane.
134const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
135/// Ceiling for a staged change manifest. Mirrors the hub's bound so an
136/// oversized push is refused before it stages anything.
137const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
138
139/// Canonical raw ZIP32 uses a u16 entry count and stores each UTF-8 name twice.
140const MAX_PUSH_FILES: usize = u16::MAX as usize;
141const MAX_STORE_PATH_BYTES: usize = 1_024;
142const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
143/// Exact worst case for the canonical STORED profile:
144/// payload + (local 30 + central 46 + name twice) per entry + EOCD 22.
145const MAX_PACK_BYTES: u64 =
146    MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
147/// An upload reservation body carries one entry per changed blob, so a real
148/// store's first push does not fit in a single request: the hub bounds the body
149/// and the hosting platform rejects an oversized payload before the route sees
150/// it. Reservations are therefore batched. The budget is deliberately well
151/// under the hub's own ceiling, and both bounds are enforced because a single
152/// hash can carry many coordinates and a path may be long.
153/// The count matches the reservation window a hub grants per request; batching
154/// above it only fails deeper in, after the body has already been accepted.
155const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
156const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
157
158/// A legitimate brain should rotate rarely. Bound adversarial identity
159/// histories before allocating and repeatedly verifying an unbounded chain.
160const MAX_IDENTITY_ROTATIONS: usize = 1_024;
161
162/// Split blob declarations into request-sized batches, by serialized size and
163/// by count. Order is preserved so a caller can concatenate the responses.
164fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
165    let mut batches: Vec<Vec<Value>> = Vec::new();
166    let mut current: Vec<Value> = Vec::new();
167    let mut current_bytes = 0usize;
168    for declaration in declarations {
169        let declared_bytes = serde_json::to_string(&declaration)
170            .map(|text| text.len())
171            .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
172            + 1;
173        if !current.is_empty()
174            && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
175                || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
176        {
177            batches.push(std::mem::take(&mut current));
178            current_bytes = 0;
179        }
180        current_bytes += declared_bytes;
181        current.push(declaration);
182    }
183    if !current.is_empty() {
184        batches.push(current);
185    }
186    batches
187}
188/// A client never needs to replay an unbounded feed in one invocation. Large
189/// histories are mirrored incrementally; a first mirror beyond this cap needs a
190/// checkpoint/export rather than allocating attacker-controlled metadata.
191const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
192const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
193const FEED_PAGE_LIMIT: usize = 100;
194
195/// The hub's inbox cap on one `propose` submission body, mirrored client-side
196/// so an oversized body fails before the upload, not after (the same
197/// fail-before-upload contract as the push caps). Public so the CLI can
198/// pre-check a `--body-file` from file metadata without reading it.
199pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
200
201/// Bounded connect so a dead hub fails fast; a generous read window so a
202/// large export on a slow link still completes.
203const CONNECT_TIMEOUT_SECS: u64 = 10;
204const READ_TIMEOUT_SECS: u64 = 120;
205/// Hard wall-clock budget for one HTTP attempt, spanning socket writes,
206/// response headers, and the complete response body. DNS performed for pinned
207/// agents has its own interruptible deadline below.
208const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
209/// A commit of a whole store is brain-sized work the hub is allowed to spend
210/// its full route budget on, so the client has to outwait the server rather
211/// than the other way round. At the default 120 seconds a first push of a real
212/// store abandoned a commit the hub was still completing and reported a
213/// transport failure for work that had not failed. This sits above the hub's
214/// own genesis-commit ceiling so the client always learns the outcome.
215const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
216/// A commit is idempotent by mutation coordinate, so a lost response is worth
217/// asking after — and the work it lost the receipt for may still be finishing,
218/// which is why the waits are long enough to let a brain-sized commit land.
219const COMMIT_ATTEMPTS: usize = 4;
220const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
221const CONNECT_ATTEMPTS: usize = 3;
222const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
223
224/// Staging a real store means tens of thousands of immutable object PUTs in a
225/// row. A single refusal under sustained load must not fail the migration, so
226/// the upload lane retries further and waits longer than a connect blip does.
227const UPLOAD_ATTEMPTS: usize = 6;
228const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
229/// Retries share one wall-clock budget. Without this outer deadline, one
230/// black-holed object can consume the per-attempt timeout six times, outlive
231/// its presigned capability, and make a large migration appear hung.
232const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
233
234fn upload_retry_backoff_ms(attempt: usize) -> u64 {
235    UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
236}
237
238fn upload_deadline_error() -> LinkError {
239    LinkError::Transport {
240        hub: "the object store".to_string(),
241        message: "network error (upload deadline exceeded)".to_string(),
242    }
243}
244
245fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
246    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
247    if remaining.is_zero() {
248        return Err(upload_deadline_error());
249    }
250    Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
251}
252
253fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
254    if attempt + 1 >= UPLOAD_ATTEMPTS {
255        return false;
256    }
257    let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
258    if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
259        return false;
260    }
261    std::thread::sleep(pause);
262    true
263}
264
265/// A migration opens dozens of staging windows back to back, so it is ordinary
266/// for a shared limiter or a briefly busy hub to answer one of them with 429 or
267/// 5xx. Failing the push there throws away every byte already staged, and the
268/// waits have to reach past a whole limiter window to be worth anything.
269const RESERVATION_ATTEMPTS: usize = 7;
270const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
271    [500, 2_000, 5_000, 15_000, 30_000, 60_000];
272
273/// Answers a busy or briefly unhealthy hub gives, as opposed to a refusal that
274/// would repeat identically. A 4xx other than these states something about the
275/// request itself and is never retried.
276fn is_retryable_hub_status(status: u16) -> bool {
277    matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
278}
279
280/// Statuses worth replaying an immutable, content-addressed upload for: edge
281/// and object-store refusals that are load-shaped rather than a statement about
282/// these bytes. A checksum or precondition failure is never retried.
283fn is_retryable_upload_status(status: u16) -> bool {
284    matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
285}
286/// Cold checkouts may need every visible blob, but short-lived direct object
287/// capabilities must not create an unbounded connection storm. Keep the
288/// verified downloads inside this fixed worker count.
289const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
290/// File durability barriers are independent inside the private pull stage.
291/// Keep enough in flight to hide storage flush latency without approaching
292/// ordinary process descriptor limits.
293#[cfg(unix)]
294const V2_PULL_INSTALL_WORKERS: usize = 16;
295/// One authenticated link.md v2 bulk frame remains small enough for bounded
296/// retries and serverless streaming while amortizing tens of thousands of
297/// immutable blobs over a few hundred requests instead of one request each.
298const V2_BULK_STREAM_FILES: usize = 256;
299const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
300const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
301const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
302
303/// Everything that can go wrong on the wire or at its edges. Each variant maps
304/// onto one stable CLI error code; messages are single-line and never echo the
305/// credential.
306#[derive(Debug, thiserror::Error)]
307pub enum LinkError {
308    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
309    #[error(
310        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
311    )]
312    NoHub,
313
314    /// The verb needs a credential and none was present.
315    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
316    NoCredential,
317
318    /// The credential contains whitespace / non-ASCII (a paste artifact). The
319    /// key is deliberately not echoed.
320    #[error(
321        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
322    )]
323    BadKey,
324
325    /// A store selected the destination while an ambient credential was
326    /// present, but the operator did not bind that credential to the same
327    /// origin. This is a hard refusal, not an anonymous fallback: silently
328    /// dropping a credential can turn an intended private operation into a
329    /// confusing public one.
330    #[error(
331        "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}"
332    )]
333    UnboundCredential,
334
335    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
336    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
337    /// echoed.
338    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
339    BadAgentKey {
340        /// What failed, without any key material.
341        message: String,
342    },
343
344    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
345    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
346    UnsafeHub {
347        /// The offending hub URL.
348        hub: String,
349    },
350
351    /// TCP/TLS-level failure: the hub never answered.
352    #[error("hub unreachable at {hub}: {message}")]
353    Transport {
354        /// The hub base URL.
355        hub: String,
356        /// The transport-layer error text.
357        message: String,
358    },
359
360    /// The hub answered with an HTTP error status.
361    #[error("{what} failed (HTTP {status}): {message}")]
362    Http {
363        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
364        what: &'static str,
365        /// The HTTP status code.
366        status: u16,
367        /// The hub's own `error` string when it sent one, else a placeholder.
368        message: String,
369        /// The hub's machine `code` field when it sent one.
370        code: Option<String>,
371        /// Permission-filtered structured refusal details, when supplied.
372        details: Option<Value>,
373    },
374
375    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
376    /// URL — refused here rather than deserializing into nothing downstream.
377    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
378    NotJson {
379        /// What the client was doing.
380        what: &'static str,
381        /// The (2xx) status that carried the non-JSON body.
382        status: u16,
383    },
384
385    /// The hub response exceeded the selected endpoint's byte cap.
386    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
387    ResponseTooLarge {
388        /// The endpoint-specific cap applied before JSON parsing.
389        limit_bytes: u64,
390    },
391
392    /// A malformed `@brain/id` address.
393    #[error("invalid address `{given}`: {reason}")]
394    BadAddress {
395        /// The raw address as typed.
396        given: String,
397        /// Why it did not parse.
398        reason: String,
399    },
400
401    /// A grant id whose shape cannot travel as a URL path segment.
402    #[error(
403        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
404    )]
405    BadGrantId {
406        /// The raw id as typed.
407        given: String,
408    },
409
410    /// An exported file path that would escape or pollute the destination
411    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
412    /// hub is not trusted with local path layout.
413    #[error("refusing unsafe path from the hub: `{path}`")]
414    UnsafePath {
415        /// The offending path as received.
416        path: String,
417    },
418
419    /// The store exceeds the hub's bounded whole-snapshot caps.
420    #[error(
421        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
422        MAX_STORE_BYTES / (1024 * 1024),
423        MAX_PACK_BYTES / (1024 * 1024)
424    )]
425    PushTooLarge {
426        /// Which cap was hit, human-readable.
427        detail: String,
428    },
429
430    /// The propose body exceeds the hub's inbox cap.
431    #[error(
432        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
433        MAX_PROPOSE_BYTES / 1024
434    )]
435    ProposeTooLarge {
436        /// The offending body size in bytes.
437        bytes: u64,
438    },
439
440    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
441    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
442    NotUtf8 {
443        /// The store-relative path of the offending file.
444        path: String,
445    },
446
447    /// A downloaded pack failed validation before any local write.
448    #[error("invalid store pack: {message}")]
449    InvalidPack {
450        /// Hash, ZIP, path, count, or expansion failure.
451        message: String,
452    },
453
454    /// A signed feed entry, hash chain, or advertised feed head did not verify.
455    #[error("invalid signed feed: {message}")]
456    InvalidFeed {
457        /// The failed integrity condition, without untrusted secret material.
458        message: String,
459    },
460
461    /// A mutable alias moved to another canonical brain. Ordinary sync fails
462    /// closed; only the exact old/new ids can authorize replacing that one
463    /// alias binding while preserving both canonical trust histories.
464    #[error(
465        "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}`"
466    )]
467    AliasRebindRequired {
468        alias: String,
469        from: String,
470        to: String,
471    },
472
473    /// Local and remote both changed one or more coordinates since the last
474    /// verified sync baseline. No side was overwritten.
475    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
476    Conflict {
477        /// Bounded, portable paths safe to show to the agent/operator.
478        paths: Vec<String>,
479    },
480
481    /// A readable v2 conflict was preserved as a private, exact-head bundle.
482    /// The bundle is local control state; none of its bytes ride with the
483    /// brain, and resolving it always creates a fresh explicit mutation.
484    #[error(
485        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
486    )]
487    ConflictBundle {
488        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
489        bundle: String,
490        /// Bounded, readable conflict paths.
491        paths: Vec<String>,
492    },
493
494    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
495    /// explicit adoption boundary, never an accidental side effect of editing
496    /// or removing a local policy file.
497    #[error(
498        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
499    )]
500    LocalPolicyTransition {
501        /// Bounded local-only paths. They are never sent in telemetry.
502        paths: Vec<String>,
503    },
504
505    /// The exact mutation crosses a permissioned bulk-impact boundary. The
506    /// hub has not committed it; `preview` is the bounded, permission-filtered
507    /// receipt an agent must inspect before explicitly confirming the same
508    /// request.
509    #[error(
510        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
511    )]
512    BulkPreviewRequired {
513        /// Stable structured preview returned by the hub.
514        preview: Value,
515    },
516
517    /// A scoped checkout's generated store marker was edited or removed.
518    /// It is local projection metadata and is never accepted as brain data.
519    #[error(
520        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
521    )]
522    ScopedProjectionModified,
523
524    /// The effective permission slice changed after this checkout was pinned.
525    /// Reusing the directory could conceal removals or accidentally adopt files
526    /// revealed by a wider grant, so a new checkout is required.
527    #[error(
528        "the checkout's permission scope changed — clone into a new directory to accept the new view"
529    )]
530    ScopedViewChanged,
531
532    /// A v2 identity/head was previously accepted for this ref, but the hub
533    /// now hides it. Never reinterpret that as a v1 downgrade.
534    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
535    BrainUnavailable,
536
537    /// The verified remote head advanced while a pull or post-commit barrier
538    /// was in flight. The old baseline is deliberately retained.
539    #[error(
540        "the remote brain advanced during sync — retry to converge from the new verified head"
541    )]
542    RemoteAdvancedDuringSync,
543
544    /// This build cannot provide the no-follow, directory-handle-relative
545    /// filesystem semantics required for trust/key/snapshot state.
546    #[error(
547        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
548    )]
549    UnsupportedPlatform {
550        /// The operation that requires hardened local filesystem primitives.
551        operation: &'static str,
552    },
553
554    /// Local filesystem failure while materializing a pull or reading a push.
555    #[error(transparent)]
556    Io(#[from] std::io::Error),
557
558    /// A store-level failure (walking the local store for a push).
559    #[error(transparent)]
560    Store(#[from] crate::StoreError),
561}
562
563/// Result alias for link.md client operations.
564pub type LinkResult<T> = std::result::Result<T, LinkError>;
565
566/// Exact approval token returned by a permissioned v2 bulk preview.
567#[derive(Debug, Clone, PartialEq, Eq)]
568pub struct V2BulkConfirmation {
569    /// Opaque preview receipt id.
570    pub id: String,
571    /// Digest binding the receipt to principal, head, control revision, and
572    /// the exact strict mutation.
573    pub digest: String,
574}
575
576impl V2BulkConfirmation {
577    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
578    /// outside the protocol's exact lowercase shapes before network I/O.
579    pub fn parse(value: &str) -> LinkResult<Self> {
580        let (id, digest) = value
581            .split_once(':')
582            .ok_or_else(|| LinkError::InvalidPack {
583                message: "bulk confirmation must be <id>:<digest>".to_string(),
584            })?;
585        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
586            return Err(LinkError::InvalidPack {
587                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
588                    .to_string(),
589            });
590        }
591        Ok(Self {
592            id: id.to_string(),
593            digest: digest.to_string(),
594        })
595    }
596}
597
598/// Security-sensitive link.md state and destination writes require a hardened
599/// descriptor/handle-relative filesystem implementation. Unix uses
600/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
601/// refusal, exclusive share modes, and write-through replacement.
602fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
603    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
604    {
605        let _ = operation;
606        Ok(())
607    }
608    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
609    {
610        Err(LinkError::UnsupportedPlatform { operation })
611    }
612}
613
614// ─────────────────────────────────────────────────────────────────────────────
615// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
616// ─────────────────────────────────────────────────────────────────────────────
617
618/// What the part after `@brain/` names.
619#[derive(Debug, Clone, PartialEq, Eq)]
620pub enum AddressTarget {
621    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
622    Id(String),
623    /// A store-relative `.md` path — a client-side convenience the hub's
624    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
625    /// shape; unambiguous because a ULID is never a path.
626    Path(String),
627}
628
629/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
630/// and the per-verb entry gates so the two surfaces never drift.
631const BAD_BRAIN_REASON: &str =
632    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
633
634/// Why an address target failed its shape check — shared by [`Address::parse`]
635/// and the [`resolve`] entry gate.
636const BAD_TARGET_REASON: &str =
637    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
638
639/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
640/// brain's ULID id (works for any caller, including cross-party on a public
641/// brain) or a slug (which a hub resolves only against the caller's own
642/// brains; slugs are unique per owner, not globally).
643#[derive(Debug, Clone, PartialEq, Eq)]
644pub struct Address {
645    /// The brain reference (leading `@` stripped).
646    pub brain: String,
647    /// The record target, when the address names one.
648    pub target: Option<AddressTarget>,
649}
650
651impl Address {
652    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
653    /// sigil is optional (an agent piping ids around should not have to quote
654    /// it back on). Whitespace and empty segments are malformed.
655    pub fn parse(raw: &str) -> LinkResult<Address> {
656        let bad = |reason: &str| LinkError::BadAddress {
657            given: raw.to_string(),
658            reason: reason.to_string(),
659        };
660
661        let trimmed = raw.trim();
662        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
663        if body.is_empty() {
664            return Err(bad("empty address"));
665        }
666
667        let (brain, rest) = match body.split_once('/') {
668            Some((b, r)) => (b, Some(r)),
669            None => (body, None),
670        };
671
672        if brain.is_empty() {
673            return Err(bad("missing brain reference before `/`"));
674        }
675        if !is_safe_ref(brain) {
676            return Err(bad(BAD_BRAIN_REASON));
677        }
678
679        let target = match rest {
680            None => None,
681            Some("") => return Err(bad("trailing `/` with no record id or path")),
682            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
683            Some(r) => {
684                if !safe_store_rel_path(r) || !r.ends_with(".md") {
685                    return Err(bad(BAD_TARGET_REASON));
686                }
687                Some(AddressTarget::Path(r.to_string()))
688            }
689        };
690
691        Ok(Address {
692            brain: brain.to_string(),
693            target,
694        })
695    }
696}
697
698/// A brain reference safe to embed in a URL path segment: the shapes a hub
699/// accepts (ULID id or slug), which are also exactly URL-path-clean.
700fn is_safe_ref(s: &str) -> bool {
701    !s.is_empty()
702        && s.len() <= 64
703        && s.bytes()
704            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
705}
706
707/// A published-site handle (the `propose` target). Same lexical shape as a
708/// slug.
709pub fn is_valid_handle(s: &str) -> bool {
710    is_safe_ref(s)
711}
712
713/// True when `p` is a store-relative path this client will read from or write
714/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
715/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
716/// every path an export hands us (the hub is not trusted with local layout)
717/// and to every path a push sends (mirroring the hub's own gate).
718pub fn safe_store_rel_path(p: &str) -> bool {
719    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
720        return false;
721    }
722    if !p
723        .bytes()
724        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
725    {
726        return false;
727    }
728    p.split('/')
729        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
730}
731
732/// Entry gate for every verb that embeds a caller-supplied brain reference in
733/// a URL path segment. `resolve` reaches the same check through
734/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
735/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
736/// URL-reshaping byte is refused before a request exists (the `url` crate
737/// normalizes dot segments, so an unvalidated ref would redirect the
738/// authenticated request to a different hub path).
739fn require_safe_ref(brain: &str) -> LinkResult<()> {
740    if is_safe_ref(brain) {
741        Ok(())
742    } else {
743        Err(LinkError::BadAddress {
744            given: brain.to_string(),
745            reason: BAD_BRAIN_REASON.to_string(),
746        })
747    }
748}
749
750/// Entry gate for the published-site handle `propose` embeds in its URL path.
751fn require_valid_handle(handle: &str) -> LinkResult<()> {
752    if is_valid_handle(handle) {
753        Ok(())
754    } else {
755        Err(LinkError::BadAddress {
756            given: handle.to_string(),
757            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
758        })
759    }
760}
761
762/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
763/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
764/// shape as a brain ref rather than pinning one mint scheme.
765fn require_safe_grant_id(id: &str) -> LinkResult<()> {
766    if is_safe_ref(id) {
767        Ok(())
768    } else {
769        Err(LinkError::BadGrantId {
770            given: id.to_string(),
771        })
772    }
773}
774
775// ─────────────────────────────────────────────────────────────────────────────
776// Configuration — flag > env > .dbmd/config; credential from env only
777// ─────────────────────────────────────────────────────────────────────────────
778
779/// The resolved client configuration for one invocation.
780#[derive(Debug, Clone)]
781pub struct HubConfig {
782    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
783    pub hub: String,
784    /// The bearer credential, when the environment carries one.
785    pub key: Option<String>,
786    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
787    /// over the bearer for authenticated requests (link.md §8).
788    pub agent_key: Option<AgentSigningKey>,
789    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
790    /// names one — `sync --push` then signs feed entries locally (§2.4).
791    pub brain_key: Option<AgentSigningKey>,
792    /// User-owned global toolkit state root. Identity pins and monotonic feed
793    /// checkpoints live below `<state_dir>/trust/`, never under a store.
794    pub state_dir: PathBuf,
795    /// True only when the origin came from untrusted store-local configuration.
796    /// Such origins are resolved and public-IP-pinned for every request.
797    store_selected: bool,
798}
799
800/// A loaded agent signing key: the PKCS#8 secret plus its derived public
801/// multikey. Debug never prints key material.
802#[derive(Clone)]
803pub struct AgentSigningKey {
804    pkcs8: Vec<u8>,
805    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
806    pub multikey: String,
807    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
808    pub public_key_spki: String,
809}
810
811impl std::fmt::Debug for AgentSigningKey {
812    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
813        f.debug_struct("AgentSigningKey")
814            .field("multikey", &self.multikey)
815            .field("pkcs8", &"<redacted>")
816            .finish()
817    }
818}
819
820impl HubConfig {
821    /// The credential, or the canonical "not configured" error. Verbs that
822    /// authenticate call this; `propose` never does.
823    pub fn require_key(&self) -> LinkResult<&str> {
824        self.key.as_deref().ok_or(LinkError::NoCredential)
825    }
826}
827
828/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
829/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
830/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
831/// header token (never echoed on failure).
832pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
833    let explicit_hub = flag_hub
834        .map(str::to_string)
835        .or_else(|| env_nonempty(HUB_URL_ENV));
836    let selected_by_store = explicit_hub.is_none();
837    let hub = explicit_hub
838        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
839        .ok_or(LinkError::NoHub)?;
840    let hub = hub.trim().trim_end_matches('/').to_string();
841    assert_safe_hub(&hub)?;
842    if selected_by_store {
843        let parsed =
844            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
845        // A cloned store is not an operator opt-in to local-network access.
846        // Local/private development hubs must be selected explicitly by flag or
847        // environment, never by bytes inside the store.
848        if !parsed.scheme().eq_ignore_ascii_case("https")
849            || (parsed.path() != "/" && !parsed.path().is_empty())
850        {
851            return Err(LinkError::UnsafeHub { hub });
852        }
853    }
854
855    let key = match env_nonempty(HUB_KEY_ENV) {
856        Some(raw) => Some(clean_key(&raw)?),
857        None => None,
858    };
859
860    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
861        Some(path) => Some(load_agent_key(Path::new(&path))?),
862        None => None,
863    };
864
865    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
866        Some(path) => Some(load_agent_key(Path::new(&path))?),
867        None => None,
868    };
869
870    // A cloned store controls `.dbmd/config`. It must never be able to point
871    // the process at an attacker origin and harvest the user's ambient account
872    // bearer, signed agent identity, or self-custodied brain signatures/content.
873    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
874    // in the invocation environment; a store-selected target additionally
875    // needs an exact origin binding.
876    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
877        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
878            .and_then(|value| normalized_origin(&value).ok());
879        let selected_origin = normalized_origin(&hub)?;
880        if bound.as_deref() != Some(selected_origin.as_str()) {
881            return Err(LinkError::UnboundCredential);
882        }
883    }
884
885    Ok(HubConfig {
886        hub,
887        key,
888        agent_key,
889        brain_key,
890        state_dir: toolkit_state_dir()?,
891        store_selected: selected_by_store,
892    })
893}
894
895fn toolkit_state_dir() -> LinkResult<PathBuf> {
896    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
897        let path = PathBuf::from(path);
898        if !path.is_absolute() {
899            return Err(LinkError::UnsafePath {
900                path: path.display().to_string(),
901            });
902        }
903        return Ok(path);
904    }
905    #[cfg(windows)]
906    if let Some(base) = env_nonempty("LOCALAPPDATA") {
907        let base = PathBuf::from(base);
908        if base.is_absolute() {
909            return Ok(base.join("dbmd").join("state"));
910        }
911    }
912    #[cfg(windows)]
913    {
914        Err(LinkError::Io(std::io::Error::new(
915            std::io::ErrorKind::NotFound,
916            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
917        )))
918    }
919    #[cfg(not(windows))]
920    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
921        let base = PathBuf::from(base);
922        if base.is_absolute() {
923            return Ok(base.join("dbmd"));
924        }
925    }
926    #[cfg(not(windows))]
927    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
928        LinkError::Io(std::io::Error::new(
929            std::io::ErrorKind::NotFound,
930            format!("cannot locate user state; set {STATE_DIR_ENV}"),
931        ))
932    })?);
933    #[cfg(not(windows))]
934    if !home.is_absolute() {
935        return Err(LinkError::UnsafePath {
936            path: home.display().to_string(),
937        });
938    }
939    #[cfg(target_os = "macos")]
940    {
941        Ok(home
942            .join("Library")
943            .join("Application Support")
944            .join("dbmd")
945            .join("state"))
946    }
947    #[cfg(all(not(target_os = "macos"), not(windows)))]
948    {
949        Ok(home.join(".local").join("state").join("dbmd"))
950    }
951}
952
953fn normalized_origin(value: &str) -> LinkResult<String> {
954    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
955        hub: value.to_string(),
956    })?;
957    if !(parsed.scheme().eq_ignore_ascii_case("https")
958        || parsed.scheme().eq_ignore_ascii_case("http"))
959        || !parsed.username().is_empty()
960        || parsed.password().is_some()
961        || (parsed.path() != "/" && !parsed.path().is_empty())
962        || parsed.query().is_some()
963        || parsed.fragment().is_some()
964    {
965        return Err(LinkError::UnsafeHub {
966            hub: value.to_string(),
967        });
968    }
969    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
970        hub: value.to_string(),
971    })?;
972    let host = if host.contains(':') {
973        format!("[{host}]")
974    } else {
975        host.to_ascii_lowercase()
976    };
977    let port = parsed
978        .port_or_known_default()
979        .ok_or_else(|| LinkError::UnsafeHub {
980            hub: value.to_string(),
981        })?;
982    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
983        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
984    Ok(format!(
985        "{}://{}{}",
986        parsed.scheme().to_ascii_lowercase(),
987        host,
988        if default {
989            String::new()
990        } else {
991            format!(":{port}")
992        }
993    ))
994}
995
996// ─────────────────────────────────────────────────────────────────────────────
997// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
998// ─────────────────────────────────────────────────────────────────────────────
999
1000/// The DER prefix that wraps a raw Ed25519 public key into a
1001/// SubjectPublicKeyInfo (RFC 8410).
1002const ED25519_SPKI_PREFIX: [u8; 12] = [
1003    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1004];
1005
1006fn bad_agent_key(message: &str) -> LinkError {
1007    LinkError::BadAgentKey {
1008        message: message.to_string(),
1009    }
1010}
1011
1012fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1013    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
1014    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
1015    // deriving the public half itself.
1016    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1017        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1018        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1019}
1020
1021/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
1022fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1023    use ring::signature::KeyPair as _;
1024    let mut spki = Vec::with_capacity(44);
1025    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1026    spki.extend_from_slice(pair.public_key().as_ref());
1027    (
1028        URL_SAFE_NO_PAD.encode(&spki),
1029        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1030    )
1031}
1032
1033/// Load and validate a signing-key file (agent or brain — same format):
1034/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
1035/// old key explicitly.
1036pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1037    load_agent_key(path)
1038}
1039
1040/// Load and validate the agent key file: one base64url line of PKCS#8.
1041fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1042    #[cfg(unix)]
1043    let file = {
1044        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1045        use std::os::unix::ffi::OsStrExt as _;
1046        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1047            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1048        let leaf = path
1049            .file_name()
1050            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1051        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1052        let fd = unsafe {
1053            libc::openat(
1054                parent.as_raw_fd(),
1055                leaf.as_ptr(),
1056                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1057            )
1058        };
1059        if fd < 0 {
1060            return Err(bad_agent_key(
1061                "the key path must be an existing regular file without symlink ancestors",
1062            ));
1063        }
1064        unsafe { std::fs::File::from_raw_fd(fd) }
1065    };
1066    #[cfg(not(unix))]
1067    let file = std::fs::File::open(path)
1068        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1069    let metadata = file
1070        .metadata()
1071        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1072    if !metadata.is_file() {
1073        return Err(bad_agent_key("the key path must be a regular file"));
1074    }
1075    #[cfg(unix)]
1076    {
1077        use std::os::unix::fs::PermissionsExt as _;
1078        if metadata.permissions().mode() & 0o077 != 0 {
1079            return Err(bad_agent_key(
1080                "the key file is accessible to group/other; set mode 0600",
1081            ));
1082        }
1083    }
1084    let mut text = String::new();
1085    file.take(1024 * 1024 + 1)
1086        .read_to_string(&mut text)
1087        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1088    if text.len() > 1024 * 1024 {
1089        return Err(bad_agent_key("the key file exceeds the size limit"));
1090    }
1091    let pkcs8 = URL_SAFE_NO_PAD
1092        .decode(text.trim())
1093        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1094    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1095    Ok(AgentSigningKey {
1096        pkcs8,
1097        multikey,
1098        public_key_spki,
1099    })
1100}
1101
1102/// Durably create a new secret file without an exists→write race and without
1103/// ever exposing a default-mode (commonly 0644) key between write and chmod.
1104/// `create_new` also refuses a planted symlink. The file and its parent
1105/// directory are synced before the caller can publish the corresponding
1106/// public identity remotely.
1107fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1108    #[cfg(unix)]
1109    let (mut file, parent, leaf) = {
1110        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1111        use std::os::unix::ffi::OsStrExt as _;
1112        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1113        let leaf_name = path
1114            .file_name()
1115            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1116        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1117        let fd = unsafe {
1118            libc::openat(
1119                parent.as_raw_fd(),
1120                leaf.as_ptr(),
1121                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1122                0o600,
1123            )
1124        };
1125        if fd < 0 {
1126            let error = std::io::Error::last_os_error();
1127            if error.kind() == std::io::ErrorKind::AlreadyExists {
1128                return Err(bad_agent_key(
1129                    "the output file already exists — refusing to overwrite a key",
1130                ));
1131            }
1132            return Err(error.into());
1133        }
1134        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1135    };
1136    #[cfg(not(unix))]
1137    let mut file = std::fs::OpenOptions::new()
1138        .write(true)
1139        .create_new(true)
1140        .open(path)
1141        .map_err(|error| {
1142            if error.kind() == std::io::ErrorKind::AlreadyExists {
1143                bad_agent_key("the output file already exists — refusing to overwrite a key")
1144            } else {
1145                LinkError::Io(error)
1146            }
1147        })?;
1148    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1149        drop(file);
1150        #[cfg(unix)]
1151        let _ =
1152            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1153        #[cfg(not(unix))]
1154        let _ = std::fs::remove_file(path);
1155        return Err(LinkError::Io(error));
1156    }
1157    drop(file);
1158    #[cfg(unix)]
1159    parent.sync_all()?;
1160    Ok(())
1161}
1162
1163/// What `dbmd key generate` returns: the public identity to register plus
1164/// where the secret landed.
1165#[derive(Debug, Serialize)]
1166pub struct GeneratedAgentKey {
1167    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1168    pub multikey: String,
1169    /// base64url SPKI DER — what a hub's register endpoint takes.
1170    #[serde(rename = "publicKeySpki")]
1171    pub public_key_spki: String,
1172    /// Where the PKCS#8 secret was written (mode 0600).
1173    #[serde(rename = "keyFile")]
1174    pub key_file: String,
1175}
1176
1177/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1178/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1179/// identity is returned. The private key never enters a store and never
1180/// travels — requests carry per-request signatures instead (link.md §8).
1181pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1182    require_hardened_filesystem("key generation")?;
1183    let rng = ring::rand::SystemRandom::new();
1184    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1185        .map_err(|_| bad_agent_key("key generation failed"))?;
1186    let pair = agent_keypair(pkcs8.as_ref())?;
1187    let (spki_b64u, multikey) = public_identity_for(&pair);
1188
1189    write_secret_new(
1190        out,
1191        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1192    )?;
1193
1194    Ok(GeneratedAgentKey {
1195        multikey,
1196        public_key_spki: spki_b64u,
1197        key_file: out.display().to_string(),
1198    })
1199}
1200
1201/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1202/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1203/// (sha256hex(body) | "-")`.
1204///
1205/// The origin is derived from the already-validated hub URL, never from
1206/// attacker-controlled request metadata. Binding it closes the v1 replay
1207/// class where a proof captured by one hub could be replayed to another hub
1208/// serving the same path inside the timestamp window.
1209fn linkmd_sig_header(
1210    key: &AgentSigningKey,
1211    origin: &str,
1212    method: &str,
1213    path: &str,
1214    body: Option<&str>,
1215) -> LinkResult<String> {
1216    let ts = std::time::SystemTime::now()
1217        .duration_since(std::time::UNIX_EPOCH)
1218        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1219        .as_secs();
1220    let body_hash = match body {
1221        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1222        None => "-".to_string(),
1223    };
1224    let canonical = format!(
1225        "v2\n{}\n{}\n{}\n{}\n{}",
1226        origin,
1227        method.to_uppercase(),
1228        path,
1229        ts,
1230        body_hash
1231    );
1232    let pair = agent_keypair(&key.pkcs8)?;
1233    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1234    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1235    Ok(format!(
1236        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1237    ))
1238}
1239
1240// ─────────────────────────────────────────────────────────────────────────────
1241// Self-custody feed entries — the client signs what the hub only verifies
1242// ─────────────────────────────────────────────────────────────────────────────
1243
1244/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1245/// exactly this order).
1246#[derive(Serialize)]
1247struct WireFeedFile {
1248    path: String,
1249    sha256: String,
1250    bytes: u64,
1251}
1252
1253/// The unsigned entry in the normative §5.1 field order — serde serializes
1254/// struct fields in declaration order, which IS the wire contract.
1255#[derive(Serialize)]
1256struct UnsignedWireEntry<'a> {
1257    v: u8,
1258    seq: u64,
1259    ts: String,
1260    brain: &'a str,
1261    public_key: &'a str,
1262    kind: &'a str,
1263    op: &'a str,
1264    pack_sha256: &'a str,
1265    files: &'a [WireFeedFile],
1266    removed: &'a [String],
1267    prev_entry_hash: Option<&'a str>,
1268}
1269
1270/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1271/// brain key: serialize the unsigned entry compactly in the normative order,
1272/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1273/// returned string is the exact serialization the hub stores verbatim (plus
1274/// one trailing newline) and every independent reader re-derives.
1275fn self_custody_entry(
1276    key: &AgentSigningKey,
1277    seq: u64,
1278    ts: String,
1279    pack_sha256: &str,
1280    files: &[WireFeedFile],
1281    prev_entry_hash: Option<&str>,
1282) -> LinkResult<String> {
1283    let removed: [String; 0] = [];
1284    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1285        v: 1,
1286        seq,
1287        ts,
1288        brain: &key.multikey,
1289        public_key: &key.public_key_spki,
1290        kind: "push",
1291        op: "snapshot",
1292        pack_sha256,
1293        files,
1294        removed: &removed,
1295        prev_entry_hash,
1296    })
1297    .expect("serialize feed entry");
1298    let pair = agent_keypair(&key.pkcs8)?;
1299    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1300    Ok(format!(
1301        "{},\"sig\":\"{}\"}}",
1302        &unsigned[..unsigned.len() - 1],
1303        sig
1304    ))
1305}
1306
1307/// An env var, treated as absent when unset or empty (an empty
1308/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1309fn env_nonempty(name: &str) -> Option<String> {
1310    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1311}
1312
1313/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1314/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1315/// ignored (forward-compatible). A missing or unreadable file is simply "not
1316/// configured here".
1317fn config_file_hub(path: &Path) -> Option<String> {
1318    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1319    #[cfg(unix)]
1320    let file = {
1321        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1322        use std::os::unix::ffi::OsStrExt as _;
1323        let parent =
1324            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1325        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1326        let fd = unsafe {
1327            libc::openat(
1328                parent.as_raw_fd(),
1329                leaf.as_ptr(),
1330                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1331            )
1332        };
1333        if fd < 0 {
1334            return None;
1335        }
1336        unsafe { std::fs::File::from_raw_fd(fd) }
1337    };
1338    #[cfg(not(unix))]
1339    let file = std::fs::File::open(path).ok()?;
1340    let metadata = file.metadata().ok()?;
1341    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1342        return None;
1343    }
1344    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1345    file.take(MAX_CONFIG_BYTES + 1)
1346        .read_to_end(&mut bytes)
1347        .ok()?;
1348    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1349        return None;
1350    }
1351    let text = String::from_utf8(bytes).ok()?;
1352    for line in text.lines() {
1353        let line = line.trim();
1354        if line.is_empty() || line.starts_with('#') {
1355            continue;
1356        }
1357        if let Some((k, v)) = line.split_once('=') {
1358            if k.trim() == "hub" {
1359                let v = v.trim();
1360                if !v.is_empty() {
1361                    return Some(v.to_string());
1362                }
1363            }
1364        }
1365    }
1366    None
1367}
1368
1369/// The bearer key must never travel in cleartext; only loopback hosts may
1370/// skip TLS (local development against a hub on localhost).
1371fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1372    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1373        hub: hub.to_string(),
1374    })?;
1375    if !(parsed.scheme().eq_ignore_ascii_case("https")
1376        || parsed.scheme().eq_ignore_ascii_case("http"))
1377        || !parsed.username().is_empty()
1378        || parsed.password().is_some()
1379        || (parsed.path() != "/" && !parsed.path().is_empty())
1380        || parsed.query().is_some()
1381        || parsed.fragment().is_some()
1382    {
1383        return Err(LinkError::UnsafeHub {
1384            hub: hub.to_string(),
1385        });
1386    }
1387    let loopback = match parsed.host() {
1388        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1389        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1390        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1391        None => false,
1392    };
1393    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1394        Ok(())
1395    } else {
1396        Err(LinkError::UnsafeHub {
1397            hub: hub.to_string(),
1398        })
1399    }
1400}
1401
1402/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1403/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1404/// value tends to echo the whole header line, credential included, so the
1405/// gate sits here instead.
1406fn clean_key(raw: &str) -> LinkResult<String> {
1407    let k = raw.trim();
1408    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1409        return Err(LinkError::BadKey);
1410    }
1411    Ok(k.to_string())
1412}
1413
1414// ─────────────────────────────────────────────────────────────────────────────
1415// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1416// ─────────────────────────────────────────────────────────────────────────────
1417
1418/// One hub response: the status plus the parsed JSON body when there was one.
1419#[derive(Debug)]
1420pub struct HubResponse {
1421    /// The HTTP status code.
1422    pub status: u16,
1423    /// The parsed JSON body, `None` when the body was empty or not JSON.
1424    pub body: Option<Value>,
1425}
1426
1427struct RawHubResponse {
1428    status: u16,
1429    body: Vec<u8>,
1430}
1431
1432/// Whether a request carries the bearer credential.
1433#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1434enum Auth {
1435    /// Send `authorization: Bearer <key>`; error without a key.
1436    Required,
1437    /// Send no credential — the propose door is unauthenticated by design.
1438    None,
1439    /// Send the configured credential when one exists, otherwise nothing —
1440    /// brain-addressed propose works anonymously on public brains, and an
1441    /// authenticated caller earns a bigger actor-class budget.
1442    Optional,
1443}
1444
1445fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1446    ureq::AgentBuilder::new()
1447        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1448        // Never follow a redirect while a bearer, a store pack, or a signed
1449        // response is in flight. Callers see the 3xx as a non-success instead
1450        // of letting an origin steer sensitive material elsewhere.
1451        .redirects(0)
1452        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1453        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1454        .timeout_write(overall)
1455        .timeout(overall)
1456}
1457
1458fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1459    hub_agent_with_timeout(
1460        cfg,
1461        std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1462    )
1463}
1464
1465fn hub_agent_with_timeout(
1466    cfg: &HubConfig,
1467    overall: std::time::Duration,
1468) -> LinkResult<ureq::Agent> {
1469    if !cfg.store_selected {
1470        return Ok(agent_builder_with_timeout(overall).build());
1471    }
1472    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1473        hub: cfg.hub.clone(),
1474    })?;
1475    pinned_public_agent_pooled(
1476        &parsed,
1477        false,
1478        "store-selected hub",
1479        AgentShape {
1480            overall,
1481            ..AgentShape::default()
1482        },
1483    )
1484}
1485
1486/// Perform one hub request. `path` is the binding path (starts with `/`);
1487/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1488/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1489/// the [`HubResponse`] for [`ensure_ok`] to shape.
1490fn request_raw(
1491    cfg: &HubConfig,
1492    method: &str,
1493    path: &str,
1494    body: Option<&Value>,
1495    auth: Auth,
1496    max_response_bytes: u64,
1497) -> LinkResult<RawHubResponse> {
1498    let http = hub_agent(cfg)?;
1499    request_raw_with_agent(
1500        cfg,
1501        &http,
1502        method,
1503        path,
1504        body,
1505        RawRequestOptions {
1506            auth,
1507            max_response_bytes,
1508            request_id: None,
1509        },
1510    )
1511}
1512
1513struct RawRequestOptions<'a> {
1514    auth: Auth,
1515    max_response_bytes: u64,
1516    request_id: Option<&'a str>,
1517}
1518
1519fn request_raw_with_agent(
1520    cfg: &HubConfig,
1521    http: &ureq::Agent,
1522    method: &str,
1523    path: &str,
1524    body: Option<&Value>,
1525    options: RawRequestOptions<'_>,
1526) -> LinkResult<RawHubResponse> {
1527    let url = format!("{}{}", cfg.hub, path);
1528    let encoded_body = body.map(Value::to_string);
1529    let origin = normalized_origin(&cfg.hub)?;
1530    // An agent signing key outranks the bearer: possession proofs put nothing
1531    // reusable on the wire, so when both are configured the stronger one wins.
1532    let credential = match options.auth {
1533        Auth::Required => Some(match &cfg.agent_key {
1534            Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1535            None => format!("Bearer {}", cfg.require_key()?),
1536        }),
1537        Auth::Optional => match &cfg.agent_key {
1538            Some(key) => Some(linkmd_sig_header(
1539                key,
1540                &origin,
1541                method,
1542                path,
1543                encoded_body.as_deref(),
1544            )?),
1545            None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1546        },
1547        Auth::None => None,
1548    };
1549    let result = with_connect_retries(|| {
1550        let mut req = http.request(method, &url);
1551        if let Some(value) = &credential {
1552            req = req.set("authorization", value);
1553        }
1554        if let Some(value) = options.request_id {
1555            req = req.set("x-request-id", value);
1556        }
1557        match &encoded_body {
1558            Some(value) => req
1559                .set("content-type", "application/json")
1560                .send_string(value)
1561                .map_err(Box::new),
1562            None => req.call().map_err(Box::new),
1563        }
1564    });
1565    let resp = match result {
1566        Ok(resp) => resp,
1567        Err(error) => match *error {
1568            ureq::Error::Status(_, resp) => resp,
1569            ureq::Error::Transport(error) => {
1570                return Err(LinkError::Transport {
1571                    hub: cfg.hub.clone(),
1572                    message: error.to_string(),
1573                });
1574            }
1575        },
1576    };
1577
1578    let status = resp.status();
1579    let buf = read_response_body(resp, options.max_response_bytes + 1, &cfg.hub)?;
1580    if buf.len() as u64 > options.max_response_bytes {
1581        return Err(LinkError::ResponseTooLarge {
1582            limit_bytes: options.max_response_bytes,
1583        });
1584    }
1585    Ok(RawHubResponse { status, body: buf })
1586}
1587
1588fn request_capped(
1589    cfg: &HubConfig,
1590    method: &str,
1591    path: &str,
1592    body: Option<&Value>,
1593    auth: Auth,
1594    max_response_bytes: u64,
1595) -> LinkResult<HubResponse> {
1596    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1597    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1598    Ok(HubResponse {
1599        status: raw.status,
1600        body: parsed,
1601    })
1602}
1603
1604/// One request the hub is allowed to take brain-sized time over. Used for the
1605/// commit lane, where abandoning the attempt early does not cancel the work —
1606/// it only loses the receipt for it.
1607///
1608/// Which is why a lost connection is asked again rather than reported. The
1609/// mutation coordinate is derived from the observed base and the exact
1610/// operation set precisely so a repeat is the SAME effect: the hub answers a
1611/// mutation it already applied with that commit's receipt instead of a second
1612/// commit. Measured on a 32,338-file store: the commit landed durably and the
1613/// push reported a transport failure, because nobody went back for the
1614/// receipt.
1615fn request_patient(
1616    cfg: &HubConfig,
1617    method: &str,
1618    path: &str,
1619    body: Option<&Value>,
1620    auth: Auth,
1621) -> LinkResult<HubResponse> {
1622    let http = hub_agent_with_timeout(
1623        cfg,
1624        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1625    )?;
1626    let mut attempt = 0;
1627    loop {
1628        let sent = request_raw_with_agent(
1629            cfg,
1630            &http,
1631            method,
1632            path,
1633            body,
1634            RawRequestOptions {
1635                auth,
1636                max_response_bytes: MAX_RESPONSE_BYTES,
1637                request_id: None,
1638            },
1639        );
1640        match sent {
1641            Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1642                std::thread::sleep(std::time::Duration::from_millis(
1643                    COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1644                ));
1645                attempt += 1;
1646            }
1647            Err(error) => return Err(error),
1648            Ok(raw) => {
1649                return Ok(HubResponse {
1650                    status: raw.status,
1651                    body: serde_json::from_slice(&raw.body).ok(),
1652                })
1653            }
1654        }
1655    }
1656}
1657
1658fn request(
1659    cfg: &HubConfig,
1660    method: &str,
1661    path: &str,
1662    body: Option<&Value>,
1663    auth: Auth,
1664) -> LinkResult<HubResponse> {
1665    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1666}
1667
1668/// Repeat one challenge-response mutation with the exact request identity the
1669/// hub bound into its actor claim. A new request id would create a new actor
1670/// hash and therefore a different canonical commit candidate, making the
1671/// otherwise correct self-custody signature unusable.
1672fn request_with_request_id(
1673    cfg: &HubConfig,
1674    method: &str,
1675    path: &str,
1676    body: Option<&Value>,
1677    auth: Auth,
1678    request_id: &str,
1679) -> LinkResult<HubResponse> {
1680    if request_id.is_empty()
1681        || request_id.len() > 128
1682        || !request_id
1683            .bytes()
1684            .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1685    {
1686        return Err(invalid_feed("hub returned an unsafe request id"));
1687    }
1688    // Both callers are commit responses, so this shares the commit lane's
1689    // patience: the hub is finishing the same brain-sized work either way.
1690    let http = hub_agent_with_timeout(
1691        cfg,
1692        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1693    )?;
1694    let raw = request_raw_with_agent(
1695        cfg,
1696        &http,
1697        method,
1698        path,
1699        body,
1700        RawRequestOptions {
1701            auth,
1702            max_response_bytes: MAX_RESPONSE_BYTES,
1703            request_id: Some(request_id),
1704        },
1705    )?;
1706    Ok(HubResponse {
1707        status: raw.status,
1708        body: serde_json::from_slice(&raw.body).ok(),
1709    })
1710}
1711
1712fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1713    if (200..300).contains(&r.status) {
1714        return Ok(r.body);
1715    }
1716    ensure_ok(
1717        HubResponse {
1718            status: r.status,
1719            body: serde_json::from_slice(&r.body).ok(),
1720        },
1721        what,
1722    )
1723    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1724}
1725
1726/// These failures happen before any HTTP request reaches the hub, so retrying
1727/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1728/// bytes may have crossed the wire, the caller must rely on the verb's own
1729/// idempotency contract instead of guessing.
1730fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1731    matches!(
1732        kind,
1733        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1734    )
1735}
1736
1737fn with_connect_retries(
1738    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1739) -> Result<ureq::Response, Box<ureq::Error>> {
1740    let mut attempt = 0;
1741    loop {
1742        match send() {
1743            Err(error)
1744                if matches!(
1745                    error.as_ref(),
1746                    ureq::Error::Transport(transport)
1747                        if is_pre_request_transport(transport.kind())
1748                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1749            {
1750                std::thread::sleep(std::time::Duration::from_millis(
1751                    CONNECT_RETRY_BACKOFF_MS[attempt],
1752                ));
1753                attempt += 1;
1754            }
1755            result => return result,
1756        }
1757    }
1758}
1759
1760fn hub_is_loopback(hub: &str) -> bool {
1761    url::Url::parse(hub).ok().is_some_and(|parsed| {
1762        parsed.host().is_some_and(|host| match host {
1763            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1764            url::Host::Ipv4(ip) => ip.is_loopback(),
1765            url::Host::Ipv6(ip) => ip.is_loopback(),
1766        })
1767    })
1768}
1769
1770/// Every safety property a presigned object-store URL must carry, checked
1771/// before anything is sent to it. Split out from agent construction so a batch
1772/// can share one connection pool without ever skipping a per-URL check.
1773fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1774    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1775        message: "the hub returned an invalid object-store URL".to_string(),
1776    })?;
1777    let allow_private = hub_is_loopback(&cfg.hub)
1778        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1779    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1780        || !parsed.username().is_empty()
1781        || parsed.password().is_some()
1782        || parsed.fragment().is_some()
1783    {
1784        return Err(LinkError::InvalidPack {
1785            message: "the hub returned an unsafe object-store URL".to_string(),
1786        });
1787    }
1788    Ok((parsed, allow_private))
1789}
1790
1791fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1792    let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1793    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1794        LinkError::InvalidPack {
1795            message: "the hub returned an object-store URL with an unsafe network target"
1796                .to_string(),
1797        }
1798    })
1799}
1800
1801/// One pinned agent for a whole staging batch.
1802///
1803/// Every object in a batch is presigned against the same object-store
1804/// authority, so one agent can serve them all — and must: building one per
1805/// object costs a DNS resolution and a fresh TLS handshake per object, which is
1806/// the difference between a real store staging in minutes and in an hour. The
1807/// shared authority is verified rather than assumed, and a batch that ever
1808/// spans two of them falls back to a per-object agent.
1809fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1810    let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1811    let authority = (
1812        first.host_str()?.to_string(),
1813        first.port_or_known_default()?,
1814    );
1815    for raw in &urls[1..] {
1816        let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1817        if (parsed.host_str()?, parsed.port_or_known_default()?)
1818            != (authority.0.as_str(), authority.1)
1819        {
1820            return None;
1821        }
1822    }
1823    pinned_public_agent_pooled(
1824        &first,
1825        allow_private,
1826        "object-store URL",
1827        AgentShape {
1828            idle_per_host: V2_UPLOAD_CONCURRENCY,
1829            ..AgentShape::default()
1830        },
1831    )
1832    .ok()
1833}
1834
1835/// Turn an object-store transport failure into a useful but credential-safe
1836/// diagnostic. `ureq::Transport`'s Display implementation includes the full
1837/// request URL; for a presigned URL that means the short-lived signature and
1838/// every signed query coordinate. The error kind is enough to distinguish a
1839/// network failure without copying bearer material into terminals or logs.
1840fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1841    LinkError::Transport {
1842        hub: "the object store".to_string(),
1843        message: format!("network error ({:?})", error.kind()),
1844    }
1845}
1846
1847fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1848    let http = presigned_agent(cfg, raw)?;
1849    let deadline = std::time::Instant::now()
1850        .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1851        .ok_or_else(upload_deadline_error)?;
1852    let mut attempt = 0;
1853    let result = loop {
1854        // The reservation states content-length itself. This client sends the
1855        // body from memory, so it never needs to add one, and adding one under
1856        // a different capitalization would leave both on the wire.
1857        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1858        if let Some(map) = headers.as_object() {
1859            for (name, value) in map {
1860                if let Some(value) = value.as_str() {
1861                    req = req.set(name, value);
1862                }
1863            }
1864        }
1865        match req.send_bytes(bytes) {
1866            // An upload is idempotent here: fixed content address, exact
1867            // length and checksum, write-once precondition. A dropped
1868            // connection or a single overloaded refusal is worth replaying
1869            // rather than failing a whole migration on. 412 already means
1870            // "someone else stored these exact bytes".
1871            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1872                attempt += 1;
1873            }
1874            Err(ureq::Error::Status(status, _))
1875                if status != 412
1876                    && is_retryable_upload_status(status)
1877                    && wait_for_upload_retry(deadline, attempt) =>
1878            {
1879                attempt += 1;
1880            }
1881            result => break result,
1882        }
1883    };
1884    match result {
1885        Ok(resp) if (200..300).contains(&resp.status()) => {
1886            drain_presigned_response(resp);
1887            Ok(())
1888        }
1889        Ok(resp) => Err(presigned_upload_refusal(resp)),
1890        Err(error) => match error {
1891            // Immutable uploads use If-None-Match. A concurrent writer may
1892            // win the same content address; the commit/confirm endpoint reads
1893            // and hashes that object before accepting it, so 412 safely means
1894            // "continue to verification", never "trust the upload".
1895            ureq::Error::Status(412, _) => Ok(()),
1896            ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
1897            ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
1898        },
1899    }
1900}
1901
1902/// Read a response body, typed as the transport failure it is.
1903///
1904/// A connection that fails while the body is still arriving is the same class
1905/// of problem as one that fails before it arrives at all — but a bare `?` on
1906/// the read turned it into an anonymous io error that no retry path recognised
1907/// and no message attributed to a peer. On a migration making tens of
1908/// thousands of requests, one stalled body ended the whole push and reported
1909/// only "timed out reading response".
1910fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
1911    let mut buf = Vec::new();
1912    response
1913        .into_reader()
1914        .take(limit)
1915        .read_to_end(&mut buf)
1916        .map_err(|error| LinkError::Transport {
1917            hub: peer.to_string(),
1918            message: error.to_string(),
1919        })?;
1920    Ok(buf)
1921}
1922
1923/// Draining a response is what returns its connection to the pool: the client
1924/// cannot recycle a socket whose body may still have bytes pending, so a body
1925/// that is dropped instead of read costs the next object a fresh handshake.
1926/// An object-store PUT answers with almost nothing, so this is nearly free.
1927fn drain_presigned_response(response: ureq::Response) {
1928    let mut reader = response.into_reader().take(64 * 1024);
1929    let _ = std::io::copy(&mut reader, &mut std::io::sink());
1930}
1931
1932/// Carry the object store's own reason for a refusal. An unexplained status is
1933/// unactionable; the body states checksum mismatch, precondition, or expiry.
1934fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
1935    let status = response.status();
1936    let detail = response
1937        .into_string()
1938        .ok()
1939        .map(|body| body.chars().take(400).collect::<String>())
1940        .filter(|body| !body.trim().is_empty());
1941    LinkError::Http {
1942        what: "pack upload",
1943        status,
1944        message: match detail {
1945            Some(body) => format!(
1946                "object store rejected the upload: {}",
1947                body.replace('\n', " ")
1948            ),
1949            None => "object store rejected the upload".to_string(),
1950        },
1951        code: None,
1952        details: None,
1953    }
1954}
1955
1956fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1957    max_bytes.checked_add(1)
1958}
1959
1960fn presigned_download_read_limit() -> u64 {
1961    one_past_bounded_limit(MAX_PACK_BYTES)
1962        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1963}
1964
1965fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1966    let http = presigned_agent(cfg, raw)?;
1967    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1968        Ok(resp) => resp,
1969        Err(error) => match *error {
1970            ureq::Error::Status(_, resp) => {
1971                return Err(LinkError::Http {
1972                    what: "pack download",
1973                    status: resp.status(),
1974                    message: "object store rejected the download".to_string(),
1975                    code: None,
1976                    details: None,
1977                });
1978            }
1979            ureq::Error::Transport(err) => {
1980                return Err(LinkError::Transport {
1981                    hub: "the object store".to_string(),
1982                    message: err.to_string(),
1983                });
1984            }
1985        },
1986    };
1987    if !(200..300).contains(&resp.status()) {
1988        return Err(LinkError::Http {
1989            what: "pack download",
1990            status: resp.status(),
1991            message: "object store rejected the download".to_string(),
1992            code: None,
1993            details: None,
1994        });
1995    }
1996    let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
1997    if bytes.len() as u64 > MAX_PACK_BYTES {
1998        return Err(LinkError::InvalidPack {
1999            message: "download exceeds the compressed-size limit".to_string(),
2000        });
2001    }
2002    Ok(bytes)
2003}
2004
2005/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
2006/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
2007/// answer.
2008fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2009    if !(200..300).contains(&r.status) {
2010        let message = r
2011            .body
2012            .as_ref()
2013            .and_then(|b| b.get("error"))
2014            .and_then(Value::as_str)
2015            .unwrap_or("unknown error")
2016            .to_string();
2017        let code = r
2018            .body
2019            .as_ref()
2020            .and_then(|b| b.get("code"))
2021            .and_then(Value::as_str)
2022            .map(str::to_string);
2023        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2024        return Err(LinkError::Http {
2025            what,
2026            status: r.status,
2027            message,
2028            code,
2029            details,
2030        });
2031    }
2032    r.body.ok_or(LinkError::NotJson {
2033        what,
2034        status: r.status,
2035    })
2036}
2037
2038// ─────────────────────────────────────────────────────────────────────────────
2039// resolve — handle → brain card; @brain/id → the record
2040// ─────────────────────────────────────────────────────────────────────────────
2041
2042/// Resolve an address. A bare `@brain` returns the brain card (metadata +
2043/// index stats — the v0 form of the card; keys arrive with the protocol's
2044/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
2045/// record, frontmatter + body.
2046fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2047    match ip {
2048        std::net::IpAddr::V4(ip) => {
2049            let [a, b, c, _] = ip.octets();
2050            !(a == 0
2051                || a == 10
2052                || a == 127
2053                || (a == 100 && (64..=127).contains(&b))
2054                || (a == 169 && b == 254)
2055                || (a == 172 && (16..=31).contains(&b))
2056                || (a == 192 && b == 0 && c == 0)
2057                || (a == 192 && b == 0 && c == 2)
2058                || (a == 192 && b == 88 && c == 99)
2059                || (a == 192 && b == 168)
2060                || (a == 198 && (b == 18 || b == 19))
2061                || (a == 198 && b == 51 && c == 100)
2062                || (a == 203 && b == 0 && c == 113)
2063                || a >= 224)
2064        }
2065        std::net::IpAddr::V6(ip) => {
2066            let segments = ip.segments();
2067            // Conservatively accept only global unicast 2000::/3, excluding
2068            // special-purpose/transition blocks. In particular, 6to4 embeds
2069            // an IPv4 destination and must not tunnel a validated public
2070            // connect to 127/8 or RFC1918.
2071            (segments[0] & 0xe000) == 0x2000
2072                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2073                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2074                && segments[0] != 0x2002
2075                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2076        }
2077    }
2078}
2079
2080#[derive(Clone)]
2081struct PinnedRegistryResolver {
2082    netloc: String,
2083    addresses: Vec<std::net::SocketAddr>,
2084}
2085
2086impl ureq::Resolver for PinnedRegistryResolver {
2087    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2088        if requested == self.netloc {
2089            Ok(self.addresses.clone())
2090        } else {
2091            Err(std::io::Error::new(
2092                std::io::ErrorKind::PermissionDenied,
2093                "registry request attempted to resolve an unvalidated authority",
2094            ))
2095        }
2096    }
2097}
2098
2099fn pinned_public_agent(
2100    url: &url::Url,
2101    allow_private: bool,
2102    label: &str,
2103) -> LinkResult<ureq::Agent> {
2104    pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2105}
2106
2107/// How one agent is built: `idle_per_host` sizes the connection pool (the
2108/// default of one is right for a lone request but throttles a concurrent batch
2109/// back to a single warm connection), and `overall` is the wall-clock budget
2110/// for one attempt.
2111struct AgentShape {
2112    idle_per_host: usize,
2113    overall: std::time::Duration,
2114}
2115
2116impl Default for AgentShape {
2117    fn default() -> Self {
2118        Self {
2119            idle_per_host: 1,
2120            overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2121        }
2122    }
2123}
2124
2125fn pinned_public_agent_pooled(
2126    url: &url::Url,
2127    allow_private: bool,
2128    label: &str,
2129    shape: AgentShape,
2130) -> LinkResult<ureq::Agent> {
2131    let host = url
2132        .host_str()
2133        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2134    let port = url
2135        .port_or_known_default()
2136        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2137    let addresses = resolve_addresses_with_deadline(
2138        host,
2139        port,
2140        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2141    )
2142    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2143    if addresses.is_empty() {
2144        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2145    }
2146    if !allow_private
2147        && addresses
2148            .iter()
2149            .any(|address| !is_public_registry_ip(address.ip()))
2150    {
2151        return Err(invalid_feed(format!(
2152            "{label} resolves to a non-public address"
2153        )));
2154    }
2155    let netloc = if host.contains(':') {
2156        format!("[{host}]:{port}")
2157    } else {
2158        format!("{host}:{port}")
2159    };
2160    Ok(agent_builder_with_timeout(shape.overall)
2161        .max_idle_connections_per_host(shape.idle_per_host.max(1))
2162        .resolver(PinnedRegistryResolver { netloc, addresses })
2163        .build())
2164}
2165
2166/// Resolve one authority without allowing libc DNS to hold the caller forever.
2167/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
2168/// worker and only its result channel is awaited. A late resolver result has no
2169/// side effects and is discarded after the deadline.
2170fn resolve_addresses_with_deadline(
2171    host: &str,
2172    port: u16,
2173    timeout: std::time::Duration,
2174) -> std::io::Result<Vec<std::net::SocketAddr>> {
2175    use std::net::ToSocketAddrs as _;
2176
2177    let host = host.to_string();
2178    let (send, receive) = std::sync::mpsc::sync_channel(1);
2179    std::thread::Builder::new()
2180        .name("dbmd-dns".to_string())
2181        .spawn(move || {
2182            let result = (host.as_str(), port)
2183                .to_socket_addrs()
2184                .map(|addresses| addresses.collect());
2185            let _ = send.send(result);
2186        })
2187        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2188    match receive.recv_timeout(timeout) {
2189        Ok(result) => result,
2190        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2191            std::io::ErrorKind::TimedOut,
2192            "resolution exceeded its deadline",
2193        )),
2194        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2195            "resolver stopped without returning a result",
2196        )),
2197    }
2198}
2199
2200fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2201    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2202    pinned_public_agent(url, allow_private, "registry home")
2203}
2204
2205/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
2206/// from a FOREIGN home during registry resolution. DNS is resolved once,
2207/// every answer is required to be public, and that exact answer set is pinned
2208/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
2209fn get_json_absolute(url: &str) -> LinkResult<Value> {
2210    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2211    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2212    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2213        || !parsed.username().is_empty()
2214        || parsed.password().is_some()
2215        || parsed.query().is_some()
2216        || parsed.fragment().is_some()
2217    {
2218        return Err(invalid_feed("unsafe registry home URL"));
2219    }
2220    let http = registry_agent(&parsed)?;
2221    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2222        Ok(resp) => resp,
2223        Err(error) => match *error {
2224            ureq::Error::Status(status, resp) => {
2225                let _ = resp;
2226                return Err(LinkError::Http {
2227                    what: "registry home fetch",
2228                    status,
2229                    message: "the home node rejected the card request".to_string(),
2230                    code: None,
2231                    details: None,
2232                });
2233            }
2234            ureq::Error::Transport(err) => {
2235                return Err(LinkError::Transport {
2236                    hub: url.to_string(),
2237                    message: err.to_string(),
2238                });
2239            }
2240        },
2241    };
2242    if !(200..300).contains(&resp.status()) {
2243        return Err(LinkError::Http {
2244            what: "registry home fetch",
2245            status: resp.status(),
2246            message: "the home node returned a redirect or error".to_string(),
2247            code: None,
2248            details: None,
2249        });
2250    }
2251    let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2252    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2253        return Err(LinkError::ResponseTooLarge {
2254            limit_bytes: MAX_REGISTRY_CARD_BYTES,
2255        });
2256    }
2257    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2258        message: "the home node returned invalid JSON".to_string(),
2259    })
2260}
2261
2262/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
2263/// E5): look the handle up in the hub's registry, fetch the brain card from
2264/// the returned HOME node, and PIN — the card's identity fingerprint must
2265/// equal the registry's, or resolution fails. Returns the card enriched with
2266/// the resolved `home`, or `Ok(None)` when the registry has no such handle
2267/// (so the caller can fall back to a direct lookup).
2268pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2269    require_safe_ref(handle)?;
2270    // Hold the validated trust-directory capability before any network I/O.
2271    // Every lock/load/save below remains relative to this exact inode even if
2272    // an attacker swaps an ancestor while the registry or home is answering.
2273    let trust_directory = open_trust_dir(cfg)?;
2274    let reg = request_capped(
2275        cfg,
2276        "GET",
2277        &format!("/api/hub/registry/{handle}"),
2278        None,
2279        Auth::None,
2280        MAX_REGISTRY_CARD_BYTES,
2281    )?;
2282    if reg.status == 404 {
2283        return Ok(None);
2284    }
2285    let body = ensure_ok(reg, "registry resolve")?;
2286    let home = body
2287        .get("home")
2288        .and_then(Value::as_str)
2289        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2290    let brain = body
2291        .get("brain")
2292        .and_then(Value::as_str)
2293        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2294    if !crate::ulid::is_ulid(brain) {
2295        return Err(invalid_feed(
2296            "registry entry brain is not a canonical lowercase ULID",
2297        ));
2298    }
2299    let want_fp = body
2300        .get("identity")
2301        .and_then(|i| i.get("fingerprint"))
2302        .and_then(Value::as_str)
2303        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2304
2305    let home = home.trim_end_matches('/');
2306    let origin = normalized_origin(home)?;
2307    if origin != home {
2308        return Err(invalid_feed(
2309            "registry home must be an origin without a path, query, or fragment",
2310        ));
2311    }
2312    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2313    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2314    if let Some(binding) = &alias_binding {
2315        if binding
2316            .home
2317            .as_deref()
2318            .is_some_and(|pinned_home| pinned_home != home)
2319        {
2320            return Err(invalid_feed(
2321                "registry relocated a pinned handle to a different home",
2322            ));
2323        }
2324    }
2325    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2326    if card.get("id").and_then(Value::as_str) != Some(brain) {
2327        return Err(invalid_feed(
2328            "the home node served a card for a different brain",
2329        ));
2330    }
2331    let identity: FeedIdentity = serde_json::from_value(
2332        card.get("identity")
2333            .cloned()
2334            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2335    )
2336    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2337    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2338    let got_fp = card
2339        .get("identity")
2340        .and_then(|i| i.get("fingerprint"))
2341        .and_then(Value::as_str)
2342        .unwrap_or_default();
2343    if got_fp != want_fp {
2344        return Err(invalid_feed(
2345            "the home node served an identity that does not match the registry — refusing",
2346        ));
2347    }
2348    let current = format!("ed25519:{}", identity.fingerprint);
2349    let advertised_seq = card
2350        .get("headSeq")
2351        .and_then(Value::as_u64)
2352        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2353    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2354    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2355        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2356    {
2357        return Err(invalid_feed(
2358            "the home node served an invalid feed head boundary",
2359        ));
2360    }
2361    // This is required even on first contact. Otherwise a syntactically valid
2362    // identity may claim that a rotation happened after a head the home has
2363    // never reached, then become the permanent TOFU anchor.
2364    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2365    let registry_alias = AliasBinding {
2366        v: 1,
2367        origin: normalized_origin(&cfg.hub)?,
2368        requested: handle.to_string(),
2369        brain: brain.to_string(),
2370        home: Some(home.to_string()),
2371    };
2372    save_canonical_pin_and_alias(
2373        cfg,
2374        &trust_directory,
2375        handle,
2376        brain,
2377        TrustState {
2378            v: 2,
2379            origin: normalized_origin(&cfg.hub)?,
2380            requested: brain.to_string(),
2381            brain: brain.to_string(),
2382            home: None,
2383            anchor,
2384            current,
2385            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2386            feed_hash: pinned
2387                .as_ref()
2388                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2389            rotations: identity.rotations.clone(),
2390            hub_signer: None,
2391            protocol_profile: None,
2392        },
2393        Some(&registry_alias),
2394    )?;
2395    let mut out = card;
2396    if let Value::Object(map) = &mut out {
2397        map.insert("home".to_string(), Value::String(home.to_string()));
2398        map.insert(
2399            "resolvedVia".to_string(),
2400            Value::String("registry".to_string()),
2401        );
2402    }
2403    Ok(Some(out))
2404}
2405
2406pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2407    // `Address::parse` refuses these shapes already, but `Address` has public
2408    // fields — re-assert at the wire so a hand-built address can never
2409    // reshape the request path.
2410    require_safe_ref(&addr.brain)?;
2411    if let Some(target) = &addr.target {
2412        let (given, ok) = match target {
2413            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2414            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2415        };
2416        if !ok {
2417            return Err(LinkError::BadAddress {
2418                given: given.clone(),
2419                reason: BAD_TARGET_REASON.to_string(),
2420            });
2421        }
2422    }
2423
2424    // A record is never accepted from the hub's mutable query/index response.
2425    // Under v2, an id lookup may suggest a path, but the client accepts only
2426    // bytes whose exact path proof reaches the verified content root and whose
2427    // parsed id still matches. Under v1, the signed snapshot pack remains the
2428    // binding. Both profiles therefore reject a forged query response.
2429    if let Some(target) = &addr.target {
2430        if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2431            let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2432                what: "resolve",
2433                status: 404,
2434                message: "record not found".to_string(),
2435                code: Some("NOT_FOUND".to_string()),
2436                details: None,
2437            })?;
2438            let (path, file) = match target {
2439                AddressTarget::Path(path) => {
2440                    let file =
2441                        v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2442                            LinkError::Http {
2443                                what: "resolve",
2444                                status: 404,
2445                                message: "record not found".to_string(),
2446                                code: Some("NOT_FOUND".to_string()),
2447                                details: None,
2448                            }
2449                        })?;
2450                    (path.clone(), file)
2451                }
2452                AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2453            };
2454            let mut downloaded =
2455                download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2456            let (_, bytes) = downloaded
2457                .pop()
2458                .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2459            let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2460            accept_v2_head(cfg, &head)?;
2461            return Ok(resolved);
2462        }
2463        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2464        if !remote.head.verified {
2465            return Err(invalid_feed(
2466                "a path-scoped feed cannot prove a record against the full signed snapshot",
2467            ));
2468        }
2469        if remote.head.seq == 0 {
2470            return Err(LinkError::Http {
2471                what: "resolve",
2472                status: 404,
2473                message: "record not found".to_string(),
2474                code: Some("NOT_FOUND".to_string()),
2475                details: None,
2476            });
2477        }
2478        let brain = remote.head.brain.clone();
2479        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2480        return resolve_from_verified_pack(&brain, target, pack);
2481    }
2482
2483    let path = format!("/api/hub/brains/{}", addr.brain);
2484    // Direct first: the caller's own slug and hub-hosted public handles resolve
2485    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2486    // (404) falls through to the federation registry — how a handle reaches a
2487    // brain on ANOTHER node (link.md §7.1, E5).
2488    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2489    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2490        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2491            return Ok(card);
2492        }
2493    }
2494    let mut resolved = ensure_ok(direct, "resolve")?;
2495    if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2496        let v2 = v2_verified_head(cfg, &addr.brain)?
2497            .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2498        if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2499            return Err(invalid_feed(
2500                "resolve card is not bound to the verified v2 brain",
2501            ));
2502        }
2503        let card_identity: FeedIdentity = serde_json::from_value(
2504            resolved
2505                .get("identity")
2506                .cloned()
2507                .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2508        )
2509        .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2510        if card_identity != v2_identity(&v2.identity) {
2511            return Err(invalid_feed(
2512                "resolve card identity differs from the verified v2 identity",
2513            ));
2514        }
2515        accept_v2_head(cfg, &v2)?;
2516        if let Value::Object(card) = &mut resolved {
2517            card.insert(
2518                "headSeq".to_string(),
2519                json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2520            );
2521            card.insert(
2522                "feedHash".to_string(),
2523                v2.pointer
2524                    .as_ref()
2525                    .map(|pointer| Value::String(pointer.feed_hash.clone()))
2526                    .unwrap_or(Value::Null),
2527            );
2528            card.insert(
2529                "storageProfile".to_string(),
2530                Value::String("v2".to_string()),
2531            );
2532            if let Some(pointer) = &v2.pointer {
2533                card.insert(
2534                    "updatedAt".to_string(),
2535                    Value::String(pointer.signed_at.clone()),
2536                );
2537            }
2538        }
2539        return Ok(resolved);
2540    }
2541    // A successful direct response is accepted only after the same centralized
2542    // identity/rotation/feed checkpoint verification used by sync and
2543    // subscribe. No verb gets a weaker ad-hoc pinning path.
2544    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2545    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2546        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2547        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2548    {
2549        return Err(invalid_feed(
2550            "resolve card is not bound to the exact verified feed checkpoint",
2551        ));
2552    }
2553    let card_identity: FeedIdentity = serde_json::from_value(
2554        resolved
2555            .get("identity")
2556            .cloned()
2557            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2558    )
2559    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2560    if remote.identity.as_ref() != Some(&card_identity) {
2561        return Err(invalid_feed(
2562            "resolve card identity differs from the verified feed identity",
2563        ));
2564    }
2565    Ok(resolved)
2566}
2567
2568/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2569/// mutable query/index result is intentionally not consulted: a signed pack
2570/// digest is the only cryptographic binding between a feed checkpoint and
2571/// record bytes in wire profile v1.
2572fn resolve_from_verified_pack(
2573    brain: &str,
2574    target: &AddressTarget,
2575    pack: Vec<u8>,
2576) -> LinkResult<Value> {
2577    let entries = parse_store_pack(pack)?;
2578    let mut matched: Option<(String, Vec<u8>)> = None;
2579
2580    for (path, bytes) in entries {
2581        let is_candidate = match target {
2582            AddressTarget::Path(want) => &path == want,
2583            AddressTarget::Id(_) => {
2584                path.ends_with(".md")
2585                    && (path.starts_with("records/") || path.starts_with("sources/"))
2586            }
2587        };
2588        if !is_candidate {
2589            continue;
2590        }
2591        let text = std::str::from_utf8(&bytes)
2592            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2593        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2594            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2595        if let AddressTarget::Id(want) = target {
2596            let frontmatter =
2597                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2598                    .map_err(|_| {
2599                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2600                    })?;
2601            if frontmatter.id.as_deref() != Some(want) {
2602                continue;
2603            }
2604        }
2605        if matched.is_some() {
2606            return Err(invalid_feed(
2607                "signed snapshot contains more than one record for the requested target",
2608            ));
2609        }
2610        matched = Some((path, bytes));
2611    }
2612
2613    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2614        what: "resolve",
2615        status: 404,
2616        message: "record not found".to_string(),
2617        code: Some("NOT_FOUND".to_string()),
2618        details: None,
2619    })?;
2620    resolve_from_verified_record_bytes(brain, target, path, bytes)
2621}
2622
2623fn resolve_from_verified_record_bytes(
2624    brain: &str,
2625    target: &AddressTarget,
2626    path: String,
2627    bytes: Vec<u8>,
2628) -> LinkResult<Value> {
2629    match target {
2630        AddressTarget::Path(expected) if expected != &path => {
2631            return Err(invalid_feed(
2632                "verified record path differs from the requested path",
2633            ));
2634        }
2635        AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2636            return Err(invalid_feed(
2637                "verified id resolved outside records or sources",
2638            ));
2639        }
2640        _ => {}
2641    }
2642    let text = std::str::from_utf8(&bytes)
2643        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2644    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2645        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2646    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2647        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2648    let Value::Object(fields) = frontmatter else {
2649        return Err(invalid_feed(format!(
2650            "signed snapshot record `{path}` frontmatter is not a mapping"
2651        )));
2652    };
2653    if let AddressTarget::Id(expected) = target {
2654        if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2655            return Err(invalid_feed(
2656                "verified record id differs from the requested id",
2657            ));
2658        }
2659    }
2660    let mut document = serde_json::Map::new();
2661    document.insert("path".to_string(), Value::String(path));
2662    for (key, value) in fields {
2663        document.insert(key, value);
2664    }
2665    document.insert("body".to_string(), Value::String(parsed.body));
2666    document.insert(
2667        "contentSha".to_string(),
2668        Value::String(content_sha256(&bytes)),
2669    );
2670    Ok(json!({
2671        "brain": brain,
2672        "document": Value::Object(document),
2673    }))
2674}
2675
2676// ─────────────────────────────────────────────────────────────────────────────
2677// sync — pull the granted slice as files; push the local store as a snapshot
2678// ─────────────────────────────────────────────────────────────────────────────
2679
2680/// What a pull materialized.
2681#[derive(Debug, Clone, serde::Serialize)]
2682pub struct PullReport {
2683    /// The brain id the hub reported.
2684    pub brain: String,
2685    /// The brain's slug.
2686    pub slug: String,
2687    /// The hub's feed head at export time.
2688    #[serde(rename = "headSeq")]
2689    pub head_seq: u64,
2690    /// How many files were written.
2691    pub files: usize,
2692    /// Where they were written (as given or derived from the slug).
2693    pub dest: String,
2694    /// Local content files that the export did not carry — present so a
2695    /// caller sees divergence; nothing is ever deleted locally.
2696    #[serde(rename = "extraLocal")]
2697    pub extra_local: Vec<String>,
2698    /// Exact convergence result from the post-install barrier.
2699    #[serde(rename = "syncStatus")]
2700    pub sync_status: String,
2701}
2702
2703struct V2PulledSnapshot {
2704    report: PullReport,
2705    head: V2VerifiedHead,
2706    files: std::collections::BTreeMap<String, V2BaselineFile>,
2707    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2708    local: V2LocalView,
2709    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2710}
2711
2712fn download_verified_snapshot_pack(
2713    cfg: &HubConfig,
2714    brain: &str,
2715    remote: &VerifiedRemote,
2716) -> LinkResult<Vec<u8>> {
2717    let feed_hash = remote
2718        .head
2719        .feed_hash
2720        .as_deref()
2721        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2722    let signed_head = remote
2723        .head_entry
2724        .as_ref()
2725        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2726    let expected = &signed_head.entry.pack_sha256;
2727    if !is_sha256(expected) {
2728        return Err(invalid_feed(
2729            "signed head carries an invalid snapshot pack digest",
2730        ));
2731    }
2732    let path = format!(
2733        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2734        remote.head.seq
2735    );
2736    let body = ensure_ok(
2737        request(cfg, "GET", &path, None, Auth::Required)?,
2738        "sync pull",
2739    )?;
2740    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2741        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2742        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2743        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2744    {
2745        return Err(invalid_feed(
2746            "export response is not bound to the exact verified snapshot",
2747        ));
2748    }
2749    let url = body
2750        .get("url")
2751        .and_then(Value::as_str)
2752        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2753    let bytes = get_presigned(cfg, url)?;
2754    if content_sha256(&bytes) != *expected {
2755        return Err(LinkError::InvalidPack {
2756            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2757        });
2758    }
2759    let entries = parse_store_pack(bytes.clone())?;
2760    if signed_head.entry.kind == "push" {
2761        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2762    }
2763    Ok(bytes)
2764}
2765
2766#[derive(Debug, Clone, Deserialize, Serialize)]
2767struct V2PointerBody {
2768    v: u8,
2769    brain: String,
2770    seq: u64,
2771    commit_hash: String,
2772    feed_hash: String,
2773    content_root: Option<String>,
2774    asset_root: Option<String>,
2775    materializer: String,
2776    signer_epoch: u64,
2777    control_revision: String,
2778    backup_preparation: String,
2779    prior_pointer_hash: Option<String>,
2780    signed_at: String,
2781}
2782
2783#[derive(Debug, Clone, Deserialize)]
2784struct V2SignedPointer {
2785    pointer: V2PointerBody,
2786    hub_public_key: String,
2787    hub_fingerprint: String,
2788    sig: String,
2789}
2790
2791#[derive(Debug, Clone, Deserialize)]
2792struct V2HeadIdentity {
2793    #[serde(default)]
2794    custody: String,
2795    fingerprint: String,
2796    public_key_spki: String,
2797    #[serde(default)]
2798    previous: Vec<V2PreviousIdentity>,
2799    #[serde(default)]
2800    rotations: Vec<String>,
2801}
2802
2803#[derive(Debug, Clone, Deserialize)]
2804struct V2PreviousIdentity {
2805    fingerprint: String,
2806    public_key_spki: String,
2807}
2808
2809#[derive(Debug, Deserialize)]
2810struct V2HeadResponse {
2811    v: u8,
2812    brain_id: String,
2813    profile: String,
2814    view: Option<V2HeadView>,
2815    pointer: Option<V2SignedPointer>,
2816    identity: Option<V2HeadIdentity>,
2817}
2818
2819#[derive(Debug, Clone, Deserialize)]
2820struct V2HeadView {
2821    kind: String,
2822    #[serde(default)]
2823    id: Option<String>,
2824    control_revision: String,
2825}
2826
2827#[derive(Debug, Clone)]
2828struct V2VerifiedHead {
2829    requested: String,
2830    brain_id: String,
2831    view_kind: String,
2832    /// Stable identity of the exact current-content path projection.
2833    view_revision: String,
2834    /// Use-time authority revision independently bound into writes/signatures.
2835    control_revision: String,
2836    identity: V2HeadIdentity,
2837    pointer: Option<V2PointerBody>,
2838    trust: TrustState,
2839    alias: Option<AliasBinding>,
2840}
2841
2842fn verify_v2_spki_signature(
2843    public_key: &str,
2844    message: &[u8],
2845    signature: &str,
2846) -> LinkResult<Vec<u8>> {
2847    let der = URL_SAFE_NO_PAD
2848        .decode(public_key)
2849        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2850    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2851        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2852    }
2853    let sig = URL_SAFE_NO_PAD
2854        .decode(signature)
2855        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2856    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2857        .verify(message, &sig)
2858        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2859    Ok(der)
2860}
2861
2862fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2863    if pointer.pointer.v != 2
2864        || pointer.pointer.brain != expected_brain
2865        || pointer.pointer.seq == 0
2866        || !is_sha256(&pointer.pointer.commit_hash)
2867        || !is_sha256(&pointer.pointer.feed_hash)
2868        || pointer
2869            .pointer
2870            .content_root
2871            .as_deref()
2872            .is_some_and(|hash| !is_sha256(hash))
2873        || !is_sha256(&pointer.pointer.backup_preparation)
2874    {
2875        return Err(invalid_feed("v2 pointer fields are invalid"));
2876    }
2877    let value = serde_json::to_value(&pointer.pointer)
2878        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2879    let message = crate::linkmd_v2::canonical_bytes(&value)
2880        .map_err(|error| invalid_feed(error.to_string()))?;
2881    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2882    let fingerprint = format!("{:x}", Sha256::digest(&der));
2883    if fingerprint != pointer.hub_fingerprint {
2884        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2885    }
2886    Ok(format!(
2887        "{}:{}",
2888        pointer.hub_fingerprint, pointer.hub_public_key
2889    ))
2890}
2891
2892fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2893    FeedIdentity {
2894        fingerprint: identity.fingerprint.clone(),
2895        public_key_spki: identity.public_key_spki.clone(),
2896        previous: identity
2897            .previous
2898            .iter()
2899            .map(|previous| PreviousIdentity {
2900                fingerprint: previous.fingerprint.clone(),
2901                public_key_spki: previous.public_key_spki.clone(),
2902            })
2903            .collect(),
2904        rotations: identity.rotations.clone(),
2905    }
2906}
2907
2908fn verified_v2_commit_object(
2909    raw: &[u8],
2910    identity: &V2HeadIdentity,
2911) -> LinkResult<serde_json::Map<String, Value>> {
2912    let mut value: Value =
2913        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2914    let canonical = crate::linkmd_v2::canonical_bytes(&value)
2915        .map_err(|error| invalid_feed(error.to_string()))?;
2916    if canonical != raw {
2917        return Err(invalid_feed("v2 commit is not canonical JSON"));
2918    }
2919    let object = value
2920        .as_object_mut()
2921        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2922    let sig = object
2923        .remove("sig")
2924        .and_then(|value| value.as_str().map(str::to_string))
2925        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2926    const FIELDS: [&str; 18] = [
2927        "actor_ref",
2928        "asset_root",
2929        "brain",
2930        "changes_sha256",
2931        "control_revision",
2932        "materializer",
2933        "op",
2934        "parent_asset_root",
2935        "parent_commit",
2936        "parent_root",
2937        "prev_entry_hash",
2938        "public_key",
2939        "seq",
2940        "signer_epoch",
2941        "state_root",
2942        "ts",
2943        "v",
2944        "v1_bridge",
2945    ];
2946    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2947        return Err(invalid_feed("v2 commit has a non-normative field set"));
2948    }
2949    let seq = object
2950        .get("seq")
2951        .and_then(Value::as_u64)
2952        .filter(|seq| *seq > 0)
2953        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2954    let signer_epoch = object
2955        .get("signer_epoch")
2956        .and_then(Value::as_u64)
2957        .filter(|epoch| *epoch > 0)
2958        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2959    let hash_or_null = |field: &str| {
2960        object
2961            .get(field)
2962            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2963    };
2964    if object.get("v").and_then(Value::as_u64) != Some(2)
2965        || object.get("op").and_then(Value::as_str) != Some("changeset")
2966        || !object
2967            .get("changes_sha256")
2968            .and_then(Value::as_str)
2969            .is_some_and(is_sha256)
2970        || !object
2971            .get("actor_ref")
2972            .and_then(Value::as_str)
2973            .is_some_and(is_sha256)
2974        || !object
2975            .get("control_revision")
2976            .and_then(Value::as_str)
2977            .is_some_and(is_sha256)
2978        || !object
2979            .get("state_root")
2980            .and_then(Value::as_str)
2981            .is_some_and(is_sha256)
2982        || !hash_or_null("parent_commit")
2983        || !hash_or_null("parent_root")
2984        || !hash_or_null("parent_asset_root")
2985        || !hash_or_null("asset_root")
2986        || !hash_or_null("prev_entry_hash")
2987        || !object
2988            .get("materializer")
2989            .and_then(Value::as_str)
2990            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2991        || !object
2992            .get("ts")
2993            .and_then(Value::as_str)
2994            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
2995    {
2996        return Err(invalid_feed("v2 commit fields are invalid"));
2997    }
2998    if (seq == 1
2999        && [
3000            "parent_commit",
3001            "parent_root",
3002            "parent_asset_root",
3003            "prev_entry_hash",
3004        ]
3005        .iter()
3006        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3007        || (seq > 1
3008            && ["parent_commit", "parent_root", "prev_entry_hash"]
3009                .iter()
3010                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3011    {
3012        return Err(invalid_feed("v2 commit parent shape is invalid"));
3013    }
3014    match object.get("v1_bridge") {
3015        Some(Value::Null) => {}
3016        Some(Value::Object(bridge))
3017            if seq == 1
3018                && bridge.len() == 3
3019                && bridge
3020                    .get("head_seq")
3021                    .and_then(Value::as_u64)
3022                    .is_some_and(|v| v > 0)
3023                && bridge
3024                    .get("feed_hash")
3025                    .and_then(Value::as_str)
3026                    .is_some_and(is_sha256)
3027                && bridge
3028                    .get("pack_sha256")
3029                    .and_then(Value::as_str)
3030                    .is_some_and(is_sha256) => {}
3031        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3032    }
3033    let public_key = object
3034        .get("public_key")
3035        .and_then(Value::as_str)
3036        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3037    let der = URL_SAFE_NO_PAD
3038        .decode(public_key)
3039        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3040    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3041    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3042        return Err(invalid_feed("v2 commit brain identity mismatch"));
3043    }
3044    // Verify the old-key-signed chain before trusting any prior public key.
3045    verify_identity_chain(&v2_identity(identity), None)?;
3046    // Old commits remain verifiable after brain-key rotation. `previous` is
3047    // newest-first, while signer epochs and rotation statements are oldest-first.
3048    let mut chain: Vec<(&str, &str)> = identity
3049        .previous
3050        .iter()
3051        .rev()
3052        .map(|previous| {
3053            (
3054                previous.fingerprint.as_str(),
3055                previous.public_key_spki.as_str(),
3056            )
3057        })
3058        .collect();
3059    chain.push((&identity.fingerprint, &identity.public_key_spki));
3060    let signer_index = chain.iter().position(|(fingerprint, spki)| {
3061        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3062    });
3063    let Some(signer_index) = signer_index else {
3064        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3065    };
3066    if signer_epoch != signer_index as u64 + 1 {
3067        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3068    }
3069    let lower_boundary = if signer_index == 0 {
3070        None
3071    } else {
3072        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3073            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3074        Some(prior.prior_head_seq)
3075    };
3076    let upper_boundary = if signer_index == identity.rotations.len() {
3077        None
3078    } else {
3079        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3080            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3081        Some(next.prior_head_seq)
3082    };
3083    if lower_boundary.is_some_and(|boundary| seq <= boundary)
3084        || upper_boundary.is_some_and(|boundary| seq > boundary)
3085    {
3086        return Err(invalid_feed(
3087            "v2 commit signer is outside its authenticated rotation epoch",
3088        ));
3089    }
3090    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3091        .map_err(|error| invalid_feed(error.to_string()))?;
3092    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3093    Ok(object.clone())
3094}
3095
3096#[derive(Debug, Deserialize)]
3097struct V2FeedWireEntry {
3098    seq: u64,
3099    commit_hash: String,
3100    feed_hash: String,
3101    bytes_base64: String,
3102}
3103
3104#[derive(Debug, Deserialize)]
3105struct V2FeedPage {
3106    v: u8,
3107    head_seq: u64,
3108    head_commit_hash: String,
3109    head_feed_hash: String,
3110    entries: Vec<V2FeedWireEntry>,
3111    next_after: u64,
3112    complete: bool,
3113}
3114
3115fn replay_v2_feed(
3116    cfg: &HubConfig,
3117    brain: &str,
3118    pointer: &V2PointerBody,
3119    identity: &V2HeadIdentity,
3120    start_after: u64,
3121    start_feed: Option<String>,
3122) -> LinkResult<()> {
3123    let mut after = start_after;
3124    let mut prior_feed = start_feed;
3125    let mut final_object = None;
3126    let mut replayed_entries = 0_u64;
3127    let mut replayed_bytes = 0_u64;
3128    while after < pointer.seq {
3129        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3130        let value = ensure_ok(
3131            request_capped(
3132                cfg,
3133                "GET",
3134                &path,
3135                None,
3136                Auth::Required,
3137                MAX_FEED_REPLAY_BYTES,
3138            )?,
3139            "v2 feed replay",
3140        )?;
3141        let page: V2FeedPage = serde_json::from_value(value)
3142            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3143        if page.v != 2
3144            || page.head_seq != pointer.seq
3145            || page.head_commit_hash != pointer.commit_hash
3146            || page.head_feed_hash != pointer.feed_hash
3147            || page.entries.is_empty()
3148            || page.entries.len() > FEED_PAGE_LIMIT
3149        {
3150            return Err(invalid_feed("v2 feed page differs from the signed head"));
3151        }
3152        for entry in page.entries {
3153            if entry.seq != after + 1
3154                || !is_sha256(&entry.commit_hash)
3155                || !is_sha256(&entry.feed_hash)
3156            {
3157                return Err(invalid_feed("v2 feed sequence is not contiguous"));
3158            }
3159            let raw = base64::engine::general_purpose::STANDARD
3160                .decode(&entry.bytes_base64)
3161                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3162            replayed_entries = replayed_entries
3163                .checked_add(1)
3164                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3165            replayed_bytes = replayed_bytes
3166                .checked_add(raw.len() as u64)
3167                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3168            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3169            {
3170                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3171            }
3172            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3173                .map_err(|error| invalid_feed(error.to_string()))?
3174                != entry.commit_hash
3175                || content_sha256(&raw) != entry.feed_hash
3176            {
3177                return Err(invalid_feed("v2 feed entry address mismatch"));
3178            }
3179            let object = verified_v2_commit_object(&raw, identity)?;
3180            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3181                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3182            {
3183                return Err(invalid_feed(
3184                    "v2 feed entry does not extend its predecessor",
3185                ));
3186            }
3187            after = entry.seq;
3188            prior_feed = Some(entry.feed_hash);
3189            final_object = Some((entry.commit_hash, object));
3190        }
3191        if page.next_after != after || (page.complete != (after == pointer.seq)) {
3192            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3193        }
3194    }
3195    let (final_hash, object) =
3196        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3197    if final_hash != pointer.commit_hash
3198        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3199        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3200        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3201        || object.get("control_revision").and_then(Value::as_str)
3202            != Some(pointer.control_revision.as_str())
3203        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3204    {
3205        return Err(invalid_feed(
3206            "v2 replay did not converge on the signed pointer",
3207        ));
3208    }
3209    Ok(())
3210}
3211
3212fn verify_v1_to_v2_bridge(
3213    cfg: &HubConfig,
3214    brain: &str,
3215    pointer: &V2PointerBody,
3216    identity: &V2HeadIdentity,
3217    checkpoint: &TrustState,
3218) -> LinkResult<()> {
3219    let value = ensure_ok(
3220        request_capped(
3221            cfg,
3222            "GET",
3223            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3224            None,
3225            Auth::Required,
3226            MAX_FEED_RESPONSE_BYTES,
3227        )?,
3228        "v2 genesis bridge",
3229    )?;
3230    let page: V2FeedPage = serde_json::from_value(value)
3231        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3232    if page.v != 2
3233        || page.head_seq != pointer.seq
3234        || page.head_commit_hash != pointer.commit_hash
3235        || page.head_feed_hash != pointer.feed_hash
3236        || page.entries.len() != 1
3237        || page.entries[0].seq != 1
3238        || !is_sha256(&page.entries[0].commit_hash)
3239        || !is_sha256(&page.entries[0].feed_hash)
3240    {
3241        return Err(invalid_feed(
3242            "v2 genesis bridge page differs from the signed head",
3243        ));
3244    }
3245    let first = &page.entries[0];
3246    let raw = STANDARD
3247        .decode(&first.bytes_base64)
3248        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3249    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3250        .map_err(|error| invalid_feed(error.to_string()))?
3251        != first.commit_hash
3252        || content_sha256(&raw) != first.feed_hash
3253    {
3254        return Err(invalid_feed("v2 genesis bridge address mismatch"));
3255    }
3256    let object = verified_v2_commit_object(&raw, identity)?;
3257    if checkpoint.head_seq == 0 {
3258        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3259            return Err(invalid_feed(
3260                "empty v1 checkpoint did not transition through an empty v2 genesis",
3261            ));
3262        }
3263        return Ok(());
3264    }
3265    let bridge = object
3266        .get("v1_bridge")
3267        .and_then(Value::as_object)
3268        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3269    let checkpoint_feed = checkpoint
3270        .feed_hash
3271        .as_deref()
3272        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3273    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3274        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3275    {
3276        return Err(invalid_feed(
3277            "v2 genesis bridge differs from the pinned v1 checkpoint",
3278        ));
3279    }
3280    let legacy_raw = ensure_raw_ok(
3281        request_raw(
3282            cfg,
3283            "GET",
3284            &format!(
3285                "/api/hub/brains/{brain}/feed?after={}&limit=1",
3286                checkpoint.head_seq - 1
3287            ),
3288            None,
3289            Auth::Required,
3290            MAX_FEED_RESPONSE_BYTES,
3291        )?,
3292        "v1 bridge boundary",
3293    )?;
3294    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3295        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3296    let legacy_identity = legacy
3297        .identity
3298        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3299    let item = legacy
3300        .entries
3301        .first()
3302        .filter(|_| legacy.entries.len() == 1)
3303        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3304    if legacy.scope_limited
3305        || legacy.head_seq != checkpoint.head_seq
3306        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3307        || item.entry.seq != checkpoint.head_seq
3308        || item.hash != checkpoint_feed
3309        || legacy_identity != v2_identity(identity)
3310        || bridge.get("pack_sha256").and_then(Value::as_str)
3311            != Some(item.entry.pack_sha256.as_str())
3312    {
3313        return Err(invalid_feed(
3314            "v1 bridge boundary differs from its signed legacy head",
3315        ));
3316    }
3317    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3318    if anchor != checkpoint.anchor {
3319        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3320    }
3321    verify_feed_item(item, &legacy_identity)?;
3322    verify_rotation_feed_boundaries(
3323        &legacy_identity,
3324        Some(checkpoint),
3325        std::slice::from_ref(item),
3326        checkpoint.head_seq,
3327    )?;
3328    Ok(())
3329}
3330
3331fn verify_v2_commit(
3332    cfg: &HubConfig,
3333    brain: &str,
3334    pointer: &V2PointerBody,
3335    identity: &V2HeadIdentity,
3336    pinned: Option<&TrustState>,
3337) -> LinkResult<()> {
3338    let path = format!(
3339        "/api/hub/brains/{brain}/v2/commit?commit={}",
3340        pointer.commit_hash
3341    );
3342    let raw = ensure_raw_ok(
3343        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3344        "v2 commit",
3345    )?;
3346    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3347        .map_err(|error| invalid_feed(error.to_string()))?
3348        != pointer.commit_hash
3349        || content_sha256(&raw) != pointer.feed_hash
3350    {
3351        return Err(invalid_feed("v2 commit address differs from the pointer"));
3352    }
3353    let object = verified_v2_commit_object(&raw, identity)?;
3354    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3355        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3356        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3357        || object.get("control_revision").and_then(Value::as_str)
3358            != Some(pointer.control_revision.as_str())
3359        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3360    {
3361        return Err(invalid_feed("v2 commit fields differ from the pointer"));
3362    }
3363    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3364        if pointer.seq == checkpoint.head_seq + 1
3365            && object.get("prev_entry_hash").and_then(Value::as_str)
3366                != checkpoint.feed_hash.as_deref()
3367        {
3368            return Err(invalid_feed(
3369                "v2 commit does not extend the pinned feed hash",
3370            ));
3371        }
3372        if pointer.seq > checkpoint.head_seq + 1 {
3373            return replay_v2_feed(
3374                cfg,
3375                brain,
3376                pointer,
3377                identity,
3378                checkpoint.head_seq,
3379                checkpoint.feed_hash.clone(),
3380            );
3381        }
3382    } else {
3383        if let Some(checkpoint) = pinned {
3384            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3385        }
3386        if pointer.seq > 1 {
3387            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3388        }
3389    }
3390    Ok(())
3391}
3392
3393fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3394    require_hardened_filesystem("verified link.md v2 state")?;
3395    require_safe_ref(brain)?;
3396    // Open and pin the private trust directory before a credential can leave
3397    // the process. A hostile checkout must not turn an unsafe state root into
3398    // a network oracle or receive a bearer before checkpoint safety is known.
3399    let trust_directory = open_trust_dir(cfg)?;
3400    let path = format!("/api/hub/brains/{brain}/v2/head");
3401    let response = request(cfg, "GET", &path, None, Auth::Required)?;
3402    if response.status == 404 {
3403        if has_accepted_v2_ref(cfg, brain)? {
3404            return Err(LinkError::BrainUnavailable);
3405        }
3406        return Ok(None);
3407    }
3408    let body = ensure_ok(response, "v2 head")?;
3409    let head: V2HeadResponse = serde_json::from_value(body)
3410        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3411    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3412        return Err(invalid_feed("v2 head has no canonical brain id"));
3413    }
3414    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3415        return Err(invalid_feed("v2 head resolved a different brain id"));
3416    }
3417    if head.profile == "v1" {
3418        return Ok(None);
3419    }
3420    if head.profile != "v2" && head.profile != "v2-empty" {
3421        return Err(invalid_feed("v2 head advertised an unknown profile"));
3422    }
3423    let view = head
3424        .view
3425        .as_ref()
3426        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3427    if !matches!(view.kind.as_str(), "full" | "scoped")
3428        || !is_sha256(&view.control_revision)
3429        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3430    {
3431        return Err(invalid_feed("v2 head has an invalid permission view"));
3432    }
3433    let view_kind = view.kind.clone();
3434    // `id` is the stable materialized namespace. Older v2 hubs did not expose
3435    // it, so retain the fail-closed control-revision fallback for compatibility.
3436    let view_revision = view
3437        .id
3438        .clone()
3439        .unwrap_or_else(|| view.control_revision.clone());
3440    let control_revision = view.control_revision.clone();
3441    let identity = head
3442        .identity
3443        .as_ref()
3444        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3445    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3446    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3447    let feed_identity = v2_identity(identity);
3448    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3449    let (seq, feed_hash, hub_signer) = match &head.pointer {
3450        None => {
3451            if head.profile != "v2-empty" {
3452                return Err(invalid_feed("initialized v2 head has no pointer"));
3453            }
3454            (
3455                0,
3456                None,
3457                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3458            )
3459        }
3460        Some(signed) => {
3461            let signer = verify_v2_pointer(signed, &head.brain_id)?;
3462            if pinned
3463                .as_ref()
3464                .and_then(|state| state.hub_signer.as_ref())
3465                .is_some_and(|known| known != &signer)
3466            {
3467                return Err(invalid_feed(
3468                    "v2 hub pointer signer changed without a trust transition",
3469                ));
3470            }
3471            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3472                if signed.pointer.seq < checkpoint.head_seq
3473                    || (signed.pointer.seq == checkpoint.head_seq
3474                        && checkpoint.feed_hash.as_deref()
3475                            != Some(signed.pointer.feed_hash.as_str()))
3476                {
3477                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3478                }
3479            }
3480            verify_v2_commit(
3481                cfg,
3482                &head.brain_id,
3483                &signed.pointer,
3484                identity,
3485                pinned.as_ref(),
3486            )?;
3487            (
3488                signed.pointer.seq,
3489                Some(signed.pointer.feed_hash.clone()),
3490                Some(signer),
3491            )
3492        }
3493    };
3494    let trust = TrustState {
3495        v: 2,
3496        origin: normalized_origin(&cfg.hub)?,
3497        requested: head.brain_id.clone(),
3498        brain: head.brain_id.clone(),
3499        home: None,
3500        anchor,
3501        current: format!("ed25519:{}", identity.fingerprint),
3502        head_seq: seq,
3503        feed_hash,
3504        rotations: identity.rotations.clone(),
3505        hub_signer,
3506        protocol_profile: Some("link-v2".to_string()),
3507    };
3508    Ok(Some(V2VerifiedHead {
3509        requested: brain.to_string(),
3510        brain_id: head.brain_id,
3511        view_kind,
3512        view_revision,
3513        control_revision,
3514        identity: identity.clone(),
3515        pointer: head.pointer.map(|signed| signed.pointer),
3516        trust,
3517        alias: alias_binding,
3518    }))
3519}
3520
3521fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3522    let directory = open_trust_dir(cfg)?;
3523    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3524    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3525    if let Some(current) = current {
3526        let common_invalid = head.trust.anchor != current.anchor
3527            || !head.trust.rotations.starts_with(&current.rotations);
3528        let profile_invalid = if accepted_as_v2(&current) {
3529            head.trust.head_seq < current.head_seq
3530                || (head.trust.head_seq == current.head_seq
3531                    && head.trust.feed_hash != current.feed_hash)
3532                || current
3533                    .hub_signer
3534                    .as_ref()
3535                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3536        } else {
3537            head.trust.protocol_profile.as_deref() != Some("link-v2")
3538                || head.trust.hub_signer.is_none()
3539        };
3540        if common_invalid || profile_invalid {
3541            return Err(invalid_feed(
3542                "v2 head cannot advance the currently accepted trust checkpoint",
3543            ));
3544        }
3545    }
3546    save_canonical_pin_and_alias(
3547        cfg,
3548        &directory,
3549        &head.requested,
3550        &head.brain_id,
3551        head.trust.clone(),
3552        alias.as_ref().or(head.alias.as_ref()),
3553    )
3554}
3555
3556#[derive(Debug, Clone, Deserialize, Serialize)]
3557struct V2BaselineFile {
3558    sha256: String,
3559    bytes: u64,
3560    #[serde(skip)]
3561    proof: Option<Vec<V2ProofStep>>,
3562}
3563
3564#[derive(Debug, Clone, Deserialize, Serialize)]
3565struct V2SyncBaseline {
3566    v: u8,
3567    origin: String,
3568    brain: String,
3569    #[serde(default)]
3570    checkout_id: Option<String>,
3571    #[serde(default)]
3572    head_seq: Option<u64>,
3573    commit_hash: Option<String>,
3574    content_root: Option<String>,
3575    #[serde(default)]
3576    asset_root: Option<String>,
3577    #[serde(default)]
3578    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3579    #[serde(default)]
3580    view_kind: Option<String>,
3581    #[serde(default)]
3582    view_revision: Option<String>,
3583    #[serde(default)]
3584    projection_sha256: Option<String>,
3585    files: std::collections::BTreeMap<String, V2BaselineFile>,
3586    #[serde(default)]
3587    local_policy_digest: Option<String>,
3588    #[serde(default)]
3589    local_eligibility: std::collections::BTreeMap<String, bool>,
3590    #[serde(default)]
3591    remote_copy_remains: std::collections::BTreeMap<String, String>,
3592}
3593
3594struct V2LocalView {
3595    riding: std::collections::BTreeMap<String, (String, u64)>,
3596    eligibility: std::collections::BTreeMap<String, bool>,
3597    policy: crate::linkmd_sync_policy::SyncPolicy,
3598    withheld_links: Vec<V2WithheldLink>,
3599}
3600
3601#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3602struct V2WithheldLink {
3603    source: String,
3604    target: String,
3605}
3606
3607#[derive(Debug, Clone, Deserialize, Serialize)]
3608struct V2ProofStep {
3609    directory_root: String,
3610    component: String,
3611    proof: crate::linkmd_v2::HamtProof,
3612}
3613
3614#[derive(Debug, Deserialize)]
3615struct V2ManifestFile {
3616    path: String,
3617    sha256: String,
3618    bytes: u64,
3619    proof: Vec<V2ProofStep>,
3620}
3621
3622#[derive(Debug, Deserialize)]
3623struct V2ManifestPage {
3624    v: u8,
3625    commit: String,
3626    content_root: Option<String>,
3627    files: Vec<V2ManifestFile>,
3628    next_cursor: Option<String>,
3629}
3630
3631#[derive(Debug, Clone, Deserialize, Serialize)]
3632struct V2BaselineAsset {
3633    blob_sha256: String,
3634    bytes: u64,
3635    media_type: String,
3636    wrappers: Vec<String>,
3637    required: bool,
3638    disposition: String,
3639    leaf_hash: String,
3640}
3641
3642#[derive(Debug, Deserialize)]
3643struct V2AssetManifestItem {
3644    path: String,
3645    blob_sha256: String,
3646    bytes: u64,
3647    media_type: String,
3648    wrappers: Vec<String>,
3649    required: bool,
3650    disposition: String,
3651    leaf_hash: String,
3652    proof: crate::linkmd_v2::HamtProof,
3653}
3654
3655#[derive(Debug, Deserialize)]
3656struct V2AssetManifestPage {
3657    v: u8,
3658    commit: String,
3659    asset_root: Option<String>,
3660    assets: Vec<V2AssetManifestItem>,
3661    next_cursor: Option<String>,
3662}
3663
3664#[derive(Debug, Deserialize)]
3665struct V2SigningCandidate {
3666    seq: u64,
3667    content_root: Option<String>,
3668    asset_root: Option<String>,
3669    signing_bytes_base64: String,
3670    changes_base64: String,
3671    actor_claim_base64: String,
3672}
3673
3674#[derive(Debug, Deserialize)]
3675struct V2SigningCandidatePage {
3676    v: u8,
3677    challenge_id: String,
3678    mutation_id: String,
3679    request_hash: String,
3680    parent: V2SigningParent,
3681    candidate: V2SigningCandidate,
3682    files: Vec<V2ManifestFile>,
3683    #[serde(default)]
3684    assets: Vec<V2AssetManifestItem>,
3685    next_cursor: Option<String>,
3686    expires_at: String,
3687}
3688
3689#[derive(Debug, Deserialize)]
3690struct V2SigningParent {
3691    seq: u64,
3692    commit_hash: Option<String>,
3693}
3694
3695fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3696    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3697        .map_err(|error| invalid_feed(error.to_string()))?;
3698    let components = normalized.split('/').collect::<Vec<_>>();
3699    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3700        return Err(invalid_feed("v2 file proof has the wrong shape"));
3701    }
3702    let mut directory_root = root.to_string();
3703    for (index, step) in file.proof.iter().enumerate() {
3704        if step.directory_root != directory_root || step.component != components[index] {
3705            return Err(invalid_feed(
3706                "v2 file proof path chain differs from its manifest",
3707            ));
3708        }
3709        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3710            .map_err(|error| invalid_feed(error.to_string()))?
3711        {
3712            return Err(invalid_feed("v2 file proof failed verification"));
3713        }
3714        let entry = match &step.proof {
3715            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3716            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3717                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3718            }
3719        };
3720        if index + 1 == components.len() {
3721            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3722                || entry.child_hash != file.sha256
3723                || entry.bytes != Some(file.bytes)
3724            {
3725                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3726            }
3727        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3728            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3729        } else {
3730            directory_root = entry.child_hash.clone();
3731        }
3732    }
3733    Ok(())
3734}
3735
3736fn v2_manifest(
3737    cfg: &HubConfig,
3738    brain: &str,
3739    pointer: Option<&V2PointerBody>,
3740) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3741    let Some(pointer) = pointer else {
3742        return Ok(std::collections::BTreeMap::new());
3743    };
3744    let Some(root) = pointer.content_root.as_deref() else {
3745        return Ok(std::collections::BTreeMap::new());
3746    };
3747    let mut files = std::collections::BTreeMap::new();
3748    let mut after = String::new();
3749    loop {
3750        let encoded_after: String =
3751            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3752        let path = format!(
3753            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3754            pointer.commit_hash
3755        );
3756        let value = ensure_ok(
3757            request_capped(
3758                cfg,
3759                "GET",
3760                &path,
3761                None,
3762                Auth::Required,
3763                MAX_FEED_RESPONSE_BYTES,
3764            )?,
3765            "v2 file manifest",
3766        )?;
3767        let page: V2ManifestPage = serde_json::from_value(value)
3768            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3769        if page.v != 2
3770            || page.commit != pointer.commit_hash
3771            || page.content_root.as_deref() != Some(root)
3772            || page.files.len() > 500
3773        {
3774            return Err(invalid_feed(
3775                "v2 file manifest is not bound to the verified head",
3776            ));
3777        }
3778        for file in page.files {
3779            verify_v2_file_proof(root, &file)?;
3780            if files
3781                .insert(
3782                    file.path.clone(),
3783                    V2BaselineFile {
3784                        sha256: file.sha256,
3785                        bytes: file.bytes,
3786                        proof: Some(file.proof),
3787                    },
3788                )
3789                .is_some()
3790            {
3791                return Err(invalid_feed("v2 file manifest repeats a path"));
3792            }
3793            if files.len() > MAX_PUSH_FILES {
3794                return Err(invalid_feed(
3795                    "v2 file manifest exceeds the file-count bound",
3796                ));
3797            }
3798        }
3799        match page.next_cursor {
3800            None => break,
3801            Some(next) if next > after => after = next,
3802            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3803        }
3804    }
3805    Ok(files)
3806}
3807
3808/// Fetch one exact path proof instead of enumerating the whole permission
3809/// view. This keeps `resolve @brain/path` proportional to path depth even for
3810/// a very large brain. The returned leaf is accepted only after its complete
3811/// proof chain reaches the content root signed by `pointer`.
3812fn v2_manifest_file(
3813    cfg: &HubConfig,
3814    brain: &str,
3815    pointer: &V2PointerBody,
3816    path: &str,
3817) -> LinkResult<Option<V2BaselineFile>> {
3818    let Some(root) = pointer.content_root.as_deref() else {
3819        return Ok(None);
3820    };
3821    crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3822        path: error.to_string(),
3823    })?;
3824    let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3825    let value = ensure_ok(
3826        request_capped(
3827            cfg,
3828            "GET",
3829            &format!(
3830                "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3831                pointer.commit_hash
3832            ),
3833            None,
3834            Auth::Required,
3835            MAX_FEED_RESPONSE_BYTES,
3836        )?,
3837        "v2 exact file proof",
3838    )?;
3839    let mut page: V2ManifestPage = serde_json::from_value(value)
3840        .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3841    if page.v != 2
3842        || page.commit != pointer.commit_hash
3843        || page.content_root.as_deref() != Some(root)
3844        || page.next_cursor.is_some()
3845        || page.files.len() != 1
3846        || page.files[0].path != path
3847    {
3848        return Err(invalid_feed(
3849            "v2 exact file proof is not bound to the requested signed path",
3850        ));
3851    }
3852    let file = page.files.pop().expect("exactly one file was checked");
3853    verify_v2_file_proof(root, &file)?;
3854    Ok(Some(V2BaselineFile {
3855        sha256: file.sha256,
3856        bytes: file.bytes,
3857        proof: Some(file.proof),
3858    }))
3859}
3860
3861/// Resolve an id through the hub's synchronously maintained, content-light
3862/// validation certificate, then require the same exact signed path proof as a
3863/// path lookup. The certificate is only an efficient locator: the downloaded
3864/// bytes are parsed again and their id is checked by the caller.
3865fn v2_manifest_file_by_id(
3866    cfg: &HubConfig,
3867    brain: &str,
3868    pointer: &V2PointerBody,
3869    id: &str,
3870) -> LinkResult<(String, V2BaselineFile)> {
3871    let root = pointer
3872        .content_root
3873        .as_deref()
3874        .ok_or_else(|| LinkError::Http {
3875            what: "resolve",
3876            status: 404,
3877            message: "record not found".to_string(),
3878            code: Some("NOT_FOUND".to_string()),
3879            details: None,
3880        })?;
3881    if !crate::ulid::is_ulid(id) {
3882        return Err(LinkError::BadAddress {
3883            given: id.to_string(),
3884            reason: BAD_TARGET_REASON.to_string(),
3885        });
3886    }
3887    let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3888    let value = ensure_ok(
3889        request_capped(
3890            cfg,
3891            "GET",
3892            &format!(
3893                "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3894                pointer.commit_hash
3895            ),
3896            None,
3897            Auth::Required,
3898            MAX_FEED_RESPONSE_BYTES,
3899        )?,
3900        "v2 exact id proof",
3901    )?;
3902    let mut page: V2ManifestPage = serde_json::from_value(value)
3903        .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3904    if page.v != 2
3905        || page.commit != pointer.commit_hash
3906        || page.content_root.as_deref() != Some(root)
3907        || page.next_cursor.is_some()
3908        || page.files.len() != 1
3909    {
3910        return Err(invalid_feed(
3911            "v2 exact id proof is not bound to one signed path",
3912        ));
3913    }
3914    let file = page.files.pop().expect("exactly one file was checked");
3915    if !safe_store_rel_path(&file.path)
3916        || !file.path.ends_with(".md")
3917        || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
3918    {
3919        return Err(invalid_feed("v2 exact id proof has an invalid record path"));
3920    }
3921    verify_v2_file_proof(root, &file)?;
3922    Ok((
3923        file.path,
3924        V2BaselineFile {
3925            sha256: file.sha256,
3926            bytes: file.bytes,
3927            proof: Some(file.proof),
3928        },
3929    ))
3930}
3931
3932fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3933    crate::linkmd_v2::normalize_path(&item.path)
3934        .map_err(|error| invalid_feed(error.to_string()))?;
3935    if !is_sha256(&item.blob_sha256)
3936        || !is_sha256(&item.leaf_hash)
3937        || item.wrappers.is_empty()
3938        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3939        || item
3940            .wrappers
3941            .iter()
3942            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3943    {
3944        return Err(invalid_feed("v2 asset manifest item is invalid"));
3945    }
3946    let leaf = json!({
3947        "blob_sha256": item.blob_sha256,
3948        "bytes": item.bytes,
3949        "disposition": item.disposition,
3950        "media_type": item.media_type,
3951        "path": item.path,
3952        "required": item.required,
3953        "v": 2,
3954        "wrappers": item.wrappers,
3955    });
3956    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3957        .map_err(|error| invalid_feed(error.to_string()))?
3958        != item.leaf_hash
3959        || !crate::linkmd_v2::verify_proof_with_domain(
3960            root,
3961            &item.path,
3962            &item.proof,
3963            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3964        )
3965        .map_err(|error| invalid_feed(error.to_string()))?
3966    {
3967        return Err(invalid_feed("v2 asset inclusion proof failed"));
3968    }
3969    match &item.proof {
3970        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3971            if entry.name == item.path
3972                && entry.kind == crate::linkmd_v2::EntryKind::Blob
3973                && entry.child_hash == item.leaf_hash
3974                && entry.bytes == Some(item.bytes) =>
3975        {
3976            Ok(())
3977        }
3978        _ => Err(invalid_feed(
3979            "v2 asset proof leaf differs from its manifest",
3980        )),
3981    }
3982}
3983
3984fn v2_asset_manifest(
3985    cfg: &HubConfig,
3986    brain: &str,
3987    pointer: Option<&V2PointerBody>,
3988) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3989    let Some(pointer) = pointer else {
3990        return Ok(std::collections::BTreeMap::new());
3991    };
3992    let Some(root) = pointer.asset_root.as_deref() else {
3993        return Ok(std::collections::BTreeMap::new());
3994    };
3995    let mut assets = std::collections::BTreeMap::new();
3996    let mut after = String::new();
3997    loop {
3998        let encoded_after: String =
3999            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4000        let path = format!(
4001            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4002            pointer.commit_hash
4003        );
4004        let value = ensure_ok(
4005            request_capped(
4006                cfg,
4007                "GET",
4008                &path,
4009                None,
4010                Auth::Required,
4011                MAX_FEED_RESPONSE_BYTES,
4012            )?,
4013            "v2 asset manifest",
4014        )?;
4015        let page: V2AssetManifestPage = serde_json::from_value(value)
4016            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4017        if page.v != 2
4018            || page.commit != pointer.commit_hash
4019            || page.asset_root.as_deref() != Some(root)
4020            || page.assets.len() > 500
4021        {
4022            return Err(invalid_feed(
4023                "v2 asset manifest is not bound to the verified head",
4024            ));
4025        }
4026        for item in page.assets {
4027            verify_v2_asset_proof(root, &item)?;
4028            let path = item.path.clone();
4029            if assets
4030                .insert(
4031                    path,
4032                    V2BaselineAsset {
4033                        blob_sha256: item.blob_sha256,
4034                        bytes: item.bytes,
4035                        media_type: item.media_type,
4036                        wrappers: item.wrappers,
4037                        required: item.required,
4038                        disposition: item.disposition,
4039                        leaf_hash: item.leaf_hash,
4040                    },
4041                )
4042                .is_some()
4043            {
4044                return Err(invalid_feed("v2 asset manifest repeats a path"));
4045            }
4046            if assets.len() > MAX_PUSH_FILES {
4047                return Err(invalid_feed(
4048                    "v2 asset manifest exceeds the item-count bound",
4049                ));
4050            }
4051        }
4052        match page.next_cursor {
4053            None => break,
4054            Some(next) if next > after => after = next,
4055            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4056        }
4057    }
4058    Ok(assets)
4059}
4060
4061fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4062    crate::AssetRecord {
4063        path: path.to_string(),
4064        sha256: asset.blob_sha256.clone(),
4065        bytes: asset.bytes,
4066        media_type: asset.media_type.clone(),
4067        wrappers: asset.wrappers.clone(),
4068        required: asset.required,
4069    }
4070}
4071
4072fn v2_asset_resumes_hosting(
4073    remote: Option<&V2BaselineAsset>,
4074    path: &str,
4075    record: &crate::AssetRecord,
4076    disposition: &str,
4077) -> bool {
4078    remote.is_some_and(|asset| {
4079        asset.disposition == "withheld"
4080            && disposition == "hosted"
4081            && v2_asset_record(asset, path) == *record
4082    })
4083}
4084
4085fn v2_asset_record_manifest_bytes(
4086    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4087) -> LinkResult<Vec<u8>> {
4088    let mut bytes = Vec::new();
4089    for (path, asset) in assets {
4090        if asset.path != *path {
4091            return Err(invalid_feed(
4092                "local asset manifest key differs from its record path",
4093            ));
4094        }
4095        serde_json::to_writer(&mut bytes, asset)
4096            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4097        bytes.push(b'\n');
4098    }
4099    Ok(bytes)
4100}
4101
4102fn v2_local_asset_records(
4103    store: &Store,
4104) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4105    Ok(crate::assets::read_manifest(store)
4106        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
4107        .into_iter()
4108        .map(|asset| (asset.path.clone(), asset))
4109        .collect())
4110}
4111
4112fn v2_asset_records_match_remote(
4113    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4114    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4115) -> bool {
4116    local.len() == remote.len()
4117        && remote
4118            .iter()
4119            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4120}
4121
4122#[derive(Debug, Clone, PartialEq, Eq)]
4123struct V2PulledMerge<T> {
4124    records: std::collections::BTreeMap<String, T>,
4125    accept_remote: std::collections::BTreeSet<String>,
4126    conflicts: Vec<String>,
4127}
4128
4129/// Compute the local checkout that a pull may install without discarding a
4130/// local-only edit. Remote-only changes are accepted, identical concurrent
4131/// changes converge, and divergent concurrent changes are reported. The
4132/// caller can exempt kept-home coordinates from both remote installation and
4133/// conflict disclosure.
4134fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4135    base: &std::collections::BTreeMap<String, Base>,
4136    remote: &std::collections::BTreeMap<String, Remote>,
4137    local: &std::collections::BTreeMap<String, Record>,
4138    base_record: BaseRecord,
4139    remote_record: RemoteRecord,
4140    keep_local: KeepLocal,
4141) -> V2PulledMerge<Record>
4142where
4143    Record: Clone + Eq,
4144    BaseRecord: Fn(&Base, &str) -> Record,
4145    RemoteRecord: Fn(&Remote, &str) -> Record,
4146    KeepLocal: Fn(&str) -> bool,
4147{
4148    let paths = base
4149        .keys()
4150        .chain(remote.keys())
4151        .chain(local.keys())
4152        .cloned()
4153        .collect::<std::collections::BTreeSet<_>>();
4154    let mut records = local.clone();
4155    let mut accept_remote = std::collections::BTreeSet::new();
4156    let mut conflicts = Vec::new();
4157    for path in paths {
4158        if keep_local(&path) {
4159            continue;
4160        }
4161        let base_value = base.get(&path).map(|value| base_record(value, &path));
4162        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4163        let local_value = local.get(&path).cloned();
4164        if local_value != base_value && remote_value != base_value && local_value != remote_value {
4165            conflicts.push(path);
4166            continue;
4167        }
4168        if local_value == base_value || local_value == remote_value {
4169            accept_remote.insert(path.clone());
4170            match remote_value {
4171                Some(value) => {
4172                    records.insert(path, value);
4173                }
4174                None => {
4175                    records.remove(&path);
4176                }
4177            }
4178        }
4179    }
4180    V2PulledMerge {
4181        records,
4182        accept_remote,
4183        conflicts,
4184    }
4185}
4186
4187fn sign_verified_v2_candidate(
4188    cfg: &HubConfig,
4189    head: &V2VerifiedHead,
4190    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4191    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4192    mutation_id: &str,
4193    request_body: &Value,
4194    challenge_value: &Value,
4195) -> LinkResult<(String, String, String)> {
4196    if head.view_kind != "full" {
4197        return Err(invalid_feed(
4198            "a scoped self-custody writer must use the proposal workflow",
4199        ));
4200    }
4201    if head.identity.custody != "self" {
4202        return Err(invalid_feed(
4203            "a hub-custodied brain unexpectedly requested an external signature",
4204        ));
4205    }
4206    let key = cfg
4207        .brain_key
4208        .as_ref()
4209        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4210    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4211        || key.public_key_spki != head.identity.public_key_spki
4212    {
4213        return Err(bad_agent_key(
4214            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4215        ));
4216    }
4217    let challenge_id = challenge_value
4218        .get("id")
4219        .and_then(Value::as_str)
4220        .filter(|id| crate::ulid::is_ulid(id))
4221        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4222    let expected_endpoint = format!(
4223        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4224        head.brain_id
4225    );
4226    if challenge_value
4227        .get("candidate_endpoint")
4228        .and_then(Value::as_str)
4229        != Some(expected_endpoint.as_str())
4230    {
4231        return Err(invalid_feed(
4232            "self-custody challenge candidate endpoint is not origin-bound",
4233        ));
4234    }
4235
4236    let mut files = std::collections::BTreeMap::new();
4237    let mut after = String::new();
4238    type CandidateCoordinate = (
4239        String,
4240        String,
4241        String,
4242        String,
4243        Option<String>,
4244        Option<String>,
4245        u64,
4246        Option<String>,
4247    );
4248    let mut pinned: Option<CandidateCoordinate> = None;
4249    loop {
4250        let encoded_after: String =
4251            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4252        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4253        let value = ensure_ok(
4254            request_capped(
4255                cfg,
4256                "GET",
4257                &path,
4258                None,
4259                Auth::Required,
4260                MAX_FEED_RESPONSE_BYTES,
4261            )?,
4262            "v2 self-custody candidate",
4263        )?;
4264        let page: V2SigningCandidatePage = serde_json::from_value(value)
4265            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4266        if page.v != 2
4267            || page.challenge_id != challenge_id
4268            || page.mutation_id != mutation_id
4269            || page.candidate.seq != page.parent.seq + 1
4270            || page.files.len() > 500
4271            || page.expires_at.is_empty()
4272        {
4273            return Err(invalid_feed(
4274                "self-custody candidate is not bound to this mutation",
4275            ));
4276        }
4277        let coordinate = (
4278            page.request_hash.clone(),
4279            page.candidate.signing_bytes_base64.clone(),
4280            page.candidate.changes_base64.clone(),
4281            page.candidate.actor_claim_base64.clone(),
4282            page.candidate.content_root.clone(),
4283            page.candidate.asset_root.clone(),
4284            page.parent.seq,
4285            page.parent.commit_hash.clone(),
4286        );
4287        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4288            return Err(invalid_feed(
4289                "self-custody candidate changed between manifest pages",
4290            ));
4291        }
4292        pinned = Some(coordinate);
4293        let root = page
4294            .candidate
4295            .content_root
4296            .as_deref()
4297            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4298        for file in page.files {
4299            verify_v2_file_proof(root, &file)?;
4300            if files
4301                .insert(
4302                    file.path.clone(),
4303                    V2BaselineFile {
4304                        sha256: file.sha256,
4305                        bytes: file.bytes,
4306                        proof: Some(file.proof),
4307                    },
4308                )
4309                .is_some()
4310            {
4311                return Err(invalid_feed(
4312                    "self-custody candidate repeats a manifest path",
4313                ));
4314            }
4315            if files.len() > MAX_PUSH_FILES {
4316                return Err(invalid_feed(
4317                    "self-custody candidate exceeds the file-count bound",
4318                ));
4319            }
4320        }
4321        match page.next_cursor {
4322            None => break,
4323            Some(next) if next > after => after = next,
4324            Some(_) => {
4325                return Err(invalid_feed(
4326                    "self-custody candidate cursor did not advance",
4327                ))
4328            }
4329        }
4330    }
4331    if files.len() != expected.len()
4332        || files.iter().any(|(path, file)| {
4333            expected.get(path).is_none_or(|expected| {
4334                expected.sha256 != file.sha256 || expected.bytes != file.bytes
4335            })
4336        })
4337    {
4338        return Err(invalid_feed(
4339            "self-custody candidate contains an unexpected file mutation",
4340        ));
4341    }
4342    let mut assets = std::collections::BTreeMap::new();
4343    after.clear();
4344    loop {
4345        let encoded_after: String =
4346            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4347        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4348        let value = ensure_ok(
4349            request_capped(
4350                cfg,
4351                "GET",
4352                &path,
4353                None,
4354                Auth::Required,
4355                MAX_FEED_RESPONSE_BYTES,
4356            )?,
4357            "v2 self-custody asset candidate",
4358        )?;
4359        let page: V2SigningCandidatePage = serde_json::from_value(value)
4360            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4361        let coordinate = (
4362            page.request_hash.clone(),
4363            page.candidate.signing_bytes_base64.clone(),
4364            page.candidate.changes_base64.clone(),
4365            page.candidate.actor_claim_base64.clone(),
4366            page.candidate.content_root.clone(),
4367            page.candidate.asset_root.clone(),
4368            page.parent.seq,
4369            page.parent.commit_hash.clone(),
4370        );
4371        if page.v != 2
4372            || page.challenge_id != challenge_id
4373            || page.mutation_id != mutation_id
4374            || page.assets.len() > 500
4375            || pinned.as_ref() != Some(&coordinate)
4376        {
4377            return Err(invalid_feed(
4378                "self-custody asset candidate changed or is not bound",
4379            ));
4380        }
4381        let root = page.candidate.asset_root.as_deref();
4382        if !page.assets.is_empty() && root.is_none() {
4383            return Err(invalid_feed("asset candidate has no asset root"));
4384        }
4385        for item in page.assets {
4386            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4387            if assets
4388                .insert(
4389                    item.path.clone(),
4390                    V2BaselineAsset {
4391                        blob_sha256: item.blob_sha256,
4392                        bytes: item.bytes,
4393                        media_type: item.media_type,
4394                        wrappers: item.wrappers,
4395                        required: item.required,
4396                        disposition: item.disposition,
4397                        leaf_hash: item.leaf_hash,
4398                    },
4399                )
4400                .is_some()
4401            {
4402                return Err(invalid_feed("self-custody candidate repeats an asset"));
4403            }
4404        }
4405        match page.next_cursor {
4406            None => break,
4407            Some(next) if next > after => after = next,
4408            Some(_) => {
4409                return Err(invalid_feed(
4410                    "self-custody asset candidate cursor did not advance",
4411                ))
4412            }
4413        }
4414    }
4415    if assets.len() != expected_assets.len()
4416        || assets.iter().any(|(path, asset)| {
4417            expected_assets.get(path).is_none_or(|expected| {
4418                asset.blob_sha256 != expected.blob_sha256
4419                    || asset.bytes != expected.bytes
4420                    || asset.media_type != expected.media_type
4421                    || asset.wrappers != expected.wrappers
4422                    || asset.required != expected.required
4423                    || asset.disposition != expected.disposition
4424            })
4425        })
4426    {
4427        return Err(invalid_feed(
4428            "self-custody candidate contains an unexpected asset mutation",
4429        ));
4430    }
4431    let Some((
4432        request_hash,
4433        signing_b64,
4434        changes_b64,
4435        actor_b64,
4436        root,
4437        asset_root,
4438        parent_seq,
4439        parent,
4440    )) = pinned
4441    else {
4442        return Err(invalid_feed("self-custody candidate has no manifest"));
4443    };
4444    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4445    let current_commit = head
4446        .pointer
4447        .as_ref()
4448        .map(|pointer| pointer.commit_hash.clone());
4449    if parent_seq != current_seq || parent != current_commit {
4450        return Err(LinkError::RemoteAdvancedDuringSync);
4451    }
4452    let changes = STANDARD
4453        .decode(changes_b64)
4454        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4455    let mut expected_changes = json!({
4456        "mutation_id": mutation_id,
4457        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4458        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4459        "v": 2,
4460    });
4461    if let Some(withheld_links) = request_body.get("withheld_links") {
4462        expected_changes["withheld_links"] = withheld_links.clone();
4463    }
4464    if let Some(checkout_id) = request_body.get("checkout_id") {
4465        expected_changes["checkout_id"] = checkout_id.clone();
4466    }
4467    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4468        .map_err(|error| invalid_feed(error.to_string()))?;
4469    if changes != expected_changes_bytes {
4470        return Err(invalid_feed(
4471            "self-custody changeset differs from the requested mutation",
4472        ));
4473    }
4474    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4475        .map_err(|error| invalid_feed(error.to_string()))?;
4476    let request_value = json!({
4477        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4478        "brain": head.brain_id,
4479        "changes_sha256": changes_hash,
4480        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4481        "v": 2,
4482        "v1_bridge": Value::Null,
4483    });
4484    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4485        .map_err(|error| invalid_feed(error.to_string()))?;
4486    if request_hash != expected_request_hash {
4487        return Err(invalid_feed(
4488            "self-custody request hash differs from the requested mutation",
4489        ));
4490    }
4491    let actor = STANDARD
4492        .decode(actor_b64)
4493        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4494    let actor_value: Value = serde_json::from_slice(&actor)
4495        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4496    if crate::linkmd_v2::canonical_bytes(&actor_value)
4497        .map_err(|error| invalid_feed(error.to_string()))?
4498        != actor
4499    {
4500        return Err(invalid_feed("self-custody actor claim is not canonical"));
4501    }
4502    let actor_object = actor_value
4503        .as_object()
4504        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4505    let actor_claim = actor_object
4506        .get("claim")
4507        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4508    let actor_public_key = actor_object
4509        .get("public_key")
4510        .and_then(Value::as_str)
4511        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4512    let actor_fingerprint = actor_object
4513        .get("fingerprint")
4514        .and_then(Value::as_str)
4515        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4516    let actor_signature = actor_object
4517        .get("sig")
4518        .and_then(Value::as_str)
4519        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4520    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4521        .map_err(|error| invalid_feed(error.to_string()))?;
4522    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4523    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4524    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4525    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4526    let impact = actor_claim
4527        .get("result")
4528        .and_then(|result| result.get("impact"))
4529        .and_then(Value::as_object);
4530    let impact_fields = [
4531        "creates",
4532        "updates",
4533        "deletes",
4534        "withdrawals",
4535        "renames",
4536        "restores",
4537        "asset_changes",
4538        "public_expansions",
4539        "executable_activations",
4540    ];
4541    let impact_is_valid = impact.is_some_and(|impact| {
4542        impact.len() == impact_fields.len() + 1
4543            && impact.get("v").and_then(Value::as_u64) == Some(1)
4544            && impact_fields
4545                .iter()
4546                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4547    });
4548    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4549        || head
4550            .trust
4551            .hub_signer
4552            .as_ref()
4553            .is_some_and(|known| known != &expected_actor_signer)
4554        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4555        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4556        || actor_claim
4557            .get("candidate")
4558            .and_then(|candidate| candidate.get("changes_sha256"))
4559            .and_then(Value::as_str)
4560            != Some(changes_hash.as_str())
4561        || actor_claim
4562            .get("candidate")
4563            .and_then(|candidate| candidate.get("state_root"))
4564            != Some(&expected_actor_root)
4565        || actor_claim
4566            .get("candidate")
4567            .and_then(|candidate| candidate.get("asset_root"))
4568            != Some(&expected_actor_asset_root)
4569        || actor_claim
4570            .get("candidate")
4571            .and_then(|candidate| candidate.get("control_revision"))
4572            .and_then(Value::as_str)
4573            != Some(head.control_revision.as_str())
4574        || !impact_is_valid
4575    {
4576        return Err(invalid_feed(
4577            "self-custody actor claim does not bind the verified authority",
4578        ));
4579    }
4580    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4581        .map_err(|error| invalid_feed(error.to_string()))?;
4582    let signing = STANDARD
4583        .decode(signing_b64)
4584        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4585    let signing_value: Value = serde_json::from_slice(&signing)
4586        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4587    if crate::linkmd_v2::canonical_bytes(&signing_value)
4588        .map_err(|error| invalid_feed(error.to_string()))?
4589        != signing
4590    {
4591        return Err(invalid_feed("self-custody signing bytes are not canonical"));
4592    }
4593    let pointer = head.pointer.as_ref();
4594    let expected_materializer = pointer
4595        .map(|value| value.materializer.as_str())
4596        .unwrap_or("dbmd-projection-v1");
4597    let expected_parent_commit = request_body
4598        .get("base")
4599        .and_then(|base| base.get("commit_hash"))
4600        .cloned()
4601        .unwrap_or(Value::Null);
4602    let expected_parent_root = request_body
4603        .get("base")
4604        .and_then(|base| base.get("content_root"))
4605        .cloned()
4606        .unwrap_or(Value::Null);
4607    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4608    let expected_parent_asset_root = request_body
4609        .get("base")
4610        .and_then(|base| base.get("asset_root"))
4611        .cloned()
4612        .unwrap_or(Value::Null);
4613    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4614    let expected_prev_entry = pointer
4615        .map(|value| Value::String(value.feed_hash.clone()))
4616        .unwrap_or(Value::Null);
4617    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4618        .map_err(|_| invalid_feed("brain identity history is too large"))?
4619        + 1;
4620    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4621        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4622        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4623        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4624        || signing_value.get("public_key").and_then(Value::as_str)
4625            != Some(key.public_key_spki.as_str())
4626        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4627        || signing_value.get("parent_root") != Some(&expected_parent_root)
4628        || signing_value.get("state_root") != Some(&expected_state_root)
4629        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4630        || signing_value.get("asset_root") != Some(&expected_asset_root)
4631        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4632        || signing_value.get("changes_sha256").and_then(Value::as_str)
4633            != Some(changes_hash.as_str())
4634        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4635        || signing_value
4636            .get("control_revision")
4637            .and_then(Value::as_str)
4638            != Some(head.control_revision.as_str())
4639        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4640        || signing_value.get("v1_bridge") != Some(&Value::Null)
4641        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4642    {
4643        return Err(invalid_feed(
4644            "self-custody signing bytes do not bind the verified candidate",
4645        ));
4646    }
4647    let pair = agent_keypair(&key.pkcs8)?;
4648    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4649    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4650}
4651
4652fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4653    let origin = normalized_origin(&cfg.hub)?;
4654    let absolute = if checkout.is_absolute() {
4655        checkout.to_path_buf()
4656    } else {
4657        std::env::current_dir()?.join(checkout)
4658    };
4659    Ok(format!(
4660        "sync-{}.json",
4661        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4662    ))
4663}
4664
4665fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4666    if let Some(value) = existing {
4667        if !is_sha256(value) {
4668            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4669        }
4670        return Ok(value.to_string());
4671    }
4672    use ring::rand::SecureRandom as _;
4673    let mut random = [0_u8; 32];
4674    ring::rand::SystemRandom::new()
4675        .fill(&mut random)
4676        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4677    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4678}
4679
4680#[cfg(any(unix, windows))]
4681fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4682    let directory = open_trust_dir(cfg)?;
4683    let origin = normalized_origin(&cfg.hub)?;
4684    let name = format!(
4685        "operation-{}.lock",
4686        content_sha256(format!("{origin}\0{brain}").as_bytes())
4687    );
4688    lock_trust_name(&directory, &name)
4689}
4690
4691#[cfg(not(any(unix, windows)))]
4692fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4693    Err(LinkError::UnsupportedPlatform {
4694        operation: "serialized link.md v2 sync",
4695    })
4696}
4697
4698fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4699    left.brain_id == right.brain_id
4700        && left.view_kind == right.view_kind
4701        && left.view_revision == right.view_revision
4702        && left.control_revision == right.control_revision
4703        && match (&left.pointer, &right.pointer) {
4704            (None, None) => true,
4705            (Some(left), Some(right)) => {
4706                left.seq == right.seq
4707                    && left.commit_hash == right.commit_hash
4708                    && left.content_root == right.content_root
4709                    && left.asset_root == right.asset_root
4710                    && left.feed_hash == right.feed_hash
4711            }
4712            _ => false,
4713        }
4714}
4715
4716fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4717    format!(
4718        "---\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"
4719    )
4720    .into_bytes()
4721}
4722
4723fn scoped_projection_sha256(brain: &str) -> String {
4724    content_sha256(&scoped_projection_bytes(brain))
4725}
4726
4727#[derive(Deserialize)]
4728struct LocalScopedViewMarker {
4729    v: u8,
4730    kind: String,
4731    authoritative: bool,
4732    brain: String,
4733    projection_sha256: String,
4734}
4735
4736/// True only when this store carries the exact generated marker for a local
4737/// link.md scoped view. This is presentation context, never authorization;
4738/// the hub remains the only authority for reads and writes.
4739pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4740    let marker = store
4741        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4742        .ok()
4743        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4744    let Some(marker) = marker else {
4745        return false;
4746    };
4747    if marker.v != 1
4748        || marker.kind != "link.md-scoped-view"
4749        || marker.authoritative
4750        || !crate::ulid::is_ulid(&marker.brain)
4751        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4752    {
4753        return false;
4754    }
4755    store
4756        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4757        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4758}
4759
4760fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4761    let mut bytes = serde_json::to_vec_pretty(&json!({
4762        "v": 1,
4763        "kind": "link.md-scoped-view",
4764        "authoritative": false,
4765        "brain": head.brain_id,
4766        "view_revision": head.view_revision,
4767        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4768        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4769        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4770        "visible_files": files,
4771        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4772    }))
4773    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4774    bytes.push(b'\n');
4775    Ok(bytes)
4776}
4777
4778fn refresh_scoped_view_marker(
4779    store: &Store,
4780    head: &V2VerifiedHead,
4781    files: usize,
4782) -> LinkResult<()> {
4783    if head.view_kind == "scoped" {
4784        store.write_atomic(
4785            Path::new(".dbmd/view.json"),
4786            &scoped_view_metadata(head, files)?,
4787        )?;
4788    }
4789    Ok(())
4790}
4791
4792fn ensure_v2_view_compatible(
4793    head: &V2VerifiedHead,
4794    baseline: Option<&V2SyncBaseline>,
4795) -> LinkResult<()> {
4796    let Some(baseline) = baseline else {
4797        return Ok(());
4798    };
4799    match (
4800        baseline.view_kind.as_deref(),
4801        baseline.view_revision.as_deref(),
4802    ) {
4803        (None, None) if head.view_kind == "full" => Ok(()),
4804        (Some(kind), Some(revision))
4805            if kind == head.view_kind && revision == head.view_revision =>
4806        {
4807            Ok(())
4808        }
4809        _ => Err(LinkError::ScopedViewChanged),
4810    }
4811}
4812
4813fn ensure_established_v2_checkout_opened(
4814    head: &V2VerifiedHead,
4815    baseline: Option<&V2SyncBaseline>,
4816    opened: bool,
4817) -> LinkResult<()> {
4818    if baseline.is_none() || opened {
4819        return Ok(());
4820    }
4821    if head.view_kind == "scoped" {
4822        return Err(LinkError::ScopedProjectionModified);
4823    }
4824    Err(LinkError::InvalidPack {
4825        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4826    })
4827}
4828
4829fn remove_scoped_projection(
4830    head: &V2VerifiedHead,
4831    baseline: Option<&V2SyncBaseline>,
4832    view: &mut V2LocalView,
4833) -> LinkResult<()> {
4834    if head.view_kind != "scoped" {
4835        return Ok(());
4836    }
4837    let expected = scoped_projection_sha256(&head.brain_id);
4838    if baseline
4839        .and_then(|state| state.projection_sha256.as_deref())
4840        .is_some_and(|pinned| pinned != expected)
4841    {
4842        return Err(LinkError::ScopedViewChanged);
4843    }
4844    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4845        return Err(LinkError::ScopedProjectionModified);
4846    }
4847    view.riding.remove("DB.md");
4848    view.eligibility.remove("DB.md");
4849    Ok(())
4850}
4851
4852fn local_view_for_v2_push(
4853    store: &Store,
4854    head: &V2VerifiedHead,
4855    baseline: Option<&V2SyncBaseline>,
4856    carried: Option<V2LocalView>,
4857) -> LinkResult<V2LocalView> {
4858    match carried {
4859        // The pull half already verified and removed the generated scoped
4860        // projection before constructing its in-memory handoff. Requiring it
4861        // a second time would turn every scoped bidirectional write into a
4862        // false tamper refusal.
4863        Some(view) => Ok(view),
4864        None => {
4865            let mut view = v2_local_files(store)?;
4866            remove_scoped_projection(head, baseline, &mut view)?;
4867            Ok(view)
4868        }
4869    }
4870}
4871
4872fn files_for_v2_view(
4873    head: &V2VerifiedHead,
4874    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4875) -> std::collections::BTreeMap<String, V2BaselineFile> {
4876    if head.view_kind == "scoped" {
4877        // Even when a grant explicitly includes canonical DB.md, a partial
4878        // checkout uses a generated marker. Canonical validation remains a
4879        // server-side operation over the complete brain.
4880        files.remove("DB.md");
4881    }
4882    files
4883}
4884
4885fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4886    let baseline: V2SyncBaseline =
4887        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4888    if baseline.v != 2
4889        || baseline.origin != normalized_origin(&cfg.hub)?
4890        || baseline.brain != brain
4891        || baseline
4892            .commit_hash
4893            .as_deref()
4894            .is_some_and(|hash| !is_sha256(hash))
4895        || baseline
4896            .content_root
4897            .as_deref()
4898            .is_some_and(|hash| !is_sha256(hash))
4899        || baseline
4900            .asset_root
4901            .as_deref()
4902            .is_some_and(|hash| !is_sha256(hash))
4903        || baseline
4904            .local_policy_digest
4905            .as_deref()
4906            .is_some_and(|hash| !is_sha256(hash))
4907        || baseline
4908            .view_kind
4909            .as_deref()
4910            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4911        || baseline
4912            .view_revision
4913            .as_deref()
4914            .is_some_and(|hash| !is_sha256(hash))
4915        || baseline
4916            .projection_sha256
4917            .as_deref()
4918            .is_some_and(|hash| !is_sha256(hash))
4919        || (baseline.view_kind.as_deref() == Some("scoped")
4920            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4921        || baseline.files.len() > MAX_PUSH_FILES
4922        || baseline.assets.len() > MAX_PUSH_FILES
4923        || baseline.local_eligibility.len() > MAX_PUSH_FILES
4924        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4925        || baseline.files.iter().any(|(path, file)| {
4926            crate::linkmd_v2::normalize_path(path).is_err()
4927                || !is_sha256(&file.sha256)
4928                || file.bytes > MAX_STORE_BYTES
4929        })
4930        || baseline.assets.iter().any(|(path, asset)| {
4931            crate::linkmd_v2::normalize_path(path).is_err()
4932                || !is_sha256(&asset.blob_sha256)
4933                || !is_sha256(&asset.leaf_hash)
4934                || asset.bytes > MAX_STORE_BYTES
4935                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4936                || asset.wrappers.is_empty()
4937                || asset
4938                    .wrappers
4939                    .iter()
4940                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4941        })
4942        || baseline
4943            .local_eligibility
4944            .keys()
4945            .chain(baseline.remote_copy_remains.keys())
4946            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4947        || baseline
4948            .remote_copy_remains
4949            .values()
4950            .any(|hash| !is_sha256(hash))
4951        || baseline
4952            .checkout_id
4953            .as_deref()
4954            .is_some_and(|checkout_id| !is_sha256(checkout_id))
4955    {
4956        return Err(invalid_feed("v2 sync baseline failed validation"));
4957    }
4958    Ok(baseline)
4959}
4960
4961#[cfg(unix)]
4962fn load_v2_baseline(
4963    cfg: &HubConfig,
4964    brain: &str,
4965    checkout: &Path,
4966) -> LinkResult<Option<V2SyncBaseline>> {
4967    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4968    let directory = open_trust_dir(cfg)?;
4969    let name_string = v2_baseline_name(cfg, brain, checkout)?;
4970    let _lock = lock_trust_name(&directory, &name_string)?;
4971    let name = c_name(name_string.as_bytes(), &name_string)?;
4972    let fd = unsafe {
4973        libc::openat(
4974            directory.as_raw_fd(),
4975            name.as_ptr(),
4976            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4977        )
4978    };
4979    if fd < 0 {
4980        let error = std::io::Error::last_os_error();
4981        return if error.kind() == std::io::ErrorKind::NotFound {
4982            Ok(None)
4983        } else {
4984            Err(LinkError::UnsafePath { path: name_string })
4985        };
4986    }
4987    let file = unsafe { std::fs::File::from_raw_fd(fd) };
4988    let mut bytes = Vec::new();
4989    file.take(MAX_FEED_RESPONSE_BYTES + 1)
4990        .read_to_end(&mut bytes)?;
4991    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
4992        return Err(invalid_feed("v2 sync baseline is oversized"));
4993    }
4994    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
4995}
4996
4997#[cfg(windows)]
4998fn load_v2_baseline(
4999    cfg: &HubConfig,
5000    brain: &str,
5001    checkout: &Path,
5002) -> LinkResult<Option<V2SyncBaseline>> {
5003    let directory = open_trust_dir(cfg)?;
5004    let name = v2_baseline_name(cfg, brain, checkout)?;
5005    let _lock = lock_trust_name(&directory, &name)?;
5006    let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5007    match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5008        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5009        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5010        Err(_) => Err(LinkError::UnsafePath { path: name }),
5011    }
5012}
5013
5014#[cfg(not(any(unix, windows)))]
5015fn load_v2_baseline(
5016    _cfg: &HubConfig,
5017    _brain: &str,
5018    _checkout: &Path,
5019) -> LinkResult<Option<V2SyncBaseline>> {
5020    Err(LinkError::UnsupportedPlatform {
5021        operation: "verified link.md v2 baseline",
5022    })
5023}
5024
5025#[cfg(unix)]
5026fn save_v2_baseline(
5027    cfg: &HubConfig,
5028    brain: &str,
5029    checkout: &Path,
5030    baseline: &V2SyncBaseline,
5031) -> LinkResult<()> {
5032    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5033    let directory = open_trust_dir(cfg)?;
5034    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5035    let _lock = lock_trust_name(&directory, &name_string)?;
5036    let name = c_name(name_string.as_bytes(), &name_string)?;
5037    let mut bytes = serde_json::to_vec(baseline)
5038        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5039    bytes.push(b'\n');
5040    let temp_string = format!(
5041        ".{name_string}.tmp.{}-{}",
5042        std::process::id(),
5043        std::time::SystemTime::now()
5044            .duration_since(std::time::UNIX_EPOCH)
5045            .unwrap_or_default()
5046            .as_nanos()
5047    );
5048    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5049    let fd = unsafe {
5050        libc::openat(
5051            directory.as_raw_fd(),
5052            temp.as_ptr(),
5053            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5054            0o600,
5055        )
5056    };
5057    if fd < 0 {
5058        return Err(std::io::Error::last_os_error().into());
5059    }
5060    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5061    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5062        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5063        return Err(error.into());
5064    }
5065    drop(file);
5066    if unsafe {
5067        libc::renameat(
5068            directory.as_raw_fd(),
5069            temp.as_ptr(),
5070            directory.as_raw_fd(),
5071            name.as_ptr(),
5072        )
5073    } != 0
5074    {
5075        let error = std::io::Error::last_os_error();
5076        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5077        return Err(error.into());
5078    }
5079    directory.sync_all()?;
5080    Ok(())
5081}
5082
5083#[cfg(windows)]
5084fn save_v2_baseline(
5085    cfg: &HubConfig,
5086    brain: &str,
5087    checkout: &Path,
5088    baseline: &V2SyncBaseline,
5089) -> LinkResult<()> {
5090    let directory = open_trust_dir(cfg)?;
5091    let name = v2_baseline_name(cfg, brain, checkout)?;
5092    let _lock = lock_trust_name(&directory, &name)?;
5093    let mut bytes = serde_json::to_vec(baseline)
5094        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5095    bytes.push(b'\n');
5096    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5097    Ok(())
5098}
5099
5100#[cfg(not(any(unix, windows)))]
5101fn save_v2_baseline(
5102    _cfg: &HubConfig,
5103    _brain: &str,
5104    _checkout: &Path,
5105    _baseline: &V2SyncBaseline,
5106) -> LinkResult<()> {
5107    Err(LinkError::UnsupportedPlatform {
5108        operation: "verified link.md v2 baseline",
5109    })
5110}
5111
5112fn v2_baseline_from_head(
5113    cfg: &HubConfig,
5114    head: &V2VerifiedHead,
5115    files: std::collections::BTreeMap<String, V2BaselineFile>,
5116    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5117    local: Option<&V2LocalView>,
5118    checkout_id: Option<&str>,
5119) -> LinkResult<V2SyncBaseline> {
5120    let mut local_eligibility = local
5121        .map(|view| view.eligibility.clone())
5122        .unwrap_or_default();
5123    if let Some(view) = local {
5124        for path in files.keys() {
5125            local_eligibility
5126                .entry(path.clone())
5127                .or_insert_with(|| !view.policy.keeps_home(path));
5128        }
5129    }
5130    let remote_copy_remains = local_eligibility
5131        .iter()
5132        .filter(|(_, riding)| !**riding)
5133        .filter_map(|(path, _)| {
5134            files
5135                .get(path)
5136                .map(|file| (path.clone(), file.sha256.clone()))
5137        })
5138        .collect();
5139    Ok(V2SyncBaseline {
5140        v: 2,
5141        origin: normalized_origin(&cfg.hub)?,
5142        brain: head.brain_id.clone(),
5143        checkout_id: Some(v2_checkout_id(checkout_id)?),
5144        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5145        commit_hash: head
5146            .pointer
5147            .as_ref()
5148            .map(|pointer| pointer.commit_hash.clone()),
5149        content_root: head
5150            .pointer
5151            .as_ref()
5152            .and_then(|pointer| pointer.content_root.clone()),
5153        asset_root: head
5154            .pointer
5155            .as_ref()
5156            .and_then(|pointer| pointer.asset_root.clone()),
5157        assets,
5158        view_kind: Some(head.view_kind.clone()),
5159        view_revision: Some(head.view_revision.clone()),
5160        projection_sha256: (head.view_kind == "scoped")
5161            .then(|| scoped_projection_sha256(&head.brain_id)),
5162        files,
5163        local_policy_digest: local.map(|view| view.policy.digest.clone()),
5164        local_eligibility,
5165        remote_copy_remains,
5166    })
5167}
5168
5169fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5170    let policy = crate::linkmd_sync_policy::load(store)
5171        .map_err(|message| LinkError::InvalidPack { message })?;
5172    let asset_paths = crate::assets::read_manifest(store)
5173        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5174        .into_iter()
5175        .map(|asset| asset.path)
5176        .collect::<std::collections::BTreeSet<_>>();
5177    let mut result = std::collections::BTreeMap::new();
5178    let mut eligibility = std::collections::BTreeMap::new();
5179    let mut riding_links = Vec::<(String, Vec<String>)>::new();
5180    let mut total = 0_u64;
5181    let mut paths = vec![PathBuf::from("DB.md")];
5182    paths.extend(store.walk()?);
5183    for relative in paths {
5184        let path = relative.to_string_lossy().replace('\\', "/");
5185        // v2 catalogs and asset inventory are materialized/signed separately.
5186        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5187            continue;
5188        }
5189        if asset_paths.contains(&path) {
5190            continue;
5191        }
5192        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5193            path: error.to_string(),
5194        })?;
5195        let riding = !policy.keeps_home(&path);
5196        eligibility.insert(path.clone(), riding);
5197        if !riding {
5198            continue;
5199        }
5200        let remaining = MAX_STORE_BYTES.saturating_sub(total);
5201        let bytes = store.read_bounded(&relative, remaining)?;
5202        total = total
5203            .checked_add(bytes.len() as u64)
5204            .ok_or_else(|| LinkError::PushTooLarge {
5205                detail: "v2 local byte count overflow".to_string(),
5206            })?;
5207        if total > MAX_STORE_BYTES {
5208            return Err(LinkError::PushTooLarge {
5209                detail: format!("{total} uncompressed bytes"),
5210            });
5211        }
5212        if std::str::from_utf8(&bytes).is_err() {
5213            return Err(LinkError::NotUtf8 { path });
5214        }
5215        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5216        riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5217        result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5218    }
5219    let kept_home = eligibility
5220        .iter()
5221        .filter(|(_, riding)| !**riding)
5222        .map(|(path, _)| path.clone())
5223        .collect::<std::collections::BTreeSet<_>>();
5224    let mut withheld_links = riding_links
5225        .into_iter()
5226        .flat_map(|(source, targets)| {
5227            let kept_home = &kept_home;
5228            let policy = &policy;
5229            targets.into_iter().filter_map(move |target| {
5230                let target = format!("{target}.md");
5231                // A kept-home target need not be a file on THIS machine. An
5232                // export omits kept-home content by construction, so a store
5233                // restored from one links to names it does not carry — and
5234                // without this the owner's own export could never be pushed
5235                // back, which is the opposite of owning your data. The policy
5236                // is the owner's statement about where a path lives, and it
5237                // stands whether or not this checkout holds a copy. It
5238                // discloses nothing either way: the name is already written in
5239                // the content that links to it.
5240                (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5241                    V2WithheldLink {
5242                        source: source.clone(),
5243                        target,
5244                    },
5245                )
5246            })
5247        })
5248        .collect::<Vec<_>>();
5249    withheld_links.sort();
5250    withheld_links.dedup();
5251    Ok(V2LocalView {
5252        riding: result,
5253        eligibility,
5254        policy,
5255        withheld_links,
5256    })
5257}
5258
5259#[derive(Debug, Deserialize)]
5260struct V2DownloadItem {
5261    path: String,
5262    sha256: String,
5263    bytes: u64,
5264    url: String,
5265    method: String,
5266}
5267
5268#[derive(Debug, Deserialize)]
5269struct V2DownloadWindow {
5270    v: u8,
5271    commit: String,
5272    downloads: Vec<V2DownloadItem>,
5273}
5274
5275#[derive(Debug, Deserialize)]
5276struct V2BulkStreamHeader {
5277    v: u8,
5278    path: String,
5279    sha256: String,
5280    bytes: u64,
5281}
5282
5283fn parse_v2_bulk_stream(
5284    bytes: &[u8],
5285    expected: &[(&String, &V2BaselineFile)],
5286) -> LinkResult<Vec<(String, Vec<u8>)>> {
5287    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5288        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5289    }
5290    let mut cursor = V2_BULK_STREAM_MAGIC.len();
5291    let mut result = Vec::with_capacity(expected.len());
5292    for (expected_path, expected_file) in expected {
5293        let length_bytes = bytes
5294            .get(cursor..cursor + 4)
5295            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5296        cursor += 4;
5297        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5298        if header_len == 0 || header_len > 4 * 1024 {
5299            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5300        }
5301        let header_bytes = bytes
5302            .get(cursor..cursor + header_len)
5303            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5304        cursor += header_len;
5305        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5306            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5307        if header.v != 2
5308            || &header.path != *expected_path
5309            || header.sha256 != expected_file.sha256
5310            || header.bytes != expected_file.bytes
5311            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5312        {
5313            return Err(invalid_feed(
5314                "v2 bulk stream frame differs from its proven manifest entry",
5315            ));
5316        }
5317        let body_len = usize::try_from(header.bytes)
5318            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5319        let body = bytes
5320            .get(cursor..cursor + body_len)
5321            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5322        cursor += body_len;
5323        if content_sha256(body) != header.sha256 {
5324            return Err(invalid_feed(
5325                "v2 bulk stream file differs from its proven manifest entry",
5326            ));
5327        }
5328        result.push((header.path, body.to_vec()));
5329    }
5330    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5331        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5332    }
5333    cursor += 4;
5334    if cursor != bytes.len() {
5335        return Err(invalid_feed("v2 bulk stream carries trailing data"));
5336    }
5337    Ok(result)
5338}
5339
5340fn download_v2_bulk_stream(
5341    cfg: &HubConfig,
5342    brain: &str,
5343    pointer: &V2PointerBody,
5344    pending: &[(&String, &V2BaselineFile)],
5345) -> LinkResult<Vec<(String, Vec<u8>)>> {
5346    let claims = pending
5347        .iter()
5348        .map(|(path, file)| {
5349            Ok(json!({
5350                "path": path,
5351                "sha256": file.sha256,
5352                "bytes": file.bytes,
5353                "proof": file.proof.as_ref().ok_or_else(|| {
5354                    invalid_feed("v2 manifest omitted a bulk-stream proof")
5355                })?,
5356            }))
5357        })
5358        .collect::<LinkResult<Vec<_>>>()?;
5359    let raw = request_raw(
5360        cfg,
5361        "POST",
5362        &format!("/api/hub/brains/{brain}/v2/stream"),
5363        Some(&json!({
5364            "commit": pointer.commit_hash,
5365            "files": claims,
5366        })),
5367        Auth::Required,
5368        V2_BULK_STREAM_RESPONSE_BYTES,
5369    )?;
5370    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5371    parse_v2_bulk_stream(&body, pending)
5372}
5373
5374fn prepare_v2_downloads(
5375    cfg: &HubConfig,
5376    brain: &str,
5377    pointer: &V2PointerBody,
5378    pending: &[(&String, &V2BaselineFile)],
5379) -> LinkResult<Vec<V2DownloadItem>> {
5380    let mut result = Vec::with_capacity(pending.len());
5381    for chunk in pending.chunks(128) {
5382        let claims = chunk
5383            .iter()
5384            .map(|(path, file)| {
5385                Ok(json!({
5386                    "path": path,
5387                    "sha256": file.sha256,
5388                    "bytes": file.bytes,
5389                    "proof": file.proof.as_ref().ok_or_else(|| {
5390                        invalid_feed("v2 manifest omitted a download proof")
5391                    })?,
5392                }))
5393            })
5394            .collect::<LinkResult<Vec<_>>>()?;
5395        let value = ensure_ok(
5396            request_capped(
5397                cfg,
5398                "POST",
5399                &format!("/api/hub/brains/{brain}/v2/downloads"),
5400                Some(&json!({
5401                    "commit": pointer.commit_hash,
5402                    "files": claims,
5403                })),
5404                Auth::Required,
5405                MAX_FEED_RESPONSE_BYTES,
5406            )?,
5407            "prepare v2 blob downloads",
5408        )?;
5409        let window: V2DownloadWindow = serde_json::from_value(value)
5410            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5411        if window.v != 2
5412            || window.commit != pointer.commit_hash
5413            || window.downloads.len() != chunk.len()
5414        {
5415            return Err(invalid_feed(
5416                "v2 download window is not bound to the requested files",
5417            ));
5418        }
5419        let mut by_path = window
5420            .downloads
5421            .into_iter()
5422            .map(|item| (item.path.clone(), item))
5423            .collect::<std::collections::BTreeMap<_, _>>();
5424        if by_path.len() != chunk.len() {
5425            return Err(invalid_feed("v2 download window repeats a path"));
5426        }
5427        for (path, file) in chunk {
5428            let item = by_path
5429                .remove(*path)
5430                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5431            if item.method != "GET"
5432                || item.sha256 != file.sha256
5433                || item.bytes != file.bytes
5434                || item.url.is_empty()
5435            {
5436                return Err(invalid_feed(
5437                    "v2 download capability differs from its proven file",
5438                ));
5439            }
5440            result.push(item);
5441        }
5442    }
5443    Ok(result)
5444}
5445
5446fn prepare_v2_asset_downloads(
5447    cfg: &HubConfig,
5448    brain: &str,
5449    pointer: &V2PointerBody,
5450    pending: &[(&String, &V2BaselineAsset)],
5451) -> LinkResult<Vec<V2DownloadItem>> {
5452    let mut result = Vec::with_capacity(pending.len());
5453    for chunk in pending.chunks(128) {
5454        let claims = chunk
5455            .iter()
5456            .map(|(path, asset)| {
5457                json!({
5458                    "path": path,
5459                    "sha256": asset.blob_sha256,
5460                    "bytes": asset.bytes,
5461                    "leaf_hash": asset.leaf_hash,
5462                })
5463            })
5464            .collect::<Vec<_>>();
5465        let value = ensure_ok(
5466            request_capped(
5467                cfg,
5468                "POST",
5469                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5470                Some(&json!({
5471                    "commit": pointer.commit_hash,
5472                    "assets": claims,
5473                })),
5474                Auth::Required,
5475                MAX_FEED_RESPONSE_BYTES,
5476            )?,
5477            "prepare v2 asset downloads",
5478        )?;
5479        let window: V2DownloadWindow = serde_json::from_value(value)
5480            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5481        if window.v != 2
5482            || window.commit != pointer.commit_hash
5483            || window.downloads.len() != chunk.len()
5484        {
5485            return Err(invalid_feed(
5486                "v2 asset download window is not bound to the requested assets",
5487            ));
5488        }
5489        let mut by_path = window
5490            .downloads
5491            .into_iter()
5492            .map(|item| (item.path.clone(), item))
5493            .collect::<std::collections::BTreeMap<_, _>>();
5494        if by_path.len() != chunk.len() {
5495            return Err(invalid_feed("v2 asset download window repeats a path"));
5496        }
5497        for (path, asset) in chunk {
5498            let item = by_path
5499                .remove(*path)
5500                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5501            if item.method != "GET"
5502                || item.sha256 != asset.blob_sha256
5503                || item.bytes != asset.bytes
5504                || item.url.is_empty()
5505            {
5506                return Err(invalid_feed(
5507                    "v2 asset download capability differs from its signed leaf",
5508                ));
5509            }
5510            result.push(item);
5511        }
5512    }
5513    Ok(result)
5514}
5515
5516fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5517    let bytes = get_presigned(cfg, &item.url)?;
5518    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5519        return Err(invalid_feed("v2 blob differs from its proven path entry"));
5520    }
5521    Ok(bytes)
5522}
5523
5524#[derive(Debug, Clone)]
5525struct V2StagedFile {
5526    path: String,
5527    source: PathBuf,
5528    sha256: String,
5529    bytes: u64,
5530}
5531
5532#[cfg(unix)]
5533fn v2_download_cache_dir(
5534    cfg: &HubConfig,
5535    brain: &str,
5536    pointer: &V2PointerBody,
5537) -> LinkResult<PathBuf> {
5538    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5539}
5540
5541#[cfg(unix)]
5542fn v2_download_cache_dir_for(
5543    cfg: &HubConfig,
5544    brain: &str,
5545    transaction: &str,
5546) -> LinkResult<PathBuf> {
5547    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5548        return Err(invalid_feed("v2 download cache address is invalid"));
5549    }
5550    let path = cfg
5551        .state_dir
5552        .join("downloads")
5553        .join(brain)
5554        .join(transaction);
5555    let directory = open_or_create_dir_nofollow(&path)?;
5556    use std::os::fd::AsRawFd as _;
5557    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5558        return Err(std::io::Error::last_os_error().into());
5559    }
5560    directory.sync_all()?;
5561    Ok(path)
5562}
5563
5564#[cfg(windows)]
5565fn v2_download_cache_dir(
5566    cfg: &HubConfig,
5567    brain: &str,
5568    pointer: &V2PointerBody,
5569) -> LinkResult<PathBuf> {
5570    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5571}
5572
5573#[cfg(windows)]
5574fn v2_download_cache_dir_for(
5575    cfg: &HubConfig,
5576    brain: &str,
5577    transaction: &str,
5578) -> LinkResult<PathBuf> {
5579    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5580        return Err(invalid_feed("v2 download cache address is invalid"));
5581    }
5582    let path = cfg
5583        .state_dir
5584        .join("downloads")
5585        .join(brain)
5586        .join(transaction);
5587    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5588    crate::fsx::open_directory_nofollow(&path)?;
5589    Ok(path)
5590}
5591
5592#[cfg(unix)]
5593fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5594    use std::os::fd::AsRawFd as _;
5595    let parent = cfg.state_dir.join("downloads").join(brain);
5596    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5597        return;
5598    };
5599    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5600        return;
5601    };
5602    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5603    let _ = directory.sync_all();
5604}
5605
5606#[cfg(windows)]
5607fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5608    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5609        return;
5610    }
5611    let parent = cfg.state_dir.join("downloads").join(brain);
5612    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5613        return;
5614    };
5615    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5616}
5617
5618#[cfg(not(any(unix, windows)))]
5619fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5620
5621#[cfg(not(any(unix, windows)))]
5622fn v2_download_cache_dir_for(
5623    _cfg: &HubConfig,
5624    _brain: &str,
5625    _transaction: &str,
5626) -> LinkResult<PathBuf> {
5627    Err(LinkError::UnsupportedPlatform {
5628        operation: "resumable v2 download staging",
5629    })
5630}
5631
5632#[cfg(any(unix, windows))]
5633fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5634    let file = match crate::fsx::open_regular_nofollow(path) {
5635        Ok(file) => file,
5636        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5637        Err(error) => return Err(error.into()),
5638    };
5639    if file.metadata()?.len() != bytes {
5640        return Ok(false);
5641    }
5642    Ok(content_sha256_reader(file)? == sha256)
5643}
5644
5645#[cfg(any(unix, windows))]
5646fn cache_v2_blob_bytes(
5647    cache_dir: &Path,
5648    sha256: &str,
5649    expected_bytes: u64,
5650    bytes: &[u8],
5651) -> LinkResult<PathBuf> {
5652    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5653        return Err(invalid_feed("v2 cached blob differs from its declaration"));
5654    }
5655    let path = cache_dir.join(sha256);
5656    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5657        crate::fsx::write_atomic(&path, bytes)?;
5658    }
5659    Ok(path)
5660}
5661
5662#[cfg(not(any(unix, windows)))]
5663fn cache_v2_blob_bytes(
5664    _cache_dir: &Path,
5665    _sha256: &str,
5666    _expected_bytes: u64,
5667    _bytes: &[u8],
5668) -> LinkResult<PathBuf> {
5669    Err(LinkError::UnsupportedPlatform {
5670        operation: "resumable v2 download staging",
5671    })
5672}
5673
5674#[cfg(unix)]
5675fn download_presigned_to_cache(
5676    cfg: &HubConfig,
5677    url: &str,
5678    cache_dir: &Path,
5679    sha256: &str,
5680    expected_bytes: u64,
5681) -> LinkResult<PathBuf> {
5682    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5683
5684    let target = cache_dir.join(sha256);
5685    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5686        return Ok(target);
5687    }
5688    let directory = open_existing_dir_nofollow(cache_dir)?;
5689    let mut nonce = [0_u8; 16];
5690    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5691        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5692    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5693    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5694    let fd = unsafe {
5695        libc::openat(
5696            directory.as_raw_fd(),
5697            temp.as_ptr(),
5698            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5699            0o600,
5700        )
5701    };
5702    if fd < 0 {
5703        return Err(std::io::Error::last_os_error().into());
5704    }
5705    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5706    let response = match presigned_agent(cfg, url)?.get(url).call() {
5707        Ok(response) => response,
5708        Err(ureq::Error::Status(_, response)) => {
5709            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5710            return Err(LinkError::Http {
5711                what: "v2 direct download",
5712                status: response.status(),
5713                message: "object store rejected the download".to_string(),
5714                code: None,
5715                details: None,
5716            });
5717        }
5718        Err(ureq::Error::Transport(error)) => {
5719            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5720            return Err(LinkError::Transport {
5721                hub: cfg.hub.clone(),
5722                message: error.to_string(),
5723            });
5724        }
5725    };
5726    let mut reader = response
5727        .into_reader()
5728        .take(expected_bytes.saturating_add(1));
5729    let mut digest = Sha256::new();
5730    let mut total = 0_u64;
5731    let mut buffer = [0_u8; 64 * 1024];
5732    // Reading the socket and writing the file are different failures wearing
5733    // the same io::Error: a stalled download is the peer's problem and a full
5734    // disk is this machine's, and an operator who is told only "timed out"
5735    // cannot tell which one to act on.
5736    let write_result = (|| -> LinkResult<()> {
5737        loop {
5738            let read = reader
5739                .read(&mut buffer)
5740                .map_err(|error| LinkError::Transport {
5741                    hub: cfg.hub.clone(),
5742                    message: error.to_string(),
5743                })?;
5744            if read == 0 {
5745                break;
5746            }
5747            total = total.saturating_add(read as u64);
5748            digest.update(&buffer[..read]);
5749            output.write_all(&buffer[..read])?;
5750        }
5751        output.sync_all().map_err(LinkError::from)
5752    })();
5753    if let Err(error) = write_result {
5754        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5755        return Err(error);
5756    }
5757    drop(output);
5758    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5759        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5760        return Err(invalid_feed(
5761            "v2 direct download failed integrity verification",
5762        ));
5763    }
5764    let target_name = c_name(sha256.as_bytes(), sha256)?;
5765    // Replace only a previously inspected cache leaf. Cache bytes are private
5766    // and content-addressed; final install re-verifies them again.
5767    if unsafe {
5768        libc::renameat(
5769            directory.as_raw_fd(),
5770            temp.as_ptr(),
5771            directory.as_raw_fd(),
5772            target_name.as_ptr(),
5773        )
5774    } != 0
5775    {
5776        let error = std::io::Error::last_os_error();
5777        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5778        return Err(error.into());
5779    }
5780    directory.sync_all()?;
5781    Ok(target)
5782}
5783
5784#[cfg(windows)]
5785fn download_presigned_to_cache(
5786    cfg: &HubConfig,
5787    url: &str,
5788    cache_dir: &Path,
5789    sha256: &str,
5790    expected_bytes: u64,
5791) -> LinkResult<PathBuf> {
5792    use std::fs::OpenOptions;
5793
5794    let target = cache_dir.join(sha256);
5795    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5796        return Ok(target);
5797    }
5798    // Keep the checked directory handle alive while the private cache path is
5799    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
5800    // an ancestor can be swapped after validation.
5801    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
5802    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
5803    let mut output = OpenOptions::new()
5804        .write(true)
5805        .create_new(true)
5806        .open(&temp)?;
5807    let response = match presigned_agent(cfg, url)?.get(url).call() {
5808        Ok(response) => response,
5809        Err(ureq::Error::Status(_, response)) => {
5810            let _ = std::fs::remove_file(&temp);
5811            return Err(LinkError::Http {
5812                what: "v2 direct download",
5813                status: response.status(),
5814                message: "object store rejected the download".to_string(),
5815                code: None,
5816                details: None,
5817            });
5818        }
5819        Err(ureq::Error::Transport(error)) => {
5820            let _ = std::fs::remove_file(&temp);
5821            return Err(LinkError::Transport {
5822                hub: cfg.hub.clone(),
5823                message: error.to_string(),
5824            });
5825        }
5826    };
5827    let mut reader = response
5828        .into_reader()
5829        .take(expected_bytes.saturating_add(1));
5830    let mut digest = Sha256::new();
5831    let mut total = 0_u64;
5832    let mut buffer = [0_u8; 64 * 1024];
5833    // Same split as the cache-fill path: a stalled socket is not a disk error.
5834    let copied = (|| -> LinkResult<()> {
5835        loop {
5836            let read = reader
5837                .read(&mut buffer)
5838                .map_err(|error| LinkError::Transport {
5839                    hub: cfg.hub.clone(),
5840                    message: error.to_string(),
5841                })?;
5842            if read == 0 {
5843                break;
5844            }
5845            total = total.saturating_add(read as u64);
5846            digest.update(&buffer[..read]);
5847            output.write_all(&buffer[..read])?;
5848        }
5849        output.sync_all()?;
5850        Ok(())
5851    })();
5852    if let Err(error) = copied {
5853        let _ = std::fs::remove_file(&temp);
5854        return Err(error);
5855    }
5856    drop(output);
5857    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5858        let _ = std::fs::remove_file(&temp);
5859        return Err(invalid_feed(
5860            "v2 direct download failed integrity verification",
5861        ));
5862    }
5863    if target.exists() {
5864        std::fs::remove_file(&target)?;
5865    }
5866    if let Err(error) = std::fs::rename(&temp, &target) {
5867        let _ = std::fs::remove_file(&temp);
5868        return Err(error.into());
5869    }
5870    Ok(target)
5871}
5872
5873#[cfg(not(any(unix, windows)))]
5874fn download_presigned_to_cache(
5875    _cfg: &HubConfig,
5876    _url: &str,
5877    _cache_dir: &Path,
5878    _sha256: &str,
5879    _expected_bytes: u64,
5880) -> LinkResult<PathBuf> {
5881    Err(LinkError::UnsupportedPlatform {
5882        operation: "resumable v2 download staging",
5883    })
5884}
5885
5886fn download_v2_blobs(
5887    cfg: &HubConfig,
5888    brain: &str,
5889    pointer: &V2PointerBody,
5890    pending: Vec<(&String, &V2BaselineFile)>,
5891) -> LinkResult<Vec<(String, Vec<u8>)>> {
5892    if pending.is_empty() {
5893        return Ok(Vec::new());
5894    }
5895    let expected_order = pending
5896        .iter()
5897        .map(|(path, _)| (*path).clone())
5898        .collect::<Vec<_>>();
5899    let mut streamed = std::collections::BTreeMap::new();
5900    let mut direct = Vec::new();
5901    let mut window = Vec::new();
5902    let mut window_bytes = 0_u64;
5903    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5904                 window_bytes: &mut u64,
5905                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5906     -> LinkResult<()> {
5907        if window.is_empty() {
5908            return Ok(());
5909        }
5910        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5911            if streamed.insert(path, bytes).is_some() {
5912                return Err(invalid_feed("v2 bulk streams repeated a path"));
5913            }
5914        }
5915        window.clear();
5916        *window_bytes = 0;
5917        Ok(())
5918    };
5919    for &(path, file) in &pending {
5920        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5921            flush(&mut window, &mut window_bytes, &mut streamed)?;
5922            direct.push((path, file));
5923            continue;
5924        }
5925        if window.len() == V2_BULK_STREAM_FILES
5926            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5927        {
5928            flush(&mut window, &mut window_bytes, &mut streamed)?;
5929        }
5930        window.push((path, file));
5931        window_bytes += file.bytes;
5932    }
5933    flush(&mut window, &mut window_bytes, &mut streamed)?;
5934
5935    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5936    let next = std::sync::atomic::AtomicUsize::new(0);
5937    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5938    let mut results = std::iter::repeat_with(|| None)
5939        .take(downloads.len())
5940        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5941    std::thread::scope(|scope| {
5942        let (sender, receiver) = std::sync::mpsc::channel();
5943        for _ in 0..worker_count {
5944            let sender = sender.clone();
5945            let downloads = &downloads;
5946            let next = &next;
5947            scope.spawn(move || loop {
5948                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5949                let Some(item) = downloads.get(index) else {
5950                    break;
5951                };
5952                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5953                if sender.send((index, result)).is_err() {
5954                    break;
5955                }
5956            });
5957        }
5958        drop(sender);
5959        for (index, result) in receiver {
5960            results[index] = Some(result);
5961        }
5962    });
5963    for result in results.into_iter().map(|result| {
5964        result.ok_or_else(|| LinkError::Transport {
5965            hub: cfg.hub.clone(),
5966            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5967        })?
5968    }) {
5969        let (path, bytes) = result?;
5970        if streamed.insert(path, bytes).is_some() {
5971            return Err(invalid_feed("v2 download lanes repeated a path"));
5972        }
5973    }
5974    expected_order
5975        .into_iter()
5976        .map(|path| {
5977            streamed
5978                .remove(&path)
5979                .map(|bytes| (path, bytes))
5980                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5981        })
5982        .collect()
5983}
5984
5985/// Resume an exact-head materialization into the private global cache. Each
5986/// verified frame is durable before the next network window begins, so a
5987/// retry skips it and memory never grows with total changed bytes.
5988#[cfg(any(unix, windows))]
5989fn stage_v2_blobs(
5990    cfg: &HubConfig,
5991    brain: &str,
5992    pointer: &V2PointerBody,
5993    pending: Vec<(&String, &V2BaselineFile)>,
5994) -> LinkResult<Vec<V2StagedFile>> {
5995    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
5996    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
5997    let mut direct = Vec::new();
5998    let mut window = Vec::new();
5999    let mut window_bytes = 0_u64;
6000    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6001                 window_bytes: &mut u64,
6002                 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6003     -> LinkResult<()> {
6004        if window.is_empty() {
6005            return Ok(());
6006        }
6007        let missing = window
6008            .iter()
6009            .filter_map(|(path, file)| {
6010                let target = cache_dir.join(&file.sha256);
6011                match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6012                    Ok(true) => {
6013                        staged.insert(
6014                            (*path).clone(),
6015                            V2StagedFile {
6016                                path: (*path).clone(),
6017                                source: target,
6018                                sha256: file.sha256.clone(),
6019                                bytes: file.bytes,
6020                            },
6021                        );
6022                        None
6023                    }
6024                    Ok(false) => Some(Ok((*path, *file))),
6025                    Err(error) => Some(Err(error)),
6026                }
6027            })
6028            .collect::<LinkResult<Vec<_>>>()?;
6029        if !missing.is_empty() {
6030            for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6031                let file = missing
6032                    .iter()
6033                    .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6034                    .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6035                let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6036                staged.insert(
6037                    path.clone(),
6038                    V2StagedFile {
6039                        path,
6040                        source,
6041                        sha256: file.sha256.clone(),
6042                        bytes: file.bytes,
6043                    },
6044                );
6045            }
6046        }
6047        window.clear();
6048        *window_bytes = 0;
6049        Ok(())
6050    };
6051    for &(path, file) in &pending {
6052        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6053            flush(&mut window, &mut window_bytes, &mut staged)?;
6054            direct.push((path, file));
6055            continue;
6056        }
6057        if window.len() == V2_BULK_STREAM_FILES
6058            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6059        {
6060            flush(&mut window, &mut window_bytes, &mut staged)?;
6061        }
6062        window.push((path, file));
6063        window_bytes += file.bytes;
6064    }
6065    flush(&mut window, &mut window_bytes, &mut staged)?;
6066    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6067        let source =
6068            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6069        staged.insert(
6070            item.path.clone(),
6071            V2StagedFile {
6072                path: item.path,
6073                source,
6074                sha256: item.sha256,
6075                bytes: item.bytes,
6076            },
6077        );
6078    }
6079    pending
6080        .into_iter()
6081        .map(|(path, _)| {
6082            staged
6083                .remove(path)
6084                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6085        })
6086        .collect()
6087}
6088
6089#[cfg(not(any(unix, windows)))]
6090fn stage_v2_blobs(
6091    _cfg: &HubConfig,
6092    _brain: &str,
6093    _pointer: &V2PointerBody,
6094    _pending: Vec<(&String, &V2BaselineFile)>,
6095) -> LinkResult<Vec<V2StagedFile>> {
6096    Err(LinkError::UnsupportedPlatform {
6097        operation: "resumable v2 download staging",
6098    })
6099}
6100
6101const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6102const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6103const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6104
6105#[derive(Debug, Clone, Deserialize, Serialize)]
6106struct V2ConflictCoordinate {
6107    sha256: Option<String>,
6108    bytes: Option<u64>,
6109    file: Option<String>,
6110}
6111
6112#[derive(Debug, Clone, Deserialize, Serialize)]
6113struct V2ConflictFile {
6114    path: String,
6115    base: V2ConflictCoordinate,
6116    local: V2ConflictCoordinate,
6117    remote: V2ConflictCoordinate,
6118}
6119
6120#[derive(Debug, Clone, Deserialize, Serialize)]
6121struct V2ConflictPlan {
6122    v: u8,
6123    class: String,
6124    bundle: String,
6125    brain: String,
6126    origin: String,
6127    created_unix: u64,
6128    expires_unix: u64,
6129    base_seq: Option<u64>,
6130    base_commit: Option<String>,
6131    remote_seq: u64,
6132    remote_commit: Option<String>,
6133    remote_content_root: Option<String>,
6134    view_kind: String,
6135    view_revision: String,
6136    files: Vec<V2ConflictFile>,
6137}
6138
6139fn v2_take_remote_selection(
6140    files: &[V2ConflictFile],
6141    current: &std::collections::BTreeMap<String, V2BaselineFile>,
6142) -> LinkResult<(
6143    std::collections::BTreeMap<String, V2BaselineFile>,
6144    Vec<String>,
6145)> {
6146    let mut selected = std::collections::BTreeMap::new();
6147    let mut deleted = Vec::new();
6148    for file in files {
6149        match (&file.remote.sha256, file.remote.bytes) {
6150            (Some(sha256), Some(bytes)) => {
6151                let proven = current.get(&file.path).ok_or_else(|| {
6152                    invalid_feed("conflict remote coordinate disappeared from the exact head")
6153                })?;
6154                if proven.sha256 != *sha256 || proven.bytes != bytes {
6155                    return Err(invalid_feed(
6156                        "conflict remote coordinate differs from the exact head",
6157                    ));
6158                }
6159                if selected.insert(file.path.clone(), proven.clone()).is_some() {
6160                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6161                }
6162            }
6163            (None, None) => {
6164                if current.contains_key(&file.path) {
6165                    return Err(invalid_feed(
6166                        "conflict remote deletion differs from the exact head",
6167                    ));
6168                }
6169                deleted.push(file.path.clone());
6170            }
6171            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6172        }
6173    }
6174    Ok((selected, deleted))
6175}
6176
6177fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6178    PathBuf::from(".dbmd")
6179        .join("conflicts")
6180        .join(bundle)
6181        .join(suffix)
6182}
6183
6184fn read_historical_conflict_blob(
6185    cfg: &HubConfig,
6186    brain: &str,
6187    baseline: &V2SyncBaseline,
6188    path: &str,
6189    file: &V2BaselineFile,
6190) -> LinkResult<Option<Vec<u8>>> {
6191    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6192        return Ok(None);
6193    };
6194    if seq == 0 {
6195        return Ok(None);
6196    }
6197    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6198    let endpoint = format!(
6199        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6200        file.sha256
6201    );
6202    let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6203    if raw.status == 404 || raw.status == 403 {
6204        return Ok(None);
6205    }
6206    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6207    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6208        return Err(invalid_feed(
6209            "v2 conflict base failed integrity verification",
6210        ));
6211    }
6212    Ok(Some(bytes))
6213}
6214
6215/// Persist a readable content conflict without modifying either side. The
6216/// plan is written last, so an interrupted bundle build is never actionable.
6217fn create_v2_conflict_bundle(
6218    cfg: &HubConfig,
6219    store: &Store,
6220    head: &V2VerifiedHead,
6221    baseline: Option<&V2SyncBaseline>,
6222    local: &std::collections::BTreeMap<String, (String, u64)>,
6223    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6224    paths: &[String],
6225) -> LinkResult<(String, Vec<String>)> {
6226    let conflicts_root = Path::new(".dbmd/conflicts");
6227    store.create_dir_all(conflicts_root)?;
6228    let completed = store
6229        .directory_names(conflicts_root)?
6230        .into_iter()
6231        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6232        .count();
6233    if completed >= V2_CONFLICT_BUNDLE_MAX {
6234        return Err(LinkError::InvalidPack {
6235            message: format!(
6236                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6237            ),
6238        });
6239    }
6240
6241    // A conflict bundle is diagnostic state, not a second whole-brain cache.
6242    // Materialize a deterministic prefix whose remote artifacts fit one
6243    // bounded window. Resolving it exposes the next prefix on the next sync.
6244    let mut selected_paths = Vec::new();
6245    let mut selected_remote_bytes = 0_u64;
6246    for path in paths {
6247        let bytes = remote.get(path).map_or(0, |file| file.bytes);
6248        if !selected_paths.is_empty()
6249            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6250        {
6251            break;
6252        }
6253        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6254        selected_paths.push(path.clone());
6255        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6256            break;
6257        }
6258    }
6259    if selected_paths.is_empty() {
6260        return Err(invalid_feed("content conflict set is empty"));
6261    }
6262    let bundle = crate::ulid::mint();
6263    let bundle_root = v2_conflict_relative(&bundle, "");
6264    store.create_dir_all(&bundle_root.join("files"))?;
6265    let pointer = head.pointer.as_ref();
6266    let remote_bytes = match pointer {
6267        Some(pointer) => download_v2_blobs(
6268            cfg,
6269            &head.brain_id,
6270            pointer,
6271            selected_paths
6272                .iter()
6273                .filter_map(|path| {
6274                    remote
6275                        .get(path)
6276                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6277                        .map(|file| (path, file))
6278                })
6279                .collect(),
6280        )?
6281        .into_iter()
6282        .collect::<std::collections::BTreeMap<_, _>>(),
6283        None => std::collections::BTreeMap::new(),
6284    };
6285
6286    let mut files = Vec::with_capacity(selected_paths.len());
6287    for (index, path) in selected_paths.iter().enumerate() {
6288        let base_file = baseline.and_then(|state| state.files.get(path));
6289        let base_bytes = match (baseline, base_file) {
6290            (Some(state), Some(file)) => {
6291                read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6292            }
6293            _ => None,
6294        };
6295        let local_file = local.get(path);
6296        let remote_file = remote.get(path);
6297        let remote_content = remote_bytes.get(path);
6298        let prefix = format!("files/{index:04}");
6299        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6300        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6301        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6302        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6303            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6304        }
6305        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6306            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6307            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6308                return Err(LinkError::InvalidPack {
6309                    message: format!("local conflict path `{path}` changed while bundling"),
6310                });
6311            }
6312            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6313        }
6314        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6315            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6316        }
6317        files.push(V2ConflictFile {
6318            path: path.clone(),
6319            base: V2ConflictCoordinate {
6320                sha256: base_file.map(|file| file.sha256.clone()),
6321                bytes: base_file.map(|file| file.bytes),
6322                file: base_name,
6323            },
6324            local: V2ConflictCoordinate {
6325                sha256: local_file.map(|(sha256, _)| sha256.clone()),
6326                bytes: local_file.map(|(_, bytes)| *bytes),
6327                file: local_name,
6328            },
6329            remote: V2ConflictCoordinate {
6330                sha256: remote_file.map(|file| file.sha256.clone()),
6331                bytes: remote_file.map(|file| file.bytes),
6332                file: remote_name,
6333            },
6334        });
6335    }
6336    let now = SystemTime::now()
6337        .duration_since(UNIX_EPOCH)
6338        .unwrap_or_default()
6339        .as_secs();
6340    let plan = V2ConflictPlan {
6341        v: 2,
6342        class: "content_resolution_required".to_string(),
6343        bundle: bundle.clone(),
6344        brain: head.brain_id.clone(),
6345        origin: normalized_origin(&cfg.hub)?,
6346        created_unix: now,
6347        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6348        base_seq: baseline.and_then(|state| state.head_seq),
6349        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6350        remote_seq: pointer.map_or(0, |value| value.seq),
6351        remote_commit: pointer.map(|value| value.commit_hash.clone()),
6352        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6353        view_kind: head.view_kind.clone(),
6354        view_revision: head.view_revision.clone(),
6355        files,
6356    };
6357    let mut bytes = serde_json::to_vec_pretty(&plan)
6358        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6359    bytes.push(b'\n');
6360    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6361    Ok((bundle, selected_paths))
6362}
6363
6364fn v2_sync_pull_with_resolution(
6365    cfg: &HubConfig,
6366    requested_brain: &str,
6367    expected_head: V2VerifiedHead,
6368    out: Option<&Path>,
6369    take_remote: Option<&std::collections::BTreeSet<String>>,
6370) -> LinkResult<V2PulledSnapshot> {
6371    let dest = out
6372        .map(Path::to_path_buf)
6373        .unwrap_or_else(|| PathBuf::from(requested_brain));
6374    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6375    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6376    let head = v2_verified_head(cfg, requested_brain)?
6377        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6378    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6379        return Err(LinkError::RemoteAdvancedDuringSync);
6380    }
6381    let remote = files_for_v2_view(
6382        &head,
6383        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6384    );
6385    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
6386    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6387    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6388    let local_store = Store::open_strict(&dest).ok();
6389    // A baseline proves this path is an established checkout. Never reinterpret
6390    // a damaged/edited store marker as an empty clone destination: doing so
6391    // would let the pull half erase the local evidence before the push half can
6392    // reject it (most importantly for the generated scoped-view DB.md).
6393    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6394    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6395    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6396        return Err(LinkError::ScopedViewChanged);
6397    }
6398    if let Some(view) = local_view.as_mut() {
6399        remove_scoped_projection(&head, baseline.as_ref(), view)?;
6400    }
6401    let empty_local = std::collections::BTreeMap::new();
6402    let local = local_view
6403        .as_ref()
6404        .map_or(&empty_local, |view| &view.riding);
6405    let kept_home = |path: &str| {
6406        local_view
6407            .as_ref()
6408            .is_some_and(|view| view.policy.keeps_home(path))
6409    };
6410    let empty_base = std::collections::BTreeMap::new();
6411    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6412    let empty_base_assets = std::collections::BTreeMap::new();
6413    let base_assets = baseline
6414        .as_ref()
6415        .map_or(&empty_base_assets, |state| &state.assets);
6416    let mut local_assets = local_store
6417        .as_ref()
6418        .map(v2_local_asset_records)
6419        .transpose()?
6420        .unwrap_or_default();
6421    let mut content_merge = merge_v2_pulled_records(
6422        base,
6423        &remote,
6424        local,
6425        |file, _| (file.sha256.clone(), file.bytes),
6426        |file, _| (file.sha256.clone(), file.bytes),
6427        kept_home,
6428    );
6429    if let Some(selected) = take_remote {
6430        for path in selected {
6431            if let Some(position) = content_merge
6432                .conflicts
6433                .iter()
6434                .position(|conflict| conflict == path)
6435            {
6436                content_merge.conflicts.remove(position);
6437                content_merge.accept_remote.insert(path.clone());
6438                match remote.get(path) {
6439                    Some(file) => {
6440                        content_merge
6441                            .records
6442                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
6443                    }
6444                    None => {
6445                        content_merge.records.remove(path);
6446                    }
6447                }
6448            } else if !content_merge.accept_remote.contains(path) {
6449                return Err(LinkError::InvalidPack {
6450                    message: format!(
6451                        "take-remote path `{path}` is no longer at its conflict coordinate"
6452                    ),
6453                });
6454            }
6455        }
6456    }
6457    if !content_merge.conflicts.is_empty() {
6458        let mut conflicts = content_merge.conflicts.clone();
6459        conflicts.truncate(100);
6460        if let Some(store) = local_store.as_ref() {
6461            let (bundle, paths) = create_v2_conflict_bundle(
6462                cfg,
6463                store,
6464                &head,
6465                baseline.as_ref(),
6466                local,
6467                &remote,
6468                &conflicts,
6469            )?;
6470            return Err(LinkError::ConflictBundle { bundle, paths });
6471        }
6472        return Err(LinkError::Conflict { paths: conflicts });
6473    }
6474    let asset_merge = merge_v2_pulled_records(
6475        base_assets,
6476        &remote_assets,
6477        &local_assets,
6478        v2_asset_record,
6479        v2_asset_record,
6480        |_| false,
6481    );
6482    if !asset_merge.conflicts.is_empty() {
6483        let mut conflicts = asset_merge.conflicts.clone();
6484        conflicts.truncate(100);
6485        return Err(LinkError::Conflict { paths: conflicts });
6486    }
6487    let pointer = head.pointer.as_ref();
6488    let cache_transaction = pointer.map_or_else(
6489        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6490        |value| value.commit_hash.clone(),
6491    );
6492    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6493    let mut changed = match pointer {
6494        Some(pointer) => stage_v2_blobs(
6495            cfg,
6496            &head.brain_id,
6497            pointer,
6498            remote
6499                .iter()
6500                .filter(|(path, file)| {
6501                    content_merge.accept_remote.contains(*path)
6502                        && local.get(*path).map(|value| value.0.as_str())
6503                            != Some(file.sha256.as_str())
6504                })
6505                .collect(),
6506        )?,
6507        None => Vec::new(),
6508    };
6509    let mut deleted = content_merge
6510        .accept_remote
6511        .iter()
6512        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6513        .cloned()
6514        .collect::<Vec<_>>();
6515    if local_assets != asset_merge.records {
6516        if asset_merge.records.is_empty() {
6517            deleted.push("assets.jsonl".to_string());
6518        } else {
6519            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6520            let sha256 = content_sha256(&bytes);
6521            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6522            changed.push(V2StagedFile {
6523                path: "assets.jsonl".to_string(),
6524                source,
6525                sha256,
6526                bytes: bytes.len() as u64,
6527            });
6528        }
6529    }
6530    if let Some(pointer) = pointer {
6531        let mut pending_assets = Vec::new();
6532        for (path, asset) in &remote_assets {
6533            if asset.disposition != "hosted"
6534                || kept_home(path)
6535                || !asset_merge.accept_remote.contains(path)
6536            {
6537                continue;
6538            }
6539            let already_current = local_store.as_ref().is_some_and(|store| {
6540                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6541                    && store
6542                        .read_bounded(Path::new(path), asset.bytes)
6543                        .ok()
6544                        .is_some_and(|bytes| {
6545                            bytes.len() as u64 == asset.bytes
6546                                && content_sha256(&bytes) == asset.blob_sha256
6547                        })
6548            });
6549            if !already_current {
6550                pending_assets.push((path, asset));
6551            }
6552        }
6553        for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
6554            let source =
6555                download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6556            changed.push(V2StagedFile {
6557                path: item.path,
6558                source,
6559                sha256: item.sha256,
6560                bytes: item.bytes,
6561            });
6562        }
6563    }
6564    for (path, prior) in base_assets {
6565        if remote_assets.contains_key(path)
6566            || kept_home(path)
6567            || !asset_merge.accept_remote.contains(path)
6568        {
6569            continue;
6570        }
6571        let unchanged = local_store.as_ref().is_some_and(|store| {
6572            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6573                && store
6574                    .read_bounded(Path::new(path), prior.bytes)
6575                    .ok()
6576                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6577        });
6578        if unchanged {
6579            deleted.push(path.clone());
6580        }
6581    }
6582    let extra_local = content_merge
6583        .records
6584        .keys()
6585        .filter(|path| !remote.contains_key(*path))
6586        .cloned()
6587        .collect::<Vec<_>>();
6588    if head.view_kind == "scoped" {
6589        for (path, bytes) in [
6590            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6591            (
6592                ".dbmd/view.json".to_string(),
6593                scoped_view_metadata(&head, remote.len())?,
6594            ),
6595        ] {
6596            let sha256 = content_sha256(&bytes);
6597            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6598            changed.push(V2StagedFile {
6599                path,
6600                source,
6601                sha256,
6602                bytes: bytes.len() as u64,
6603            });
6604        }
6605    }
6606    let install_changed = !changed.is_empty() || !deleted.is_empty();
6607    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6608    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6609        let installed_store =
6610            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6611                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6612            })?;
6613        let installed_local = if install_changed {
6614            let mut scanned = v2_local_files(&installed_store)?;
6615            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6616            scanned
6617        } else {
6618            local_view
6619                .take()
6620                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6621        };
6622        if installed_local.riding != content_merge.records {
6623            return Err(LinkError::InvalidPack {
6624                message: "local content changed while installing the v2 pull".to_string(),
6625            });
6626        }
6627        let installed_assets = if install_changed {
6628            v2_local_asset_records(&installed_store)?
6629        } else {
6630            std::mem::take(&mut local_assets)
6631        };
6632        if installed_assets != asset_merge.records {
6633            return Err(LinkError::InvalidPack {
6634                message: "local assets changed while installing the v2 pull".to_string(),
6635            });
6636        }
6637        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6638            installed_local.policy.keeps_home(path)
6639        })
6640            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6641        let final_head = v2_verified_head(cfg, requested_brain)?
6642            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6643        if !same_v2_head(&head, &final_head) {
6644            return Err(LinkError::RemoteAdvancedDuringSync);
6645        }
6646        accept_v2_head(cfg, &final_head)?;
6647        save_v2_baseline(
6648            cfg,
6649            &head.brain_id,
6650            &dest,
6651            &v2_baseline_from_head(
6652                cfg,
6653                &head,
6654                remote.clone(),
6655                remote_assets.clone(),
6656                Some(&installed_local),
6657                baseline
6658                    .as_ref()
6659                    .and_then(|current| current.checkout_id.as_deref()),
6660            )?,
6661        )?;
6662        complete_v2_pull(&dest)?;
6663        Ok((local_dirty, installed_local, installed_assets))
6664    })();
6665    let (local_dirty, installed_local, installed_assets) = match finalized {
6666        Ok(value) => value,
6667        Err(error) => {
6668            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6669                return Err(LinkError::InvalidPack {
6670                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
6671                });
6672            }
6673            return Err(error);
6674        }
6675    };
6676    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6677    let report = PullReport {
6678        brain: head.brain_id.clone(),
6679        slug: requested_brain.to_string(),
6680        head_seq: pointer.map_or(0, |value| value.seq),
6681        files: remote.len() + remote_assets.len(),
6682        dest: dest.to_string_lossy().into_owned(),
6683        extra_local,
6684        sync_status: if local_dirty {
6685            "local_dirty_after_install".to_string()
6686        } else {
6687            "synced".to_string()
6688        },
6689    };
6690    Ok(V2PulledSnapshot {
6691        report,
6692        head,
6693        files: remote,
6694        assets: remote_assets,
6695        local: installed_local,
6696        local_assets: installed_assets,
6697    })
6698}
6699
6700fn v2_sync_pull(
6701    cfg: &HubConfig,
6702    requested_brain: &str,
6703    head: V2VerifiedHead,
6704    out: Option<&Path>,
6705) -> LinkResult<PullReport> {
6706    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6707}
6708
6709fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6710    match remote {
6711        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6712        None => json!({ "kind": "absent" }),
6713    }
6714}
6715
6716fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
6717    match remote {
6718        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
6719        None => json!({ "kind": "absent" }),
6720    }
6721}
6722
6723fn v2_content_withdrawal_operation(
6724    store: &Store,
6725    local_view: &V2LocalView,
6726    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6727    path: &str,
6728    reason: &str,
6729) -> LinkResult<Value> {
6730    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
6731        || path == "DB.md"
6732    {
6733        return Err(LinkError::InvalidPack {
6734            message: format!("content withdrawal path `{path}` is not a record or source"),
6735        });
6736    }
6737    if !local_view.policy.keeps_home(path)
6738        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6739    {
6740        return Err(LinkError::InvalidPack {
6741            message: format!(
6742                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6743            ),
6744        });
6745    }
6746    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
6747        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
6748    })?;
6749    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
6750    Ok(json!({
6751        "op": "withdraw_from_hosting",
6752        "path": path,
6753        "expected": { "kind": "blob", "hash": current.sha256 },
6754        "reason": reason,
6755    }))
6756}
6757
6758fn v2_asset_withdrawal_operation(
6759    store: &Store,
6760    local_view: &V2LocalView,
6761    path: &str,
6762    local: &crate::AssetRecord,
6763    current: &V2BaselineAsset,
6764    reason: &str,
6765) -> LinkResult<Value> {
6766    if !local_view.policy.keeps_home(path)
6767        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6768    {
6769        return Err(LinkError::InvalidPack {
6770            message: format!(
6771                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6772            ),
6773        });
6774    }
6775    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
6776    if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
6777        return Err(LinkError::InvalidPack {
6778            message: format!(
6779                "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
6780            ),
6781        });
6782    }
6783    Ok(json!({
6784        "op": "asset_withdraw",
6785        "path": path,
6786        "expected": v2_asset_expected(Some(current)),
6787        "reason": reason,
6788    }))
6789}
6790
6791// A source is immutable, but its exact bytes may move from an intake namespace
6792// to an archive namespace under the distinct `promote_source` capability. A
6793// plain filesystem move appears to sync as one absent old path plus one new
6794// path. Preserve that intent as a protocol rename only when the hash has one
6795// unambiguous source delete and one unambiguous absent-destination source put.
6796// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
6797fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
6798    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
6799    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
6800    for (index, operation) in operations.iter().enumerate() {
6801        match operation.get("op").and_then(Value::as_str) {
6802            Some("delete") => {
6803                let Some(path) = operation.get("path").and_then(Value::as_str) else {
6804                    continue;
6805                };
6806                let Some(hash) = operation
6807                    .get("expected")
6808                    .and_then(|value| value.get("hash"))
6809                    .and_then(Value::as_str)
6810                else {
6811                    continue;
6812                };
6813                if path.starts_with("sources/") {
6814                    deletes
6815                        .entry(hash.to_string())
6816                        .or_default()
6817                        .push((index, path.to_string()));
6818                }
6819            }
6820            Some("put") => {
6821                let Some(path) = operation.get("path").and_then(Value::as_str) else {
6822                    continue;
6823                };
6824                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
6825                    continue;
6826                };
6827                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
6828                    continue;
6829                };
6830                let destination_absent = operation
6831                    .get("expected")
6832                    .and_then(|value| value.get("kind"))
6833                    .and_then(Value::as_str)
6834                    == Some("absent");
6835                if path.starts_with("sources/") && destination_absent {
6836                    puts.entry(hash.to_string()).or_default().push((
6837                        index,
6838                        path.to_string(),
6839                        bytes,
6840                    ));
6841                }
6842            }
6843            _ => {}
6844        }
6845    }
6846    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
6847    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
6848    for (hash, source) in deletes {
6849        let Some(destination) = puts.get(&hash) else {
6850            continue;
6851        };
6852        if source.len() != 1 || destination.len() != 1 {
6853            continue;
6854        }
6855        let (delete_index, from) = &source[0];
6856        let (put_index, to, bytes) = &destination[0];
6857        if from == to {
6858            continue;
6859        }
6860        rename_at.insert(
6861            *delete_index,
6862            json!({
6863                "op": "rename",
6864                "from": from,
6865                "to": to,
6866                "expected_from": { "kind": "blob", "hash": hash },
6867                "expected_to": { "kind": "absent" },
6868                "blob": hash,
6869                "bytes": bytes,
6870            }),
6871        );
6872        consumed_puts.insert(*put_index);
6873    }
6874    operations
6875        .into_iter()
6876        .enumerate()
6877        .filter_map(|(index, operation)| {
6878            if let Some(rename) = rename_at.remove(&index) {
6879                Some(rename)
6880            } else if consumed_puts.contains(&index) {
6881                None
6882            } else {
6883                Some(operation)
6884            }
6885        })
6886        .collect()
6887}
6888
6889fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
6890    json!({
6891        "blob_sha256": record.sha256,
6892        "bytes": record.bytes,
6893        "media_type": record.media_type,
6894        "wrappers": record.wrappers,
6895        "required": record.required,
6896        "disposition": disposition,
6897    })
6898}
6899
6900/// Apply the exact operation subset generated by this client to an already
6901/// verified manifest. This avoids downloading the same 50k-path listing again
6902/// after an unrebased commit; a rebased response still re-reads the manifest.
6903fn apply_generated_v2_operations(
6904    operations: &[Value],
6905    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6906    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6907    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6908) -> LinkResult<bool> {
6909    let mut asset_changed = false;
6910    for operation in operations {
6911        match operation.get("op").and_then(Value::as_str) {
6912            Some("put") => {
6913                let path = operation
6914                    .get("path")
6915                    .and_then(Value::as_str)
6916                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6917                let sha256 = operation
6918                    .get("blob")
6919                    .and_then(Value::as_str)
6920                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6921                let bytes = operation
6922                    .get("bytes")
6923                    .and_then(Value::as_u64)
6924                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6925                candidate.insert(
6926                    path.to_string(),
6927                    V2BaselineFile {
6928                        sha256: sha256.to_string(),
6929                        bytes,
6930                        proof: None,
6931                    },
6932                );
6933            }
6934            Some("rename") => {
6935                let from = operation
6936                    .get("from")
6937                    .and_then(Value::as_str)
6938                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
6939                let to = operation
6940                    .get("to")
6941                    .and_then(Value::as_str)
6942                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
6943                let sha256 = operation
6944                    .get("blob")
6945                    .and_then(Value::as_str)
6946                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
6947                let bytes = operation
6948                    .get("bytes")
6949                    .and_then(Value::as_u64)
6950                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
6951                let expected_from = operation
6952                    .get("expected_from")
6953                    .and_then(|expected| expected.get("hash"))
6954                    .and_then(Value::as_str);
6955                let expected_to_absent = operation
6956                    .get("expected_to")
6957                    .and_then(|expected| expected.get("kind"))
6958                    .and_then(Value::as_str)
6959                    == Some("absent");
6960                if from == to
6961                    || !from.starts_with("sources/")
6962                    || !to.starts_with("sources/")
6963                    || expected_from != Some(sha256)
6964                    || !expected_to_absent
6965                    || candidate.contains_key(to)
6966                {
6967                    return Err(invalid_feed("generated v2 source rename is malformed"));
6968                }
6969                let source = candidate
6970                    .remove(from)
6971                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
6972                if source.sha256 != sha256 || source.bytes != bytes {
6973                    return Err(invalid_feed(
6974                        "v2 rename source differs from its exact-byte claim",
6975                    ));
6976                }
6977                candidate.insert(
6978                    to.to_string(),
6979                    V2BaselineFile {
6980                        sha256: sha256.to_string(),
6981                        bytes,
6982                        proof: None,
6983                    },
6984                );
6985            }
6986            Some("delete" | "withdraw_from_hosting") => {
6987                let path = operation
6988                    .get("path")
6989                    .and_then(Value::as_str)
6990                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
6991                candidate.remove(path);
6992            }
6993            Some("asset_delete") => {
6994                let path = operation
6995                    .get("path")
6996                    .and_then(Value::as_str)
6997                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
6998                candidate_assets.remove(path);
6999                asset_changed = true;
7000            }
7001            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7002                let path = operation
7003                    .get("path")
7004                    .and_then(Value::as_str)
7005                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7006                let record = local_assets
7007                    .get(path)
7008                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7009                let disposition =
7010                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7011                        "withheld"
7012                    } else {
7013                        operation
7014                            .get("asset")
7015                            .and_then(|asset| asset.get("disposition"))
7016                            .and_then(Value::as_str)
7017                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7018                    };
7019                candidate_assets.insert(
7020                    path.to_string(),
7021                    V2BaselineAsset {
7022                        blob_sha256: record.sha256.clone(),
7023                        bytes: record.bytes,
7024                        media_type: record.media_type.clone(),
7025                        wrappers: record.wrappers.clone(),
7026                        required: record.required,
7027                        disposition: disposition.to_string(),
7028                        // The server assigns the signed leaf address. Callers
7029                        // that need an exact asset manifest re-read it below.
7030                        leaf_hash: String::new(),
7031                    },
7032                );
7033                asset_changed = true;
7034            }
7035            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7036        }
7037    }
7038    Ok(asset_changed)
7039}
7040
7041fn v2_riding_matches_remote(
7042    local: &std::collections::BTreeMap<String, (String, u64)>,
7043    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7044    keeps_home: impl Fn(&str) -> bool,
7045) -> bool {
7046    remote.iter().all(|(path, file)| {
7047        keeps_home(path)
7048            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7049    }) && local.iter().all(|(path, (hash, _))| {
7050        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7051    })
7052}
7053
7054#[derive(Debug, Clone)]
7055struct V2ResolutionOverride {
7056    expected_remote: Option<String>,
7057    selected_local: Option<String>,
7058}
7059
7060#[derive(Debug, Clone)]
7061struct V2UploadSource {
7062    path: String,
7063    bytes: u64,
7064}
7065
7066struct V2SyncPushOptions<'a> {
7067    resume_local_policy: bool,
7068    bulk_confirmation: Option<&'a V2BulkConfirmation>,
7069    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7070    pulled: Option<V2PulledSnapshot>,
7071    withdrawal_paths: &'a [String],
7072    withdrawal_reason: Option<&'a str>,
7073}
7074
7075fn verify_v2_upload_source(
7076    store: &Store,
7077    path: &str,
7078    sha256: &str,
7079    expected_bytes: u64,
7080) -> LinkResult<()> {
7081    let file = store.open_regular(Path::new(path))?;
7082    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7083        return Err(LinkError::InvalidPack {
7084            message: format!("local path `{path}` changed during sync planning"),
7085        });
7086    }
7087    Ok(())
7088}
7089
7090/// One reserved object waiting to be staged, held until its whole batch can be
7091/// uploaded together.
7092struct V2PendingUpload<'a> {
7093    url: String,
7094    headers: Value,
7095    sha256: String,
7096    source: &'a V2UploadSource,
7097}
7098
7099/// How many objects a migration stages at once. Bounded so a first push does
7100/// not behave like a burst against the object store, while still being far
7101/// faster than one-at-a-time.
7102/// Workers staging one batch. With a shared pooled connection the cost of an
7103/// object is the PUT itself rather than a handshake, so a wider pool converts
7104/// directly into throughput on a migration.
7105const V2_UPLOAD_CONCURRENCY: usize = 16;
7106
7107/// Stage a reserved batch, uploading up to `V2_UPLOAD_CONCURRENCY` objects at a
7108/// time and verifying each against the store after it lands. The first failure
7109/// is returned and the remaining work is abandoned.
7110fn upload_v2_batch_concurrently(
7111    cfg: &HubConfig,
7112    store: &Store,
7113    pending: &[V2PendingUpload<'_>],
7114) -> LinkResult<()> {
7115    if pending.is_empty() {
7116        return Ok(());
7117    }
7118    let urls = pending
7119        .iter()
7120        .map(|task| task.url.as_str())
7121        .collect::<Vec<_>>();
7122    let shared = shared_staging_agent(cfg, &urls);
7123    if pending.len() == 1 {
7124        let task = &pending[0];
7125        put_presigned_source(
7126            cfg,
7127            &task.url,
7128            &task.headers,
7129            store,
7130            task.source,
7131            shared.as_ref(),
7132        )?;
7133        return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7134    }
7135    let next = std::sync::atomic::AtomicUsize::new(0);
7136    let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7137    let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7138    std::thread::scope(|scope| {
7139        for _ in 0..workers {
7140            scope.spawn(|| loop {
7141                if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7142                    return;
7143                }
7144                let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7145                let Some(task) = pending.get(index) else {
7146                    return;
7147                };
7148                let outcome = put_presigned_source(
7149                    cfg,
7150                    &task.url,
7151                    &task.headers,
7152                    store,
7153                    task.source,
7154                    shared.as_ref(),
7155                )
7156                .and_then(|()| {
7157                    verify_v2_upload_source(
7158                        store,
7159                        &task.source.path,
7160                        &task.sha256,
7161                        task.source.bytes,
7162                    )
7163                });
7164                if let Err(error) = outcome {
7165                    if let Ok(mut guard) = failure.lock() {
7166                        guard.get_or_insert(error);
7167                    }
7168                    return;
7169                }
7170            });
7171        }
7172    });
7173    match failure.into_inner() {
7174        Ok(Some(error)) => Err(error),
7175        Ok(None) => Ok(()),
7176        Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7177    }
7178}
7179
7180fn put_presigned_source(
7181    cfg: &HubConfig,
7182    raw: &str,
7183    headers: &Value,
7184    store: &Store,
7185    source: &V2UploadSource,
7186    shared: Option<&ureq::Agent>,
7187) -> LinkResult<()> {
7188    put_presigned_source_with_budget(
7189        cfg,
7190        raw,
7191        headers,
7192        store,
7193        source,
7194        shared,
7195        std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7196    )
7197}
7198
7199fn put_presigned_source_with_budget(
7200    cfg: &HubConfig,
7201    raw: &str,
7202    headers: &Value,
7203    store: &Store,
7204    source: &V2UploadSource,
7205    shared: Option<&ureq::Agent>,
7206    total_budget: std::time::Duration,
7207) -> LinkResult<()> {
7208    // The URL's own safety checks run either way; only the connection pool is
7209    // shared, so a batch never trades a check for a warm socket.
7210    let owned = match shared {
7211        Some(_) => {
7212            checked_presigned_url(cfg, raw)?;
7213            None
7214        }
7215        None => Some(presigned_agent(cfg, raw)?),
7216    };
7217    let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7218    let deadline = std::time::Instant::now()
7219        .checked_add(total_budget)
7220        .ok_or_else(upload_deadline_error)?;
7221    let mut attempt = 0;
7222    let result = loop {
7223        let file = store.open_regular(Path::new(&source.path))?;
7224        if file.metadata()?.len() != source.bytes {
7225            return Err(LinkError::InvalidPack {
7226                message: format!("local path `{}` changed before upload", source.path),
7227            });
7228        }
7229        // The reservation already states content-length, lowercased. The HTTP
7230        // client de-duplicates headers by exact name, not case, so setting
7231        // `Content-Length` here as well leaves both on the wire and an edge
7232        // rejects the request as malformed. Supply it only when absent.
7233        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7234        let mut has_content_length = false;
7235        if let Some(map) = headers.as_object() {
7236            for (name, value) in map {
7237                if let Some(value) = value.as_str() {
7238                    has_content_length |= name.eq_ignore_ascii_case("content-length");
7239                    req = req.set(name, value);
7240                }
7241            }
7242        }
7243        if !has_content_length {
7244            req = req.set("Content-Length", &source.bytes.to_string());
7245        }
7246        match req.send(file) {
7247            // Every transport failure is retryable here, not only the
7248            // pre-request kinds: staging an object is idempotent (fixed content
7249            // address, exact length and checksum, write-once precondition), the
7250            // file is reopened on each attempt, and a migration holds
7251            // connections long enough that a mid-request drop is ordinary.
7252            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7253                attempt += 1;
7254            }
7255            // A migration stages tens of thousands of immutable objects, and an
7256            // edge or object store may refuse a single one under sustained
7257            // load. The upload is content-addressed and precondition-guarded,
7258            // so replaying it is safe; failing the whole push on one refusal is
7259            // not. 412 already means "someone else stored these exact bytes".
7260            Err(ureq::Error::Status(status, _))
7261                if status != 412
7262                    && is_retryable_upload_status(status)
7263                    && wait_for_upload_retry(deadline, attempt) =>
7264            {
7265                attempt += 1;
7266            }
7267            result => break result,
7268        }
7269    };
7270    match result {
7271        Ok(response) if (200..300).contains(&response.status()) => {
7272            drain_presigned_response(response);
7273            Ok(())
7274        }
7275        Ok(response) => {
7276            // The object store states its own reason (checksum mismatch,
7277            // precondition, expiry). Carrying a bounded excerpt turns an
7278            // unactionable refusal into one an operator can diagnose; the
7279            // coordinate is the store path, never file content.
7280            let status = response.status();
7281            let detail = response
7282                .into_string()
7283                .ok()
7284                .map(|body| body.chars().take(400).collect::<String>())
7285                .filter(|body| !body.trim().is_empty());
7286            Err(LinkError::Http {
7287                what: "v2 changed-byte upload",
7288                status,
7289                message: match detail {
7290                    Some(body) => format!(
7291                        "object store rejected the upload of `{}`: {}",
7292                        source.path,
7293                        body.replace('\n', " ")
7294                    ),
7295                    None => format!("object store rejected the upload of `{}`", source.path),
7296                },
7297                code: None,
7298                details: None,
7299            })
7300        }
7301        Err(error) => match error {
7302            ureq::Error::Status(412, _) => Ok(()),
7303            ureq::Error::Status(_, response) => {
7304                let status = response.status();
7305                let detail = response
7306                    .into_string()
7307                    .ok()
7308                    .map(|body| body.chars().take(400).collect::<String>())
7309                    .filter(|body| !body.trim().is_empty());
7310                Err(LinkError::Http {
7311                    what: "v2 changed-byte upload",
7312                    status,
7313                    message: match detail {
7314                        Some(body) => format!(
7315                            "object store rejected the upload of `{}`: {}",
7316                            source.path,
7317                            body.replace('\n', " ")
7318                        ),
7319                        None => {
7320                            format!("object store rejected the upload of `{}`", source.path)
7321                        }
7322                    },
7323                    code: None,
7324                    details: None,
7325                })
7326            }
7327            ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7328        },
7329    }
7330}
7331
7332/// The request as it is hashed and signed. A staged push carries its operations
7333/// by address rather than inline, so the signed view restores them: the request
7334/// hash covers the change itself, never the transport that carried it.
7335fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7336    if body.get("operations").is_some() {
7337        return body.clone();
7338    }
7339    let mut value = body.clone();
7340    if let Some(map) = value.as_object_mut() {
7341        map.remove("staged_change");
7342        map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7343    }
7344    value
7345}
7346
7347/// Open one staging window, waiting out a busy hub rather than abandoning the
7348/// push. The call reserves capacity and mints URLs; it commits nothing, so
7349/// asking again after a refusal costs only the wait.
7350fn reserve_upload_window(
7351    cfg: &HubConfig,
7352    path: &str,
7353    body: &Value,
7354    what: &'static str,
7355) -> LinkResult<Value> {
7356    let mut attempt = 0;
7357    loop {
7358        let pause = |attempt: usize| {
7359            std::thread::sleep(std::time::Duration::from_millis(
7360                RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7361            ));
7362        };
7363        match request(cfg, "POST", path, Some(body), Auth::Required) {
7364            // A migration opens dozens of windows over tens of minutes, so one
7365            // response that never arrives is ordinary rather than final. The
7366            // call reserves capacity and mints URLs; it commits nothing, so a
7367            // dropped or timed-out attempt is worth asking again.
7368            Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7369                pause(attempt);
7370                attempt += 1;
7371            }
7372            Err(error) => return Err(error),
7373            Ok(response) => {
7374                if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7375                    pause(attempt);
7376                    attempt += 1;
7377                    continue;
7378                }
7379                return ensure_ok(response, what);
7380            }
7381        }
7382    }
7383}
7384
7385/// The exact bytes of a staged change. The hub refuses a manifest carrying any
7386/// other key, so this is the whole contract: operations, and the blob
7387/// references they need, and nothing else.
7388fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7389    let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7390        .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7391    if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7392        return Err(LinkError::PushTooLarge {
7393            detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7394        });
7395    }
7396    Ok(bytes)
7397}
7398
7399/// State one change's operations and blob references through the same
7400/// content-addressed lane its bytes already ride.
7401///
7402/// A first push of a real store names one operation and one blob reference per
7403/// file, which alone outgrows the bounded commit request. Staging that metadata
7404/// and addressing it by hash keeps the push a single atomic commit: the hub
7405/// verifies the address, splices the manifest in, and validates exactly what it
7406/// would have validated inline. The alternative — many partial commits — would
7407/// make a failed migration leave a half-populated brain readable.
7408fn stage_v2_change(
7409    cfg: &HubConfig,
7410    requested_brain: &str,
7411    operations: &[Value],
7412    blobs: Value,
7413) -> LinkResult<Value> {
7414    let bytes = v2_change_manifest(operations, blobs)?;
7415    let sha256 = content_sha256(&bytes);
7416    let reserved = reserve_upload_window(
7417        cfg,
7418        &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7419        &json!({
7420            "blobs": [{
7421                "sha256": sha256,
7422                "bytes": bytes.len(),
7423                "kind": "staged_change",
7424            }],
7425        }),
7426        "stage the v2 change",
7427    )?;
7428    let items = reserved
7429        .get("uploads")
7430        .and_then(Value::as_array)
7431        .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7432    let [item] = items.as_slice() else {
7433        return Err(invalid_feed(
7434            "v2 change staging response changed the requested set",
7435        ));
7436    };
7437    let reservation_id = item
7438        .get("reservation_id")
7439        .and_then(Value::as_str)
7440        .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7441    if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7442        || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7443        || !crate::ulid::is_ulid(reservation_id)
7444    {
7445        return Err(invalid_feed("v2 change staging item is inconsistent"));
7446    }
7447    match item.get("status").and_then(Value::as_str) {
7448        Some("upload") => put_presigned(
7449            cfg,
7450            item.get("url")
7451                .and_then(Value::as_str)
7452                .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7453            item.get("headers").unwrap_or(&Value::Null),
7454            &bytes,
7455        )?,
7456        Some("already_present") => {}
7457        _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7458    }
7459    Ok(json!({
7460        "sha256": sha256,
7461        "bytes": bytes.len(),
7462        "reservation_id": reservation_id,
7463    }))
7464}
7465
7466/// Move a commit request's operations and blob references off the wire body
7467/// when they no longer fit in it. Idempotent by construction: the mutation ID
7468/// is derived from the change, never from how the change was transported.
7469fn stage_oversized_v2_change(
7470    cfg: &HubConfig,
7471    requested_brain: &str,
7472    operations: &[Value],
7473    body: &mut Value,
7474) -> LinkResult<()> {
7475    if body.to_string().len() <= MAX_PUSH_BYTES {
7476        return Ok(());
7477    }
7478    let staged = stage_v2_change(
7479        cfg,
7480        requested_brain,
7481        operations,
7482        body.get("blobs")
7483            .cloned()
7484            .unwrap_or(Value::Array(Vec::new())),
7485    )?;
7486    let map = body
7487        .as_object_mut()
7488        .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7489    map.remove("operations");
7490    map.remove("blobs");
7491    map.insert("staged_change".to_string(), staged);
7492    Ok(())
7493}
7494
7495fn v2_sync_push(
7496    cfg: &HubConfig,
7497    requested_brain: &str,
7498    store: &Store,
7499    head: V2VerifiedHead,
7500    options: V2SyncPushOptions<'_>,
7501) -> LinkResult<Value> {
7502    let V2SyncPushOptions {
7503        resume_local_policy,
7504        bulk_confirmation,
7505        resolution,
7506        pulled,
7507        withdrawal_paths,
7508        withdrawal_reason,
7509    } = options;
7510    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7511    let head = v2_verified_head(cfg, requested_brain)?
7512        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7513    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7514        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7515            Some(snapshot) => (
7516                snapshot.files,
7517                snapshot.assets,
7518                Some(snapshot.local),
7519                Some(snapshot.local_assets),
7520            ),
7521            None => (
7522                files_for_v2_view(
7523                    &head,
7524                    v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7525                ),
7526                v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7527                None,
7528                None,
7529            ),
7530        };
7531    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7532    ensure_v2_view_compatible(&head, baseline.as_ref())?;
7533    if head.view_kind == "scoped" && baseline.is_none() {
7534        return Err(LinkError::ScopedViewChanged);
7535    }
7536    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7537    let local = &local_view.riding;
7538    let local_assets = match carried_local_assets {
7539        Some(assets) => assets,
7540        None => v2_local_asset_records(store)?,
7541    };
7542    if withdrawal_paths.len() > MAX_PUSH_FILES {
7543        return Err(LinkError::PushTooLarge {
7544            detail: "too many explicit withdrawal paths".to_string(),
7545        });
7546    }
7547    let withdrawal_reason = if withdrawal_paths.is_empty() {
7548        None
7549    } else {
7550        let reason = withdrawal_reason
7551            .map(str::trim)
7552            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7553            .ok_or_else(|| LinkError::InvalidPack {
7554                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7555            })?;
7556        Some(reason)
7557    };
7558    let mut withdrawals = withdrawal_paths
7559        .iter()
7560        .map(|path| {
7561            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7562                path: error.to_string(),
7563            })
7564        })
7565        .collect::<LinkResult<Vec<_>>>()?;
7566    withdrawals.sort();
7567    withdrawals.dedup();
7568    if withdrawals.len() != withdrawal_paths.len() {
7569        return Err(LinkError::InvalidPack {
7570            message: "explicit withdrawal paths must be unique".to_string(),
7571        });
7572    }
7573    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7574    let mut consumed_withdrawals = BTreeSet::new();
7575    if let Some(previous) = baseline.as_ref() {
7576        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7577            && !resume_local_policy
7578        {
7579            let mut newly_eligible = previous
7580                .local_eligibility
7581                .iter()
7582                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7583                .map(|(path, _)| path.clone())
7584                .collect::<Vec<_>>();
7585            if !newly_eligible.is_empty() {
7586                newly_eligible.truncate(100);
7587                return Err(LinkError::LocalPolicyTransition {
7588                    paths: newly_eligible,
7589                });
7590            }
7591        }
7592    }
7593    let base = match baseline.as_ref() {
7594        Some(state) => &state.files,
7595        None if remote.is_empty() => &remote,
7596        None => {
7597            let mut conflicts = remote
7598                .iter()
7599                .filter(|(path, file)| {
7600                    local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7601                })
7602                .map(|(path, _)| path.clone())
7603                .collect::<Vec<_>>();
7604            if !conflicts.is_empty() {
7605                conflicts.truncate(100);
7606                let (bundle, paths) =
7607                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7608                return Err(LinkError::ConflictBundle { bundle, paths });
7609            }
7610            &remote
7611        }
7612    };
7613    let all_paths = base
7614        .keys()
7615        .chain(remote.keys())
7616        .chain(local.keys())
7617        .cloned()
7618        .collect::<std::collections::BTreeSet<_>>();
7619    let mut conflicts = Vec::new();
7620    let mut operations = Vec::new();
7621    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7622    for path in all_paths {
7623        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7624        let remote_file = remote.get(&path);
7625        let remote_hash = remote_file.map(|file| file.sha256.as_str());
7626        let local_file = local.get(&path);
7627        let local_hash = local_file.map(|file| file.0.as_str());
7628        if local_hash == base_hash {
7629            continue;
7630        }
7631        if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
7632            continue;
7633        }
7634        if local_view.policy.keeps_home(&path) {
7635            // Kept-home is a local transfer exclusion, never an implicit
7636            // delete of the company's already-hosted coordinate.
7637            continue;
7638        }
7639        if remote_hash != base_hash && local_hash != remote_hash {
7640            let explicitly_resolved = resolution
7641                .and_then(|allowed| allowed.get(&path))
7642                .is_some_and(|selected| {
7643                    selected.expected_remote.as_deref() == remote_hash
7644                        && selected.selected_local.as_deref() == local_hash
7645                });
7646            if !explicitly_resolved {
7647                conflicts.push(path);
7648                continue;
7649            }
7650        }
7651        match local_file {
7652            Some((sha256, byte_count)) => {
7653                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7654                operations.push(json!({
7655                    "op": "put",
7656                    "path": path,
7657                    "expected": v2_expected(remote_file),
7658                    "blob": sha256,
7659                    "bytes": byte_count,
7660                }));
7661                upload_sources
7662                    .entry(sha256.clone())
7663                    .or_insert_with(|| V2UploadSource {
7664                        path: path.clone(),
7665                        bytes: *byte_count,
7666                    });
7667            }
7668            None => {
7669                let Some(current) = remote_file else {
7670                    continue;
7671                };
7672                operations.push(json!({
7673                    "op": "delete",
7674                    "path": path,
7675                    "expected": { "kind": "blob", "hash": current.sha256 },
7676                }));
7677            }
7678        }
7679    }
7680    operations = infer_exact_source_promotions(operations);
7681    for path in &withdrawals {
7682        if local_assets.contains_key(path) {
7683            continue;
7684        }
7685        operations.push(v2_content_withdrawal_operation(
7686            store,
7687            &local_view,
7688            &remote,
7689            path,
7690            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7691        )?);
7692        consumed_withdrawals.insert(path.clone());
7693    }
7694    if !conflicts.is_empty() {
7695        conflicts.truncate(100);
7696        let (bundle, paths) = create_v2_conflict_bundle(
7697            cfg,
7698            store,
7699            &head,
7700            baseline.as_ref(),
7701            local,
7702            &remote,
7703            &conflicts,
7704        )?;
7705        return Err(LinkError::ConflictBundle { bundle, paths });
7706    }
7707    let base_assets = match baseline.as_ref() {
7708        Some(state) => &state.assets,
7709        None if remote_assets.is_empty() => &remote_assets,
7710        None => {
7711            let mismatched = remote_assets.iter().any(|(path, remote)| {
7712                local_assets.get(path) != Some(&v2_asset_record(remote, path))
7713            }) || local_assets.len() != remote_assets.len();
7714            if mismatched {
7715                return Err(LinkError::Conflict {
7716                    paths: vec!["assets.jsonl".to_string()],
7717                });
7718            }
7719            &remote_assets
7720        }
7721    };
7722    let asset_paths = base_assets
7723        .keys()
7724        .chain(remote_assets.keys())
7725        .chain(local_assets.keys())
7726        .cloned()
7727        .collect::<std::collections::BTreeSet<_>>();
7728    let mut asset_policy_transitions = Vec::new();
7729    for path in asset_paths {
7730        let base_record = base_assets
7731            .get(&path)
7732            .map(|asset| v2_asset_record(asset, &path));
7733        let remote = remote_assets.get(&path);
7734        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
7735        let local_record = local_assets.get(&path);
7736        if withdrawal_set.contains(&path) {
7737            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
7738                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
7739            })?;
7740            let current = remote.ok_or_else(|| LinkError::InvalidPack {
7741                message: format!(
7742                    "asset withdrawal path `{path}` has no readable hosted coordinate"
7743                ),
7744            })?;
7745            operations.push(v2_asset_withdrawal_operation(
7746                store,
7747                &local_view,
7748                &path,
7749                record,
7750                current,
7751                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7752            )?);
7753            consumed_withdrawals.insert(path.clone());
7754            continue;
7755        }
7756        let mut raw_present = false;
7757        let mut disposition = "withheld";
7758        let mut resumes_hosting = false;
7759        if let Some(record) = local_record {
7760            crate::linkmd_v2::normalize_path(&record.path)
7761                .map_err(|error| invalid_feed(error.to_string()))?;
7762            let kept_home = local_view.policy.keeps_home(&path);
7763            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
7764            disposition = if kept_home || !raw_present {
7765                "withheld"
7766            } else {
7767                "hosted"
7768            };
7769            if !raw_present && record.required && !kept_home {
7770                return Err(LinkError::InvalidPack {
7771                    message: format!("required asset {path} is missing"),
7772                });
7773            }
7774            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
7775        }
7776        if local_record == base_record.as_ref() && !resumes_hosting {
7777            continue;
7778        }
7779        if remote_record != base_record && local_record != remote_record.as_ref() {
7780            conflicts.push(path);
7781            continue;
7782        }
7783        let Some(record) = local_record else {
7784            if let Some(remote) = remote {
7785                operations.push(json!({
7786                    "op": "asset_delete",
7787                    "path": path,
7788                    "expected": v2_asset_expected(Some(remote)),
7789                }));
7790            }
7791            continue;
7792        };
7793        let raw = if raw_present {
7794            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
7795            Some(())
7796        } else {
7797            None
7798        };
7799        let op = if resumes_hosting {
7800            if !resume_local_policy {
7801                asset_policy_transitions.push(path);
7802                continue;
7803            }
7804            "asset_resume"
7805        } else {
7806            "asset_put"
7807        };
7808        operations.push(json!({
7809            "op": op,
7810            "path": path,
7811            "expected": v2_asset_expected(remote),
7812            "asset": v2_asset_value(record, disposition),
7813        }));
7814        if disposition == "hosted" {
7815            raw.expect("hosted asset was checked present");
7816            upload_sources
7817                .entry(record.sha256.clone())
7818                .or_insert_with(|| V2UploadSource {
7819                    path: path.clone(),
7820                    bytes: record.bytes,
7821                });
7822        }
7823    }
7824    if consumed_withdrawals != withdrawal_set {
7825        let missing = withdrawal_set
7826            .difference(&consumed_withdrawals)
7827            .next()
7828            .expect("different withdrawal sets have one member");
7829        return Err(LinkError::InvalidPack {
7830            message: format!(
7831                "withdrawal path `{missing}` is not a readable content or asset coordinate"
7832            ),
7833        });
7834    }
7835    if !conflicts.is_empty() {
7836        conflicts.truncate(100);
7837        return Err(LinkError::Conflict { paths: conflicts });
7838    }
7839    if !asset_policy_transitions.is_empty() {
7840        asset_policy_transitions.truncate(100);
7841        return Err(LinkError::LocalPolicyTransition {
7842            paths: asset_policy_transitions,
7843        });
7844    }
7845    let touched_sources = operations
7846        .iter()
7847        .filter_map(
7848            |operation| match operation.get("op").and_then(Value::as_str) {
7849                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
7850                Some("rename") => operation.get("to").and_then(Value::as_str),
7851                _ => None,
7852            },
7853        )
7854        .collect::<std::collections::BTreeSet<_>>();
7855    let withheld_links = local_view
7856        .withheld_links
7857        .iter()
7858        .filter(|link| touched_sources.contains(link.source.as_str()))
7859        .collect::<Vec<_>>();
7860    let checkout_pseudonym = v2_checkout_id(
7861        baseline
7862            .as_ref()
7863            .and_then(|current| current.checkout_id.as_deref()),
7864    )?;
7865    let checkout_id = if withheld_links.is_empty() {
7866        None
7867    } else {
7868        Some(checkout_pseudonym.clone())
7869    };
7870    if operations.is_empty() {
7871        let final_head = v2_verified_head(cfg, requested_brain)?
7872            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
7873        if !same_v2_head(&head, &final_head) {
7874            return Err(LinkError::RemoteAdvancedDuringSync);
7875        }
7876        let mut final_local = v2_local_files(store)?;
7877        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
7878        let final_assets = v2_local_asset_records(store)?;
7879        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
7880        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
7881            final_local.policy.keeps_home(path)
7882        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
7883        let next = v2_baseline_from_head(
7884            cfg,
7885            &head,
7886            remote,
7887            remote_assets,
7888            Some(&final_local),
7889            Some(&checkout_pseudonym),
7890        )?;
7891        let split_count = next.remote_copy_remains.len();
7892        accept_v2_head(cfg, &final_head)?;
7893        if !local_changed && !remote_ahead {
7894            refresh_scoped_view_marker(store, &head, next.files.len())?;
7895            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
7896        }
7897        return Ok(json!({
7898            "v": 2,
7899            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
7900            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
7901            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
7902            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
7903            "local_policy": {
7904                "remote_copy_remains": split_count,
7905            },
7906        }));
7907    }
7908    let includes_contract = operations
7909        .iter()
7910        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
7911    let rebase = if head.pointer.is_none() || includes_contract {
7912        "strict"
7913    } else {
7914        "disjoint"
7915    };
7916    let base_value = head.pointer.as_ref().map(|pointer| {
7917        json!({
7918            "seq": pointer.seq,
7919            "commit_hash": pointer.commit_hash,
7920            "content_root": pointer.content_root,
7921            "asset_root": pointer.asset_root,
7922        })
7923    });
7924    // The mutation coordinate is stable for the exact observed base and local
7925    // operation set. A lost response can therefore be retried without a
7926    // duplicate commit/proposal even across process restarts.
7927    let entropy = format!(
7928        "{}\0{}\0{}\0{}\0{}\0{}",
7929        normalized_origin(&cfg.hub)?,
7930        head.brain_id,
7931        serde_json::to_string(&base_value).unwrap_or_default(),
7932        serde_json::to_string(&operations).unwrap_or_default(),
7933        serde_json::to_string(&withheld_links).unwrap_or_default(),
7934        checkout_id.as_deref().unwrap_or("")
7935    );
7936    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
7937    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
7938        total
7939            .checked_add(source.bytes)
7940            .ok_or_else(|| LinkError::PushTooLarge {
7941                detail: "v2 changed-byte total overflow".to_string(),
7942            })
7943    })?;
7944    let inline = changed_bytes <= 3 * 1024 * 1024;
7945    let inline_blobs = if inline {
7946        upload_sources
7947            .iter()
7948            .map(|(sha256, source)| {
7949                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
7950                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
7951                    return Err(LinkError::InvalidPack {
7952                        message: format!("local path `{}` changed before upload", source.path),
7953                    });
7954                }
7955                Ok(json!({
7956                    "sha256": sha256,
7957                    "bytes": source.bytes,
7958                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
7959                }))
7960            })
7961            .collect::<LinkResult<Vec<_>>>()?
7962    } else {
7963        Vec::new()
7964    };
7965    let mut body = json!({
7966        "mutation_id": mutation_id,
7967        "base": base_value,
7968        "rebase": rebase,
7969        "reason": "dbmd sync",
7970        "operations": operations,
7971        "blobs": inline_blobs,
7972    });
7973    if !withheld_links.is_empty() {
7974        body["withheld_links"] = serde_json::to_value(&withheld_links)
7975            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
7976        body["checkout_id"] =
7977            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
7978    }
7979    if let Some(confirmation) = bulk_confirmation {
7980        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
7981            return Err(LinkError::InvalidPack {
7982                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
7983                    .to_string(),
7984            });
7985        }
7986        // A preview is bound to the exact observed head. Confirmation can
7987        // therefore never use disjoint rebase semantics, even when the normal
7988        // small-mutation path could.
7989        body["rebase"] = Value::String("strict".to_string());
7990        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
7991        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
7992    }
7993    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
7994        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
7995        for operation in &operations {
7996            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
7997                return Err(invalid_feed("v2 upload operation has no kind"));
7998            };
7999            let hash = match kind {
8000                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8001                "asset_put" | "asset_resume" => operation
8002                    .get("asset")
8003                    .and_then(|asset| asset.get("blob_sha256"))
8004                    .and_then(Value::as_str),
8005                _ => None,
8006            };
8007            let Some(hash) = hash else { continue };
8008            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8009            if kind == "rename" {
8010                for field in ["from", "to"] {
8011                    coordinates.insert(
8012                        operation
8013                            .get(field)
8014                            .and_then(Value::as_str)
8015                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8016                            .to_string(),
8017                    );
8018                }
8019            } else {
8020                let path = operation
8021                    .get("path")
8022                    .and_then(Value::as_str)
8023                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8024                coordinates.insert(if kind.starts_with("asset_") {
8025                    format!("assets/{path}")
8026                } else {
8027                    path.to_string()
8028                });
8029            }
8030        }
8031        let declarations = upload_sources
8032            .iter()
8033            .map(|(sha256, source)| {
8034                json!({
8035                    "sha256": sha256,
8036                    "bytes": source.bytes,
8037                    "coordinates": coordinates_by_hash
8038                        .get(sha256)
8039                        .into_iter()
8040                        .flatten()
8041                        .collect::<Vec<_>>(),
8042                })
8043            })
8044            .collect::<Vec<_>>();
8045        let mut references = Vec::with_capacity(upload_sources.len());
8046        let mut seen = std::collections::BTreeSet::new();
8047        let mut reserved_count = 0usize;
8048        let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8049        // A reservation's presigned URL is short-lived, so each batch's bytes
8050        // ride before the next batch is reserved. Reserving every blob first
8051        // and uploading afterwards expires the earliest URLs on a real store.
8052        for batch in batch_upload_declarations(declarations) {
8053            let batch_len = batch.len();
8054            let reserved = reserve_upload_window(
8055                cfg,
8056                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8057                &json!({ "blobs": batch }),
8058                "prepare v2 changed-byte uploads",
8059            )?;
8060            let items = reserved
8061                .get("uploads")
8062                .and_then(Value::as_array)
8063                .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8064            if items.len() != batch_len {
8065                return Err(invalid_feed(
8066                    "v2 upload reservation response changed the requested set",
8067                ));
8068            }
8069            reserved_count += items.len();
8070            for item in items {
8071                let sha256 = item
8072                    .get("sha256")
8073                    .and_then(Value::as_str)
8074                    .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8075                let source = upload_sources
8076                    .get(sha256)
8077                    .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8078                let declared_bytes = item
8079                    .get("bytes")
8080                    .and_then(Value::as_u64)
8081                    .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8082                let reservation_id = item
8083                    .get("reservation_id")
8084                    .and_then(Value::as_str)
8085                    .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8086                let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8087                    invalid_feed("v2 upload reservation has no coordinate binding")
8088                })?;
8089                let returned_coordinates = item
8090                    .get("coordinates")
8091                    .and_then(Value::as_array)
8092                    .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8093                if declared_bytes != source.bytes
8094                    || !crate::ulid::is_ulid(reservation_id)
8095                    || !seen.insert(sha256.to_string())
8096                    || returned_coordinates.len() != expected_coordinates.len()
8097                    || returned_coordinates
8098                        .iter()
8099                        .zip(expected_coordinates)
8100                        .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8101                {
8102                    return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8103                }
8104                match item.get("status").and_then(Value::as_str) {
8105                    Some("upload") => {
8106                        let url = item
8107                            .get("url")
8108                            .and_then(Value::as_str)
8109                            .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8110                        pending_uploads.push(V2PendingUpload {
8111                            url: url.to_string(),
8112                            headers: item.get("headers").cloned().unwrap_or(Value::Null),
8113                            sha256: sha256.to_string(),
8114                            source,
8115                        });
8116                    }
8117                    Some("already_present") => {}
8118                    _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8119                }
8120                references.push(json!({
8121                    "sha256": sha256,
8122                    "bytes": source.bytes,
8123                    "reservation_id": reservation_id,
8124                }));
8125            }
8126            // Stage this batch's bytes concurrently. Sequential PUTs move a real
8127            // store at a few objects a second, which both takes hours and pushes
8128            // the batch past its reservation's upload window; the object store
8129            // is content-addressed and precondition-guarded, so parallel writes
8130            // stay safe.
8131            upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8132            pending_uploads.clear();
8133        }
8134        if reserved_count != upload_sources.len() {
8135            return Err(invalid_feed(
8136                "v2 upload reservation response changed the requested set",
8137            ));
8138        }
8139        body["blobs"] = Value::Array(references);
8140    }
8141    stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8142    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8143    let mut candidate_hub_signer: Option<String> = None;
8144    let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8145    let bulk_preview_required = !(200..300).contains(&response.status)
8146        && response.body.as_ref().is_some_and(|value| {
8147            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8148                || value
8149                    .get("details")
8150                    .and_then(|details| details.get("code"))
8151                    .and_then(Value::as_str)
8152                    == Some("bulk_preview_required")
8153        });
8154    if bulk_preview_required && bulk_confirmation.is_none() {
8155        body["rebase"] = Value::String("strict".to_string());
8156        body["preview_only"] = Value::Bool(true);
8157        let preview = ensure_ok(
8158            request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8159            "v2 bulk preview",
8160        )?;
8161        let preview_code = preview.get("code").and_then(Value::as_str);
8162        let required = preview.get("required").and_then(Value::as_bool);
8163        if preview.get("v").and_then(Value::as_u64) != Some(2)
8164            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8165            || !matches!(
8166                preview_code,
8167                Some("bulk_preview_created" | "bulk_preview_not_required")
8168            )
8169            || required.is_none()
8170        {
8171            return Err(invalid_feed(
8172                "bulk preview response is not bound to the requested mutation",
8173            ));
8174        }
8175        if required == Some(true) {
8176            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8177            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8178            if preview_code != Some("bulk_preview_created")
8179                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8180                || preview_digest.is_none_or(|value| !is_sha256(value))
8181                || preview.get("expires_at").and_then(Value::as_str).is_none()
8182                || !preview.get("impact").is_some_and(Value::is_object)
8183            {
8184                return Err(invalid_feed("bulk preview receipt is malformed"));
8185            }
8186            return Err(LinkError::BulkPreviewRequired { preview });
8187        }
8188        if preview_code != Some("bulk_preview_not_required") {
8189            return Err(invalid_feed("bulk preview requirement is inconsistent"));
8190        }
8191        // The rolling activity window can expire between the refusal and
8192        // preview. Retry once, still pinned strictly to the observed head.
8193        body.as_object_mut()
8194            .expect("v2 commit request is an object")
8195            .remove("preview_only");
8196        response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8197    }
8198    let mut result = ensure_ok(response, "v2 sync push")?;
8199    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8200        if let Some(object) = result.as_object_mut() {
8201            object.insert(
8202                "sync_status".to_string(),
8203                Value::String("proposal_pending".to_string()),
8204            );
8205        }
8206        return Ok(result);
8207    }
8208    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8209        let request_id = result
8210            .get("request_id")
8211            .and_then(Value::as_str)
8212            .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8213            .to_string();
8214        let challenge = result
8215            .get("signing_challenge")
8216            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8217        let mut expected_candidate = remote.clone();
8218        let mut expected_candidate_assets = remote_assets.clone();
8219        apply_generated_v2_operations(
8220            &operations,
8221            &local_assets,
8222            &mut expected_candidate,
8223            &mut expected_candidate_assets,
8224        )?;
8225        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8226            cfg,
8227            &head,
8228            &expected_candidate,
8229            &expected_candidate_assets,
8230            &mutation_id,
8231            &v2_signed_request_view(&body, &operations),
8232            challenge,
8233        )?;
8234        body["signing_challenge_id"] = Value::String(challenge_id);
8235        body["signature_base64url"] = Value::String(signature);
8236        candidate_hub_signer = Some(actor_signer);
8237        result = ensure_ok(
8238            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8239            "v2 self-custody commit",
8240        )?;
8241    }
8242    let refreshed = v2_verified_head(cfg, requested_brain)?
8243        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8244    if candidate_hub_signer
8245        .as_ref()
8246        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8247    {
8248        return Err(invalid_feed(
8249            "self-custody actor signer differs from the committed hub pointer signer",
8250        ));
8251    }
8252    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8253    if refreshed
8254        .pointer
8255        .as_ref()
8256        .map(|pointer| pointer.commit_hash.as_str())
8257        != accepted_hash
8258    {
8259        return Err(LinkError::RemoteAdvancedDuringSync);
8260    }
8261    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8262    let rebased = result
8263        .get("rebased")
8264        .and_then(Value::as_bool)
8265        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8266    let (refreshed_files, refreshed_assets) = if rebased {
8267        (
8268            files_for_v2_view(
8269                &refreshed,
8270                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8271            ),
8272            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8273        )
8274    } else {
8275        let asset_changed = apply_generated_v2_operations(
8276            &operations,
8277            &local_assets,
8278            &mut remote,
8279            &mut remote_assets,
8280        )?;
8281        let assets = if asset_changed {
8282            // Asset leaf hashes are server-generated signed coordinates; only
8283            // asset-changing commits need this usually tiny post-commit read.
8284            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8285        } else {
8286            remote_assets
8287        };
8288        (remote, assets)
8289    };
8290    let mut final_local = v2_local_files(store)?;
8291    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8292    let final_assets = v2_local_asset_records(store)?;
8293    let local_dirty = final_local.riding != local_view.riding
8294        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8295            final_local.policy.keeps_home(path)
8296        })
8297        || final_assets != local_assets
8298        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8299    let next = v2_baseline_from_head(
8300        cfg,
8301        &refreshed,
8302        refreshed_files,
8303        refreshed_assets,
8304        Some(&final_local),
8305        Some(&checkout_pseudonym),
8306    )?;
8307    let split_count = next.remote_copy_remains.len();
8308    accept_v2_head(cfg, &refreshed)?;
8309    if !local_dirty {
8310        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8311        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8312    }
8313    if let Some(object) = result.as_object_mut() {
8314        object.insert(
8315            "local_policy".to_string(),
8316            json!({ "remote_copy_remains": split_count }),
8317        );
8318        object.insert(
8319            "sync_status".to_string(),
8320            Value::String(if local_dirty {
8321                "remote_committed_local_dirty".to_string()
8322            } else {
8323                "synced".to_string()
8324            }),
8325        );
8326    }
8327    Ok(result)
8328}
8329
8330/// Negotiate v2 and send only local changes. A v1 hub retains the existing
8331/// whole-snapshot behavior until its brain advertises the new profile.
8332pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8333    sync_push_incremental_with_policy(cfg, brain, store, false)
8334}
8335
8336/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
8337/// path that became eligible because local policy was weakened or removed.
8338pub fn sync_push_incremental_with_policy(
8339    cfg: &HubConfig,
8340    brain: &str,
8341    store: &Store,
8342    resume_local_policy: bool,
8343) -> LinkResult<Value> {
8344    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8345}
8346
8347/// Incremental push with both local-policy adoption and an optional exact
8348/// permissioned bulk-preview confirmation.
8349pub fn sync_push_incremental_with_options(
8350    cfg: &HubConfig,
8351    brain: &str,
8352    store: &Store,
8353    resume_local_policy: bool,
8354    bulk_confirmation: Option<&V2BulkConfirmation>,
8355) -> LinkResult<Value> {
8356    sync_push_incremental_with_controls(
8357        cfg,
8358        brain,
8359        store,
8360        resume_local_policy,
8361        bulk_confirmation,
8362        &[],
8363        None,
8364    )
8365}
8366
8367/// Incremental push with explicit permissioned current-host withdrawal.
8368pub fn sync_push_incremental_with_controls(
8369    cfg: &HubConfig,
8370    brain: &str,
8371    store: &Store,
8372    resume_local_policy: bool,
8373    bulk_confirmation: Option<&V2BulkConfirmation>,
8374    withdrawal_paths: &[String],
8375    withdrawal_reason: Option<&str>,
8376) -> LinkResult<Value> {
8377    require_safe_ref(brain)?;
8378    if let Some(head) = v2_verified_head(cfg, brain)? {
8379        return v2_sync_push(
8380            cfg,
8381            brain,
8382            store,
8383            head,
8384            V2SyncPushOptions {
8385                resume_local_policy,
8386                bulk_confirmation,
8387                resolution: None,
8388                pulled: None,
8389                withdrawal_paths,
8390                withdrawal_reason,
8391            },
8392        );
8393    }
8394    if !withdrawal_paths.is_empty() {
8395        return Err(LinkError::InvalidPack {
8396            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8397        });
8398    }
8399    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8400}
8401
8402/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
8403/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
8404/// a damaged `DB.md` makes strict store-open fail.
8405pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8406    require_safe_ref(brain)?;
8407    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8408}
8409
8410#[cfg(windows)]
8411fn legacy_sync_push_incremental(
8412    _cfg: &HubConfig,
8413    _brain: &str,
8414    _store: &Store,
8415    _resume_local_policy: bool,
8416    _bulk_confirmation: Option<&V2BulkConfirmation>,
8417) -> LinkResult<Value> {
8418    Err(LinkError::UnsupportedPlatform {
8419        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8420    })
8421}
8422
8423#[cfg(not(windows))]
8424fn legacy_sync_push_incremental(
8425    cfg: &HubConfig,
8426    brain: &str,
8427    store: &Store,
8428    resume_local_policy: bool,
8429    bulk_confirmation: Option<&V2BulkConfirmation>,
8430) -> LinkResult<Value> {
8431    if resume_local_policy || bulk_confirmation.is_some() {
8432        return Err(LinkError::InvalidPack {
8433            message: "v2 sync options require a link.md v2 brain".to_string(),
8434        });
8435    }
8436    let files = collect_push_files(store)?;
8437    sync_push(cfg, brain, &files)
8438}
8439
8440/// One explicit resolution for a previously preserved private conflict bundle.
8441#[derive(Debug, Clone)]
8442pub enum V2ConflictChoice {
8443    KeepLocal,
8444    TakeRemote,
8445    From(PathBuf),
8446}
8447
8448fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8449    if !crate::ulid::is_ulid(bundle) {
8450        return Err(LinkError::InvalidPack {
8451            message: "conflict bundle must be a lowercase ULID".to_string(),
8452        });
8453    }
8454    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8455    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8456        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8457    if plan.v != 2
8458        || plan.class != "content_resolution_required"
8459        || plan.bundle != bundle
8460        || !crate::ulid::is_ulid(&plan.brain)
8461        || plan.files.is_empty()
8462        || plan.files.len() > 100
8463        || plan.files.iter().any(|file| {
8464            crate::linkmd_v2::normalize_path(&file.path).is_err()
8465                || [&file.base, &file.local, &file.remote]
8466                    .into_iter()
8467                    .any(|coordinate| {
8468                        coordinate
8469                            .sha256
8470                            .as_deref()
8471                            .is_some_and(|hash| !is_sha256(hash))
8472                            || coordinate.file.as_deref().is_some_and(|name| {
8473                                name.starts_with('/')
8474                                    || name
8475                                        .split('/')
8476                                        .any(|part| part.is_empty() || part == "." || part == "..")
8477                            })
8478                    })
8479        })
8480    {
8481        return Err(invalid_feed("private conflict plan failed validation"));
8482    }
8483    Ok(plan)
8484}
8485
8486/// Inspect or prune private conflict-control state. Ordinary pruning removes
8487/// only expired completed bundles and interrupted bundles with no `plan.json`;
8488/// corrupt completed plans fail closed. `all` is an explicit local discard of
8489/// every well-addressed bundle and never changes hosted brain data.
8490pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8491    require_hardened_filesystem("private conflict maintenance")?;
8492    if all && !prune {
8493        return Err(LinkError::InvalidPack {
8494            message: "discarding all conflict bundles requires prune=true".to_string(),
8495        });
8496    }
8497    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8498        message: format!("conflict checkout is not a valid db.md store: {error}"),
8499    })?;
8500    let _transaction = store.transaction()?;
8501    let root = Path::new(".dbmd/conflicts");
8502    let names = match store.directory_names(root) {
8503        Ok(names) => names,
8504        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8505        Err(error) => return Err(error.into()),
8506    };
8507    let now = SystemTime::now()
8508        .duration_since(UNIX_EPOCH)
8509        .unwrap_or_default()
8510        .as_secs();
8511    let mut bundles = Vec::new();
8512    let mut pruned = 0_u64;
8513    for name in names {
8514        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8515            continue;
8516        };
8517        let plan_path = v2_conflict_relative(bundle, "plan.json");
8518        let plan_exists = store.regular_file_exists(&plan_path)?;
8519        let expired = if plan_exists {
8520            match load_v2_conflict_plan(&store, bundle) {
8521                Ok(plan) => plan.expires_unix < now,
8522                Err(error) if all => {
8523                    let _ = error;
8524                    true
8525                }
8526                Err(error) => return Err(error),
8527            }
8528        } else {
8529            true
8530        };
8531        if prune && (all || expired) {
8532            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8533            pruned += 1;
8534            continue;
8535        }
8536        bundles.push(json!({
8537            "bundle": bundle,
8538            "complete": plan_exists,
8539            "expired": expired,
8540        }));
8541    }
8542    Ok(json!({
8543        "v": 2,
8544        "class": "private_conflict_state",
8545        "bundles": bundles.len(),
8546        "pruned": pruned,
8547        "items": bundles,
8548    }))
8549}
8550
8551/// Resolve one exact private conflict plan. Drift never lowers a precondition:
8552/// the remote head/view and every original local coordinate are rechecked
8553/// before either an explicit local install or a fresh normal commit.
8554pub fn sync_resolve_conflict(
8555    cfg: &HubConfig,
8556    checkout: &Path,
8557    bundle: &str,
8558    choice: V2ConflictChoice,
8559    bulk_confirmation: Option<&V2BulkConfirmation>,
8560) -> LinkResult<Value> {
8561    require_hardened_filesystem("conflict resolution")?;
8562    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8563        message: format!("conflict checkout is not a valid db.md store: {error}"),
8564    })?;
8565    let plan = load_v2_conflict_plan(&store, bundle)?;
8566    if plan.origin != normalized_origin(&cfg.hub)? {
8567        return Err(invalid_feed(
8568            "conflict bundle belongs to another hub origin",
8569        ));
8570    }
8571    let now = SystemTime::now()
8572        .duration_since(UNIX_EPOCH)
8573        .unwrap_or_default()
8574        .as_secs();
8575    if now > plan.expires_unix {
8576        return Err(LinkError::InvalidPack {
8577            message: "conflict bundle expired; rerun sync to obtain current coordinates"
8578                .to_string(),
8579        });
8580    }
8581    let head = v2_verified_head(cfg, &plan.brain)?
8582        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8583    let pointer = head.pointer.as_ref();
8584    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8585        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8586        || pointer.and_then(|value| value.content_root.as_deref())
8587            != plan.remote_content_root.as_deref()
8588        || head.view_kind != plan.view_kind
8589        || head.view_revision != plan.view_revision
8590    {
8591        return Err(LinkError::RemoteAdvancedDuringSync);
8592    }
8593
8594    // A resolution never overwrites a post-bundle local edit.
8595    for file in &plan.files {
8596        let actual = match store.regular_file_exists(Path::new(&file.path))? {
8597            true => Some(content_sha256(&store.read_bounded(
8598                Path::new(&file.path),
8599                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8600            )?)),
8601            false => None,
8602        };
8603        if actual.as_deref() != file.local.sha256.as_deref() {
8604            return Err(LinkError::InvalidPack {
8605                message: format!(
8606                    "local conflict path `{}` changed after the bundle was created",
8607                    file.path
8608                ),
8609            });
8610        }
8611    }
8612
8613    let from_source = match &choice {
8614        V2ConflictChoice::From(source) => Some(source.clone()),
8615        _ => None,
8616    };
8617    let result = match choice {
8618        V2ConflictChoice::TakeRemote => {
8619            if bulk_confirmation.is_some() {
8620                return Err(LinkError::InvalidPack {
8621                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8622                });
8623            }
8624            // Re-read the exact current manifest so every selected remote byte
8625            // retains its signed inclusion proof. The private bundle is useful
8626            // for inspection, but is never promoted into live state as trust.
8627            let current_remote =
8628                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8629            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
8630            let selected = plan
8631                .files
8632                .iter()
8633                .map(|file| file.path.clone())
8634                .collect::<std::collections::BTreeSet<_>>();
8635            serde_json::to_value(
8636                v2_sync_pull_with_resolution(
8637                    cfg,
8638                    &plan.brain,
8639                    head,
8640                    Some(checkout),
8641                    Some(&selected),
8642                )?
8643                .report,
8644            )
8645            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8646        }
8647        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8648            if let Some(source) = from_source.as_ref() {
8649                if plan.files.len() != 1 {
8650                    return Err(LinkError::InvalidPack {
8651                        message: "--from requires a bundle with exactly one conflict".to_string(),
8652                    });
8653                }
8654                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8655                if std::str::from_utf8(&candidate).is_err() {
8656                    return Err(LinkError::NotUtf8 {
8657                        path: source.display().to_string(),
8658                    });
8659                }
8660                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8661            }
8662            let refreshed_store =
8663                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8664                    message: format!("resolved checkout is not a valid db.md store: {error}"),
8665                })?;
8666            let mut overrides = std::collections::BTreeMap::new();
8667            for file in &plan.files {
8668                let selected_local = match refreshed_store
8669                    .regular_file_exists(Path::new(&file.path))?
8670                {
8671                    true => Some(content_sha256(
8672                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8673                    )),
8674                    false => None,
8675                };
8676                overrides.insert(
8677                    file.path.clone(),
8678                    V2ResolutionOverride {
8679                        expected_remote: file.remote.sha256.clone(),
8680                        selected_local,
8681                    },
8682                );
8683            }
8684            v2_sync_push(
8685                cfg,
8686                &plan.brain,
8687                &refreshed_store,
8688                head,
8689                V2SyncPushOptions {
8690                    resume_local_policy: true,
8691                    bulk_confirmation,
8692                    resolution: Some(&overrides),
8693                    pulled: None,
8694                    withdrawal_paths: &[],
8695                    withdrawal_reason: None,
8696                },
8697            )?
8698        }
8699    };
8700
8701    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
8702        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8703            message: format!("resolved checkout is not a valid db.md store: {error}"),
8704        })?;
8705        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8706    }
8707    Ok(json!({
8708        "v": 2,
8709        "class": "auto_converged",
8710        "bundle": bundle,
8711        "receipt": result,
8712    }))
8713}
8714
8715/// Converge one established permissioned-v2 checkout in both directions.
8716///
8717/// The pull half first performs the ordinary three-way conflict check and
8718/// crash-safe install against the accepted baseline. The push half then opens
8719/// that exact installed checkout, takes its local transaction guard, computes
8720/// a fresh three-way delta, and submits it with mandatory preconditions. A
8721/// remote movement between halves is therefore a normal rebase/conflict input,
8722/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
8723/// its whole-pack replacement semantics are never presented as granular
8724/// convergence.
8725pub fn sync_converge(
8726    cfg: &HubConfig,
8727    brain: &str,
8728    checkout: &Path,
8729    resume_local_policy: bool,
8730) -> LinkResult<Value> {
8731    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
8732}
8733
8734/// Bidirectional convergence with optional exact bulk-preview confirmation.
8735pub fn sync_converge_with_options(
8736    cfg: &HubConfig,
8737    brain: &str,
8738    checkout: &Path,
8739    resume_local_policy: bool,
8740    bulk_confirmation: Option<&V2BulkConfirmation>,
8741) -> LinkResult<Value> {
8742    sync_converge_with_controls(
8743        cfg,
8744        brain,
8745        checkout,
8746        resume_local_policy,
8747        bulk_confirmation,
8748        &[],
8749        None,
8750    )
8751}
8752
8753/// Bidirectional convergence with optional exact withdrawal intents.
8754pub fn sync_converge_with_controls(
8755    cfg: &HubConfig,
8756    brain: &str,
8757    checkout: &Path,
8758    resume_local_policy: bool,
8759    bulk_confirmation: Option<&V2BulkConfirmation>,
8760    withdrawal_paths: &[String],
8761    withdrawal_reason: Option<&str>,
8762) -> LinkResult<Value> {
8763    require_hardened_filesystem("bidirectional sync")?;
8764    require_safe_ref(brain)?;
8765    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
8766        message:
8767            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
8768                .to_string(),
8769    })?;
8770    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
8771    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8772        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
8773    })?;
8774    let _transaction = store.transaction()?;
8775    let pulled_report = pulled.report.clone();
8776    let pulled_head = pulled.head.clone();
8777    let mut result = v2_sync_push(
8778        cfg,
8779        brain,
8780        &store,
8781        pulled_head,
8782        V2SyncPushOptions {
8783            resume_local_policy,
8784            bulk_confirmation,
8785            resolution: None,
8786            pulled: Some(pulled),
8787            withdrawal_paths,
8788            withdrawal_reason,
8789        },
8790    )?;
8791    if let Some(object) = result.as_object_mut() {
8792        object.insert("pulled_files".to_string(), json!(pulled_report.files));
8793        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
8794        object.insert(
8795            "mode".to_string(),
8796            Value::String("bidirectional".to_string()),
8797        );
8798    }
8799    Ok(result)
8800}
8801
8802/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
8803/// exported path is safety-gated before it touches disk; files are written
8804/// atomically; nothing local is ever deleted (locals the export lacks are
8805/// *reported* in `extra_local` instead). Returns the report; rebuilding the
8806/// local index catalog afterwards is the caller's (cheap, optional) step.
8807pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8808    require_hardened_filesystem("sync pull")?;
8809    require_safe_ref(brain)?;
8810    if let Some(head) = v2_verified_head(cfg, brain)? {
8811        return v2_sync_pull(cfg, brain, head, out);
8812    }
8813    legacy_sync_pull(cfg, brain, out)
8814}
8815
8816#[cfg(windows)]
8817fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
8818    Err(LinkError::UnsupportedPlatform {
8819        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
8820    })
8821}
8822
8823#[cfg(not(windows))]
8824fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8825    let remote = verified_remote_head(cfg, brain, false)?;
8826    if !remote.head.verified {
8827        return Err(invalid_feed(
8828            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
8829        ));
8830    }
8831    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
8832    let path = format!(
8833        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
8834        remote.head.seq
8835    );
8836    let body = ensure_ok(
8837        request(cfg, "GET", &path, None, Auth::Required)?,
8838        "sync pull",
8839    )?;
8840    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
8841        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
8842    {
8843        return Err(invalid_feed(
8844            "export response is not bound to the verified snapshot token",
8845        ));
8846    }
8847
8848    let remote_slug = body
8849        .get("slug")
8850        .and_then(Value::as_str)
8851        .filter(|slug| is_safe_slug(slug));
8852    let slug = remote_slug
8853        .or_else(|| is_safe_slug(brain).then_some(brain))
8854        .unwrap_or("brain")
8855        .to_string();
8856    let brain_id = body
8857        .get("brain")
8858        .and_then(Value::as_str)
8859        .unwrap_or(&remote.head.brain)
8860        .to_string();
8861    if brain_id != remote.head.brain {
8862        return Err(invalid_feed(
8863            "export response names a different brain than the verified head",
8864        ));
8865    }
8866    let head_seq = remote.head.seq;
8867    let dest: PathBuf = match out {
8868        Some(p) => p.to_path_buf(),
8869        None => PathBuf::from(&slug),
8870    };
8871    let entries = if head_seq == 0 {
8872        let files = body
8873            .get("files")
8874            .and_then(Value::as_array)
8875            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
8876        if !files.is_empty() || body.get("url").is_some() {
8877            return Err(invalid_feed(
8878                "empty signed feed cannot authorize non-empty exported content",
8879            ));
8880        }
8881        Vec::new()
8882    } else {
8883        let signed_head = remote
8884            .head_entry
8885            .as_ref()
8886            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
8887        let expected = &signed_head.entry.pack_sha256;
8888        if !is_sha256(expected) {
8889            return Err(invalid_feed(
8890                "signed head carries an invalid snapshot pack digest",
8891            ));
8892        }
8893        if let Some(url) = body.get("url").and_then(Value::as_str) {
8894            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
8895                return Err(invalid_feed(
8896                    "export pack digest does not match the signed head entry",
8897                ));
8898            }
8899            let bytes = get_presigned(cfg, url)?;
8900            let actual = format!("{:x}", Sha256::digest(&bytes));
8901            if actual != *expected {
8902                return Err(LinkError::InvalidPack {
8903                    message: "downloaded pack does not match the signed snapshot digest"
8904                        .to_string(),
8905                });
8906            }
8907            let entries = parse_store_pack(bytes)?;
8908            if signed_head.entry.kind == "push" {
8909                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8910            }
8911            entries
8912        } else {
8913            if signed_head.entry.kind != "push" {
8914                return Err(invalid_feed(
8915                    "delta snapshots must export the exact signed pack",
8916                ));
8917            }
8918            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
8919                invalid_feed("verified snapshot export carried neither a pack nor files")
8920            })?;
8921            let mut entries = Vec::with_capacity(files.len());
8922            for file in files {
8923                let path = file
8924                    .get("path")
8925                    .and_then(Value::as_str)
8926                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
8927                let content = file
8928                    .get("content")
8929                    .and_then(Value::as_str)
8930                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
8931                entries.push((path.to_string(), content.as_bytes().to_vec()));
8932            }
8933            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8934            entries
8935        }
8936    };
8937
8938    // Gate the complete manifest before the first filesystem mutation.
8939    let mut seen = std::collections::HashSet::new();
8940    for (path, _) in &entries {
8941        if !safe_store_rel_path(path) {
8942            return Err(LinkError::UnsafePath { path: path.clone() });
8943        }
8944        if !seen.insert(path) {
8945            return Err(LinkError::InvalidPack {
8946                message: format!("duplicate path `{path}`"),
8947            });
8948        }
8949    }
8950    // Compute divergence against the still-live tree. The staged clone keeps
8951    // these extra locals byte-for-byte while overlaying the signed snapshot.
8952    let pulled: std::collections::BTreeSet<&str> =
8953        entries.iter().map(|(p, _)| p.as_str()).collect();
8954    let mut extra_local = Vec::new();
8955    if let Ok(store) = Store::open(&dest) {
8956        if let Ok(walked) = store.walk() {
8957            for rel in walked {
8958                let rel_str = rel.to_string_lossy().replace('\\', "/");
8959                if !pulled.contains(rel_str.as_str()) {
8960                    extra_local.push(rel_str);
8961                }
8962            }
8963        }
8964    }
8965    #[cfg(unix)]
8966    install_pulled_snapshot(&dest, &entries)?;
8967
8968    Ok(PullReport {
8969        brain: brain_id,
8970        slug,
8971        head_seq,
8972        files: entries.len(),
8973        dest: dest.to_string_lossy().into_owned(),
8974        extra_local,
8975        sync_status: "synced".to_string(),
8976    })
8977}
8978
8979#[cfg(unix)]
8980fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
8981    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
8982        path: display.to_string(),
8983    })
8984}
8985
8986#[cfg(unix)]
8987fn open_dir_at(
8988    parent: std::os::fd::RawFd,
8989    name: &std::ffi::CStr,
8990    display: &str,
8991) -> LinkResult<std::fs::File> {
8992    use std::os::fd::FromRawFd as _;
8993    let fd = unsafe {
8994        libc::openat(
8995            parent,
8996            name.as_ptr(),
8997            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
8998        )
8999    };
9000    if fd < 0 {
9001        return Err(LinkError::UnsafePath {
9002            path: display.to_string(),
9003        });
9004    }
9005    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9006}
9007
9008/// Open (and, where absent, create) a directory path without following a
9009/// symlink in any component. The returned directory capability remains bound
9010/// to the opened inode even if an attacker renames or replaces an ancestor.
9011#[cfg(unix)]
9012fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9013    use std::os::fd::AsRawFd as _;
9014
9015    // macOS exposes a few system-owned compatibility symlinks at the root.
9016    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
9017    // caller-controlled ancestor.
9018    #[cfg(target_os = "macos")]
9019    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9020        .into_iter()
9021        .find_map(|(alias, real)| {
9022            path.strip_prefix(alias)
9023                .ok()
9024                .map(|rest| Path::new(real).join(rest))
9025        })
9026        .unwrap_or_else(|| path.to_path_buf());
9027    #[cfg(not(target_os = "macos"))]
9028    let normalized = path.to_path_buf();
9029
9030    let start = if normalized.is_absolute() {
9031        std::fs::File::open("/")?
9032    } else {
9033        std::fs::File::open(".")?
9034    };
9035    let mut directory = start;
9036    for component in normalized.components() {
9037        use std::path::Component;
9038        let name = match component {
9039            Component::RootDir | Component::CurDir => continue,
9040            Component::Normal(name) => name,
9041            Component::ParentDir | Component::Prefix(_) => {
9042                return Err(LinkError::UnsafePath {
9043                    path: path.display().to_string(),
9044                });
9045            }
9046        };
9047        use std::os::unix::ffi::OsStrExt as _;
9048        let name = c_name(name.as_bytes(), &path.display().to_string())?;
9049        if create {
9050            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9051            if made != 0 {
9052                let error = std::io::Error::last_os_error();
9053                if error.raw_os_error() != Some(libc::EEXIST) {
9054                    return Err(error.into());
9055                }
9056            }
9057        }
9058        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9059    }
9060    Ok(directory)
9061}
9062
9063#[cfg(unix)]
9064fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9065    open_dir_path_nofollow(path, true)
9066}
9067
9068#[cfg(unix)]
9069fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9070    open_dir_path_nofollow(path, false)
9071}
9072
9073#[cfg(unix)]
9074fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9075    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9076    let result =
9077        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9078    if result == 0 {
9079        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9080    }
9081    let error = std::io::Error::last_os_error();
9082    if error.kind() == std::io::ErrorKind::NotFound {
9083        Ok(None)
9084    } else {
9085        Err(error.into())
9086    }
9087}
9088
9089#[cfg(unix)]
9090fn create_dir_exclusive_at(
9091    parent: std::os::fd::RawFd,
9092    name: &std::ffi::CStr,
9093    display: &str,
9094) -> LinkResult<std::fs::File> {
9095    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9096    if made != 0 {
9097        return Err(LinkError::UnsafePath {
9098            path: display.to_string(),
9099        });
9100    }
9101    open_dir_at(parent, name, display)
9102}
9103
9104#[cfg(unix)]
9105fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9106    use std::os::fd::AsRawFd as _;
9107
9108    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9109    if duplicate < 0 {
9110        return Err(std::io::Error::last_os_error().into());
9111    }
9112    let stream = unsafe { libc::fdopendir(duplicate) };
9113    if stream.is_null() {
9114        let error = std::io::Error::last_os_error();
9115        unsafe {
9116            libc::close(duplicate);
9117        }
9118        return Err(error.into());
9119    }
9120    let mut names = Vec::new();
9121    loop {
9122        let entry = unsafe { libc::readdir(stream) };
9123        if entry.is_null() {
9124            break;
9125        }
9126        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9127        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9128            names.push(raw.to_owned());
9129        }
9130    }
9131    if unsafe { libc::closedir(stream) } != 0 {
9132        return Err(std::io::Error::last_os_error().into());
9133    }
9134    Ok(names)
9135}
9136
9137/// Remove an entry tree relative to a held directory capability. Symlinks are
9138/// unlinked, never traversed, even when an old mirror contains hostile names.
9139#[cfg(unix)]
9140fn remove_tree_at(
9141    parent: std::os::fd::RawFd,
9142    name: &std::ffi::CStr,
9143    display: &str,
9144) -> LinkResult<()> {
9145    use std::os::fd::AsRawFd as _;
9146
9147    match entry_is_dir_at(parent, name)? {
9148        None => return Ok(()),
9149        Some(false) => {
9150            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9151                return Err(std::io::Error::last_os_error().into());
9152            }
9153        }
9154        Some(true) => {
9155            let directory = open_dir_at(parent, name, display)?;
9156            for child in directory_entry_names(&directory)? {
9157                let child_display =
9158                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9159                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9160            }
9161            drop(directory);
9162            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9163                return Err(std::io::Error::last_os_error().into());
9164            }
9165        }
9166    }
9167    Ok(())
9168}
9169
9170/// Clone a live destination into a private sibling stage without following
9171/// any symlink. Regular files are streamed between held descriptors; symlinks
9172/// are reproduced as links, never opened. Special files are refused.
9173#[cfg(unix)]
9174fn clone_tree_contents(
9175    source: &std::fs::File,
9176    destination: &std::fs::File,
9177    display: &str,
9178) -> LinkResult<()> {
9179    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9180
9181    for name in directory_entry_names(source)? {
9182        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9183        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9184        if unsafe {
9185            libc::fstatat(
9186                source.as_raw_fd(),
9187                name.as_ptr(),
9188                &mut stat,
9189                libc::AT_SYMLINK_NOFOLLOW,
9190            )
9191        } != 0
9192        {
9193            return Err(std::io::Error::last_os_error().into());
9194        }
9195        match stat.st_mode & libc::S_IFMT {
9196            libc::S_IFDIR => {
9197                if unsafe {
9198                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9199                } != 0
9200                {
9201                    return Err(std::io::Error::last_os_error().into());
9202                }
9203                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9204                let destination_child =
9205                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9206                clone_tree_contents(&source_child, &destination_child, &child_display)?;
9207                destination_child.sync_all()?;
9208            }
9209            libc::S_IFREG => {
9210                let source_fd = unsafe {
9211                    libc::openat(
9212                        source.as_raw_fd(),
9213                        name.as_ptr(),
9214                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9215                    )
9216                };
9217                if source_fd < 0 {
9218                    return Err(std::io::Error::last_os_error().into());
9219                }
9220                let destination_fd = unsafe {
9221                    libc::openat(
9222                        destination.as_raw_fd(),
9223                        name.as_ptr(),
9224                        libc::O_WRONLY
9225                            | libc::O_CREAT
9226                            | libc::O_EXCL
9227                            | libc::O_CLOEXEC
9228                            | libc::O_NOFOLLOW,
9229                        (stat.st_mode & 0o777) as libc::c_uint,
9230                    )
9231                };
9232                if destination_fd < 0 {
9233                    unsafe {
9234                        libc::close(source_fd);
9235                    }
9236                    return Err(std::io::Error::last_os_error().into());
9237                }
9238                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9239                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9240                std::io::copy(&mut input, &mut output)?;
9241                output.sync_all()?;
9242            }
9243            libc::S_IFLNK => {
9244                let mut target = vec![0_u8; 4097];
9245                let length = unsafe {
9246                    libc::readlinkat(
9247                        source.as_raw_fd(),
9248                        name.as_ptr(),
9249                        target.as_mut_ptr().cast(),
9250                        target.len(),
9251                    )
9252                };
9253                if length < 0 || length as usize >= target.len() {
9254                    return Err(LinkError::UnsafePath {
9255                        path: child_display,
9256                    });
9257                }
9258                target.truncate(length as usize);
9259                let target = c_name(&target, &child_display)?;
9260                if unsafe {
9261                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9262                } != 0
9263                {
9264                    return Err(std::io::Error::last_os_error().into());
9265                }
9266            }
9267            _ => {
9268                return Err(LinkError::UnsafePath {
9269                    path: child_display,
9270                });
9271            }
9272        }
9273    }
9274    destination.sync_all()?;
9275    Ok(())
9276}
9277
9278#[cfg(target_os = "linux")]
9279fn install_stage_at(
9280    parent: std::os::fd::RawFd,
9281    stage: &std::ffi::CStr,
9282    dest: &std::ffi::CStr,
9283    dest_exists: bool,
9284) -> LinkResult<()> {
9285    let flags = if dest_exists {
9286        libc::RENAME_EXCHANGE
9287    } else {
9288        libc::RENAME_NOREPLACE
9289    };
9290    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
9291    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
9292    // atomic exchange/no-replace boundary compiles for glibc and musl.
9293    let result = unsafe {
9294        libc::syscall(
9295            libc::SYS_renameat2,
9296            parent,
9297            stage.as_ptr(),
9298            parent,
9299            dest.as_ptr(),
9300            flags,
9301        )
9302    };
9303    if result == 0 {
9304        Ok(())
9305    } else {
9306        Err(std::io::Error::last_os_error().into())
9307    }
9308}
9309
9310#[cfg(target_os = "macos")]
9311fn install_stage_at(
9312    parent: std::os::fd::RawFd,
9313    stage: &std::ffi::CStr,
9314    dest: &std::ffi::CStr,
9315    dest_exists: bool,
9316) -> LinkResult<()> {
9317    let flags = if dest_exists {
9318        libc::RENAME_SWAP
9319    } else {
9320        libc::RENAME_EXCL
9321    };
9322    let result =
9323        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9324    if result == 0 {
9325        Ok(())
9326    } else {
9327        Err(std::io::Error::last_os_error().into())
9328    }
9329}
9330
9331#[cfg(unix)]
9332fn write_pull_entries_beneath_dir(
9333    root: &std::fs::File,
9334    entries: &[(String, Vec<u8>)],
9335) -> LinkResult<()> {
9336    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9337
9338    for (path, content) in entries {
9339        let components: Vec<&str> = path.split('/').collect();
9340        let (leaf, parents) = components
9341            .split_last()
9342            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9343        let mut directory = root.try_clone()?;
9344        for component in parents {
9345            let name = c_name(component.as_bytes(), path)?;
9346            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9347            if made != 0 {
9348                let error = std::io::Error::last_os_error();
9349                if error.raw_os_error() != Some(libc::EEXIST) {
9350                    return Err(error.into());
9351                }
9352            }
9353            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9354        }
9355
9356        let leaf_name = c_name(leaf.as_bytes(), path)?;
9357        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9358        let inspected = unsafe {
9359            libc::fstatat(
9360                directory.as_raw_fd(),
9361                leaf_name.as_ptr(),
9362                &mut existing,
9363                libc::AT_SYMLINK_NOFOLLOW,
9364            )
9365        };
9366        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9367            return Err(LinkError::UnsafePath { path: path.clone() });
9368        }
9369
9370        let nonce = std::time::SystemTime::now()
9371            .duration_since(std::time::UNIX_EPOCH)
9372            .unwrap_or_default()
9373            .as_nanos();
9374        let temp_name = format!(
9375            ".dbmd-pull-{}-{nonce}-{}",
9376            std::process::id(),
9377            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9378        );
9379        let temp = c_name(temp_name.as_bytes(), path)?;
9380        let fd = unsafe {
9381            libc::openat(
9382                directory.as_raw_fd(),
9383                temp.as_ptr(),
9384                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9385                0o600,
9386            )
9387        };
9388        if fd < 0 {
9389            return Err(std::io::Error::last_os_error().into());
9390        }
9391        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9392        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9393            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9394            return Err(error.into());
9395        }
9396        drop(file);
9397        let renamed = unsafe {
9398            libc::renameat(
9399                directory.as_raw_fd(),
9400                temp.as_ptr(),
9401                directory.as_raw_fd(),
9402                leaf_name.as_ptr(),
9403            )
9404        };
9405        if renamed != 0 {
9406            let error = std::io::Error::last_os_error();
9407            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9408            return Err(error.into());
9409        }
9410        directory.sync_all()?;
9411    }
9412    root.sync_all()?;
9413    Ok(())
9414}
9415
9416#[cfg(unix)]
9417fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9418    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9419
9420    let path = &entry.path;
9421    let components: Vec<&str> = path.split('/').collect();
9422    let (leaf, parents) = components
9423        .split_last()
9424        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9425    let mut directory = root.try_clone()?;
9426    for component in parents {
9427        let name = c_name(component.as_bytes(), path)?;
9428        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9429        if made != 0 {
9430            let error = std::io::Error::last_os_error();
9431            if error.raw_os_error() != Some(libc::EEXIST) {
9432                return Err(error.into());
9433            }
9434        }
9435        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9436    }
9437    let leaf_name = c_name(leaf.as_bytes(), path)?;
9438    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9439    if unsafe {
9440        libc::fstatat(
9441            directory.as_raw_fd(),
9442            leaf_name.as_ptr(),
9443            &mut existing,
9444            libc::AT_SYMLINK_NOFOLLOW,
9445        )
9446    } == 0
9447        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9448    {
9449        return Err(LinkError::UnsafePath { path: path.clone() });
9450    }
9451    let nonce = SystemTime::now()
9452        .duration_since(UNIX_EPOCH)
9453        .unwrap_or_default()
9454        .as_nanos();
9455    let temp_name = format!(
9456        ".dbmd-pull-{}-{nonce}-{}",
9457        std::process::id(),
9458        content_sha256(path.as_bytes())
9459    );
9460    let temp = c_name(temp_name.as_bytes(), path)?;
9461    let fd = unsafe {
9462        libc::openat(
9463            directory.as_raw_fd(),
9464            temp.as_ptr(),
9465            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9466            0o600,
9467        )
9468    };
9469    if fd < 0 {
9470        return Err(std::io::Error::last_os_error().into());
9471    }
9472    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9473    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9474    let mut digest = Sha256::new();
9475    let mut total = 0_u64;
9476    let mut buffer = [0_u8; 64 * 1024];
9477    let copied = (|| -> std::io::Result<()> {
9478        loop {
9479            let read = input.read(&mut buffer)?;
9480            if read == 0 {
9481                break;
9482            }
9483            total = total.saturating_add(read as u64);
9484            if total > entry.bytes {
9485                return Err(std::io::Error::new(
9486                    std::io::ErrorKind::InvalidData,
9487                    "staged sync source grew beyond its verified length",
9488                ));
9489            }
9490            digest.update(&buffer[..read]);
9491            output.write_all(&buffer[..read])?;
9492        }
9493        Ok(())
9494    })();
9495    if let Err(error) = copied {
9496        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9497        return Err(error.into());
9498    }
9499    drop(output);
9500    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9501        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9502        return Err(invalid_feed(
9503            "private staged sync source failed final integrity verification",
9504        ));
9505    }
9506    if unsafe {
9507        libc::renameat(
9508            directory.as_raw_fd(),
9509            temp.as_ptr(),
9510            directory.as_raw_fd(),
9511            leaf_name.as_ptr(),
9512        )
9513    } != 0
9514    {
9515        let error = std::io::Error::last_os_error();
9516        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9517        return Err(error.into());
9518    }
9519    Ok(())
9520}
9521
9522#[cfg(unix)]
9523fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9524    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9525
9526    let path = &entry.path;
9527    let components: Vec<&str> = path.split('/').collect();
9528    let (leaf, parents) = components
9529        .split_last()
9530        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9531    let mut directory = root.try_clone()?;
9532    for component in parents {
9533        directory = open_dir_at(
9534            directory.as_raw_fd(),
9535            &c_name(component.as_bytes(), path)?,
9536            path,
9537        )?;
9538    }
9539    let leaf = c_name(leaf.as_bytes(), path)?;
9540    let fd = unsafe {
9541        libc::openat(
9542            directory.as_raw_fd(),
9543            leaf.as_ptr(),
9544            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9545        )
9546    };
9547    if fd < 0 {
9548        return Err(std::io::Error::last_os_error().into());
9549    }
9550    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9551    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9552        return Err(invalid_feed(
9553            "private pull stage changed before its durability barrier",
9554        ));
9555    }
9556    file.sync_all()?;
9557    Ok(())
9558}
9559
9560#[cfg(unix)]
9561fn run_pull_source_workers(
9562    root: &std::fs::File,
9563    entries: &[V2StagedFile],
9564    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9565) -> LinkResult<()> {
9566    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9567
9568    let next = AtomicUsize::new(0);
9569    let failed = AtomicBool::new(false);
9570    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9571    let mut first_error = None;
9572    std::thread::scope(|scope| {
9573        let (sender, receiver) = std::sync::mpsc::channel();
9574        for _ in 0..worker_count {
9575            let sender = sender.clone();
9576            let next = &next;
9577            let failed = &failed;
9578            scope.spawn(move || {
9579                while !failed.load(Ordering::Acquire) {
9580                    let index = next.fetch_add(1, Ordering::Relaxed);
9581                    let Some(entry) = entries.get(index) else {
9582                        break;
9583                    };
9584                    let result = operation(root, entry);
9585                    if result.is_err() {
9586                        failed.store(true, Ordering::Release);
9587                    }
9588                    if sender.send(result).is_err() {
9589                        break;
9590                    }
9591                }
9592            });
9593        }
9594        drop(sender);
9595        for result in receiver {
9596            if let Err(error) = result {
9597                if first_error.is_none() {
9598                    first_error = Some(error);
9599                }
9600            }
9601        }
9602    });
9603    if let Some(error) = first_error {
9604        return Err(error);
9605    }
9606    if next.load(Ordering::Relaxed) < entries.len() {
9607        return Err(invalid_feed(
9608            "a bounded pull worker stopped before reporting every file",
9609        ));
9610    }
9611    Ok(())
9612}
9613
9614#[cfg(unix)]
9615fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9616    use std::os::fd::AsRawFd as _;
9617
9618    for name in directory_entry_names(root)? {
9619        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9620            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9621            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9622            sync_pull_directory_tree(&child, &child_display)?;
9623        }
9624    }
9625    root.sync_all()?;
9626    Ok(())
9627}
9628
9629#[cfg(unix)]
9630fn write_pull_sources_beneath_dir(
9631    root: &std::fs::File,
9632    entries: &[V2StagedFile],
9633) -> LinkResult<()> {
9634    // The stage is still private and unreachable as the checkout here. Write
9635    // and verify every leaf first so the filesystem can coalesce dirty data,
9636    // then re-open/re-hash/fsync leaves through the held root capability. Only
9637    // after every file barrier succeeds do we flush each directory once. This
9638    // preserves the same crash durability as file-fsync + directory-fsync per
9639    // path without serializing 50,000 redundant directory barriers.
9640    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9641    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9642    sync_pull_directory_tree(root, "v2 pull stage")
9643}
9644
9645#[cfg(unix)]
9646fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9647    use std::os::fd::AsRawFd as _;
9648    for path in paths {
9649        if !safe_store_rel_path(path) {
9650            return Err(LinkError::UnsafePath { path: path.clone() });
9651        }
9652        let components = path.split('/').collect::<Vec<_>>();
9653        let Some((leaf, parents)) = components.split_last() else {
9654            return Err(LinkError::UnsafePath { path: path.clone() });
9655        };
9656        let mut directory = root.try_clone()?;
9657        let mut missing = false;
9658        for component in parents {
9659            let name = c_name(component.as_bytes(), path)?;
9660            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9661                None => {
9662                    missing = true;
9663                    break;
9664                }
9665                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9666                Some(true) => {
9667                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9668                }
9669            }
9670        }
9671        if missing {
9672            continue;
9673        }
9674        let leaf = c_name(leaf.as_bytes(), path)?;
9675        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9676            None => {}
9677            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9678            Some(false) => {
9679                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9680                    return Err(std::io::Error::last_os_error().into());
9681                }
9682                directory.sync_all()?;
9683            }
9684        }
9685    }
9686    Ok(())
9687}
9688
9689#[cfg(unix)]
9690fn install_pulled_delta(
9691    dest: &Path,
9692    entries: &[(String, Vec<u8>)],
9693    deleted: &[String],
9694    rebuild_indexes: bool,
9695) -> LinkResult<()> {
9696    use ring::rand::SecureRandom as _;
9697    use std::os::fd::AsRawFd as _;
9698    use std::os::unix::ffi::OsStrExt as _;
9699
9700    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9701    let name = dest
9702        .file_name()
9703        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9704        .ok_or_else(|| LinkError::UnsafePath {
9705            path: dest.display().to_string(),
9706        })?;
9707    let parent_dir = open_or_create_dir_nofollow(parent)?;
9708    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9709    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9710        None => false,
9711        Some(true) => true,
9712        Some(false) => {
9713            return Err(LinkError::UnsafePath {
9714                path: dest.display().to_string(),
9715            });
9716        }
9717    };
9718
9719    let mut nonce = [0_u8; 16];
9720    ring::rand::SystemRandom::new()
9721        .fill(&mut nonce)
9722        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9723    let stage_label = format!(
9724        ".{}.dbmd-pull-stage-{}",
9725        name.to_string_lossy(),
9726        URL_SAFE_NO_PAD.encode(nonce)
9727    );
9728    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9729    let stage_dir = create_dir_exclusive_at(
9730        parent_dir.as_raw_fd(),
9731        &stage_name,
9732        &dest.display().to_string(),
9733    )?;
9734
9735    let prepared = (|| -> LinkResult<()> {
9736        if dest_exists {
9737            let live = open_dir_at(
9738                parent_dir.as_raw_fd(),
9739                &dest_name,
9740                &dest.display().to_string(),
9741            )?;
9742            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9743        }
9744        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9745        write_pull_entries_beneath_dir(&stage_dir, entries)?;
9746        if rebuild_indexes {
9747            let stage_store =
9748                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9749                    .map_err(|error| LinkError::InvalidPack {
9750                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
9751                    })?;
9752            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9753                LinkError::InvalidPack {
9754                    message: format!("could not materialize v2 local catalogs: {error}"),
9755                }
9756            })?;
9757        }
9758        stage_dir.sync_all()?;
9759        Ok(())
9760    })();
9761    if let Err(error) = prepared {
9762        let _ = remove_tree_at(
9763            parent_dir.as_raw_fd(),
9764            &stage_name,
9765            &dest.display().to_string(),
9766        );
9767        return Err(error);
9768    }
9769
9770    if let Err(error) =
9771        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9772    {
9773        let _ = remove_tree_at(
9774            parent_dir.as_raw_fd(),
9775            &stage_name,
9776            &dest.display().to_string(),
9777        );
9778        return Err(error);
9779    }
9780    parent_dir.sync_all()?;
9781    if dest_exists {
9782        // Commit already happened atomically. Cleanup is best-effort so a
9783        // failure cannot be reported as a failed pull after the live tree
9784        // changed; a crash may leave only this private old-tree sibling.
9785        let _ = remove_tree_at(
9786            parent_dir.as_raw_fd(),
9787            &stage_name,
9788            &dest.display().to_string(),
9789        );
9790        let _ = parent_dir.sync_all();
9791    }
9792    Ok(())
9793}
9794
9795#[cfg(unix)]
9796fn install_pulled_delta_sources(
9797    dest: &Path,
9798    entries: &[V2StagedFile],
9799    deleted: &[String],
9800    rebuild_indexes: bool,
9801    _previous: Option<&V2SyncBaseline>,
9802    _next: &V2VerifiedHead,
9803) -> LinkResult<()> {
9804    use ring::rand::SecureRandom as _;
9805    use std::os::fd::AsRawFd as _;
9806    use std::os::unix::ffi::OsStrExt as _;
9807
9808    // An established checkout is updated only at the changed coordinates
9809    // through the durable pull journal shared with Windows. Initial clones
9810    // still use a private complete stage and one atomic directory install.
9811    if let Ok(store) = Store::open_strict(dest) {
9812        return install_established_v2_delta(
9813            store,
9814            entries,
9815            deleted,
9816            rebuild_indexes,
9817            _previous,
9818            _next,
9819        );
9820    }
9821
9822    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9823    let name = dest
9824        .file_name()
9825        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9826        .ok_or_else(|| LinkError::UnsafePath {
9827            path: dest.display().to_string(),
9828        })?;
9829    let parent_dir = open_or_create_dir_nofollow(parent)?;
9830    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9831    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9832        None => false,
9833        Some(true) => true,
9834        Some(false) => {
9835            return Err(LinkError::UnsafePath {
9836                path: dest.display().to_string(),
9837            })
9838        }
9839    };
9840    let mut nonce = [0_u8; 16];
9841    ring::rand::SystemRandom::new()
9842        .fill(&mut nonce)
9843        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9844    let stage_label = format!(
9845        ".{}.dbmd-pull-stage-{}",
9846        name.to_string_lossy(),
9847        URL_SAFE_NO_PAD.encode(nonce)
9848    );
9849    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9850    let stage_dir = create_dir_exclusive_at(
9851        parent_dir.as_raw_fd(),
9852        &stage_name,
9853        &dest.display().to_string(),
9854    )?;
9855    let prepared = (|| -> LinkResult<()> {
9856        if dest_exists {
9857            let live = open_dir_at(
9858                parent_dir.as_raw_fd(),
9859                &dest_name,
9860                &dest.display().to_string(),
9861            )?;
9862            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9863        }
9864        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9865        write_pull_sources_beneath_dir(&stage_dir, entries)?;
9866        if rebuild_indexes {
9867            let stage_store =
9868                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9869                    .map_err(|error| LinkError::InvalidPack {
9870                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
9871                    })?;
9872            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9873                LinkError::InvalidPack {
9874                    message: format!("could not materialize v2 local catalogs: {error}"),
9875                }
9876            })?;
9877        }
9878        stage_dir.sync_all()?;
9879        Ok(())
9880    })();
9881    if let Err(error) = prepared {
9882        let _ = remove_tree_at(
9883            parent_dir.as_raw_fd(),
9884            &stage_name,
9885            &dest.display().to_string(),
9886        );
9887        return Err(error);
9888    }
9889    if let Err(error) =
9890        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9891    {
9892        let _ = remove_tree_at(
9893            parent_dir.as_raw_fd(),
9894            &stage_name,
9895            &dest.display().to_string(),
9896        );
9897        return Err(error);
9898    }
9899    parent_dir.sync_all()?;
9900    if dest_exists {
9901        let _ = remove_tree_at(
9902            parent_dir.as_raw_fd(),
9903            &stage_name,
9904            &dest.display().to_string(),
9905        );
9906        let _ = parent_dir.sync_all();
9907    }
9908    Ok(())
9909}
9910
9911#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9912struct V2PullCoordinate {
9913    head_seq: Option<u64>,
9914    commit_hash: Option<String>,
9915    view_kind: Option<String>,
9916    view_revision: Option<String>,
9917}
9918
9919#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9920struct V2PullFileCoordinate {
9921    sha256: String,
9922    bytes: u64,
9923}
9924
9925#[derive(Debug, Clone, Deserialize, Serialize)]
9926struct V2PullJournalEntry {
9927    path: String,
9928    old: Option<V2PullFileCoordinate>,
9929    new: Option<V2PullFileCoordinate>,
9930    backup: Option<String>,
9931}
9932
9933#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9934#[serde(rename_all = "snake_case")]
9935enum V2PullPhase {
9936    Preparing,
9937    Ready,
9938}
9939
9940#[derive(Debug, Clone, Deserialize, Serialize)]
9941struct V2PullJournal {
9942    v: u8,
9943    phase: V2PullPhase,
9944    brain: String,
9945    previous: V2PullCoordinate,
9946    next: V2PullCoordinate,
9947    backup_dir: String,
9948    entries: Vec<V2PullJournalEntry>,
9949}
9950
9951const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
9952
9953fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
9954    V2PullCoordinate {
9955        head_seq: baseline.and_then(|value| value.head_seq),
9956        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
9957        view_kind: baseline.and_then(|value| value.view_kind.clone()),
9958        view_revision: baseline.and_then(|value| value.view_revision.clone()),
9959    }
9960}
9961
9962fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
9963    V2PullCoordinate {
9964        head_seq: head.pointer.as_ref().map(|value| value.seq),
9965        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
9966        view_kind: Some(head.view_kind.clone()),
9967        view_revision: Some(head.view_revision.clone()),
9968    }
9969}
9970
9971fn v2_pull_file_coordinate(
9972    store: &Store,
9973    path: &str,
9974    limit: u64,
9975) -> LinkResult<Option<V2PullFileCoordinate>> {
9976    let file = match store.open_regular(Path::new(path)) {
9977        Ok(file) => file,
9978        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9979        Err(error) => return Err(error.into()),
9980    };
9981    let bytes = file.metadata()?.len();
9982    if bytes > limit || bytes > MAX_STORE_BYTES {
9983        return Err(invalid_feed(
9984            "pull transaction file exceeds its declared bound",
9985        ));
9986    }
9987    Ok(Some(V2PullFileCoordinate {
9988        sha256: content_sha256_reader(file)?,
9989        bytes,
9990    }))
9991}
9992
9993fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
9994    let mut bytes = serde_json::to_vec_pretty(journal)
9995        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
9996    bytes.push(b'\n');
9997    Ok(bytes)
9998}
9999
10000fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10001    let backup_prefix = ".dbmd/pull-backup-";
10002    let suffix = journal
10003        .backup_dir
10004        .strip_prefix(backup_prefix)
10005        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10006    let mut paths = std::collections::BTreeSet::new();
10007    if journal.v != 1
10008        || !crate::ulid::is_ulid(&journal.brain)
10009        || !crate::ulid::is_ulid(suffix)
10010        || journal.entries.is_empty()
10011        || journal.entries.len() > MAX_PUSH_FILES + 4
10012        || journal.previous == journal.next
10013    {
10014        return Err(invalid_feed("v2 pull journal failed validation"));
10015    }
10016    for (index, entry) in journal.entries.iter().enumerate() {
10017        if !safe_store_rel_path(&entry.path)
10018            || entry.path == V2_PULL_JOURNAL
10019            || entry.path.starts_with(backup_prefix)
10020            || !paths.insert(entry.path.clone())
10021            || (entry.old.is_none() && entry.new.is_none())
10022            || entry
10023                .old
10024                .iter()
10025                .chain(entry.new.iter())
10026                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10027            || entry.backup.as_deref()
10028                != entry
10029                    .old
10030                    .as_ref()
10031                    .map(|_| format!("{index:08x}"))
10032                    .as_deref()
10033        {
10034            return Err(invalid_feed("v2 pull journal entry failed validation"));
10035        }
10036    }
10037    Ok(())
10038}
10039
10040fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10041    #[cfg(unix)]
10042    {
10043        use std::os::unix::fs::PermissionsExt as _;
10044        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10045            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10046                return Err(invalid_feed(
10047                    "v2 pull journal is accessible to group/other; set mode 0600",
10048                ));
10049            }
10050            Ok(_) => {}
10051            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10052            Err(error) => return Err(error.into()),
10053        }
10054    }
10055    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10056        Ok(bytes) => bytes,
10057        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10058        Err(error) => return Err(error.into()),
10059    };
10060    let journal: V2PullJournal =
10061        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10062    validate_v2_pull_journal(&journal)?;
10063    Ok(Some(journal))
10064}
10065
10066fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10067    // The journal is the only recovery authority. Remove it first only after
10068    // rollback or baseline commit is durable; an orphan private backup is safe
10069    // and can be pruned, while a journal without its backups is not recoverable.
10070    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10071        Ok(()) => {}
10072        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10073        Err(error) => return Err(error.into()),
10074    }
10075    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10076        Ok(()) => Ok(()),
10077        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10078        Err(error) => Err(error.into()),
10079    }
10080}
10081
10082fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10083    let names = match store.directory_names(Path::new(".dbmd")) {
10084        Ok(names) => names,
10085        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10086        Err(error) => return Err(error.into()),
10087    };
10088    for name in names {
10089        let Some(name) = name.to_str() else {
10090            continue;
10091        };
10092        let Some(suffix) = name.strip_prefix("pull-backup-") else {
10093            continue;
10094        };
10095        if crate::ulid::is_ulid(suffix) {
10096            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10097        }
10098    }
10099    Ok(())
10100}
10101
10102fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10103    // Validate every live coordinate and every backup before the first restore.
10104    for entry in &journal.entries {
10105        let limit = entry
10106            .old
10107            .as_ref()
10108            .into_iter()
10109            .chain(entry.new.iter())
10110            .map(|value| value.bytes)
10111            .max()
10112            .unwrap_or(0);
10113        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10114        if current != entry.old && current != entry.new {
10115            return Err(LinkError::InvalidPack {
10116                message: format!(
10117                    "cannot recover interrupted pull because `{}` changed afterward",
10118                    entry.path
10119                ),
10120            });
10121        }
10122        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10123            let path = Path::new(&journal.backup_dir).join(backup);
10124            let file = store.open_regular(&path)?;
10125            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10126                return Err(invalid_feed("v2 pull recovery backup failed verification"));
10127            }
10128        }
10129    }
10130    for entry in journal.entries.iter().rev() {
10131        match (&entry.old, &entry.backup) {
10132            (Some(old), Some(backup)) => {
10133                let bytes =
10134                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10135                store.write_atomic(Path::new(&entry.path), &bytes)?;
10136            }
10137            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10138                store.remove_file(Path::new(&entry.path))?;
10139            }
10140            (None, None) => {}
10141            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10142        }
10143    }
10144    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10145        message: format!("could not rebuild catalogs after pull recovery: {error}"),
10146    })?;
10147    cleanup_v2_pull_journal(store, journal)
10148}
10149
10150fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10151    let Ok(store) = Store::open_strict(dest) else {
10152        return Ok(());
10153    };
10154    if let Some(journal) = load_v2_pull_journal(&store)? {
10155        if journal.brain != brain {
10156            return Err(invalid_feed("v2 pull journal belongs to another brain"));
10157        }
10158        if journal.phase == V2PullPhase::Preparing {
10159            cleanup_v2_pull_journal(&store, &journal)?;
10160        } else {
10161            let baseline = load_v2_baseline(cfg, brain, dest)?;
10162            let current = v2_pull_baseline_coordinate(baseline.as_ref());
10163            if current == journal.next {
10164                cleanup_v2_pull_journal(&store, &journal)?;
10165            } else {
10166                if current != journal.previous {
10167                    return Err(invalid_feed(
10168                        "cannot recover interrupted pull because its baseline changed afterward",
10169                    ));
10170                }
10171                rollback_v2_pull(&store, &journal)?;
10172            }
10173        }
10174    }
10175    // Cleanup removes the journal first after the state is durably old or new.
10176    // A hard kill in the tiny interval before deleting its backup directory can
10177    // therefore leave only an inert, private orphan. The per-brain operation
10178    // lock is held by the caller, so no live pull can own one here.
10179    prune_orphan_v2_pull_backups(&store)
10180}
10181
10182fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10183    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10184        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10185    })?;
10186    if let Some(journal) = load_v2_pull_journal(&store)? {
10187        cleanup_v2_pull_journal(&store, &journal)?;
10188    }
10189    Ok(())
10190}
10191
10192#[cfg(windows)]
10193fn install_windows_initial_sources(
10194    dest: &Path,
10195    entries: &[V2StagedFile],
10196    rebuild_indexes: bool,
10197) -> LinkResult<()> {
10198    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10199    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10200        path: dest.display().to_string(),
10201    })?;
10202    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10203    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10204        return Err(LinkError::UnsafePath {
10205            path: dest.display().to_string(),
10206        });
10207    }
10208    let stage_name = format!(
10209        ".{}.dbmd-pull-stage-{}",
10210        name.to_string_lossy(),
10211        crate::ulid::mint()
10212    );
10213    let stage_path = parent.join(&stage_name);
10214    let stage_capability =
10215        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10216    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10217    let prepared = (|| -> LinkResult<()> {
10218        for entry in entries {
10219            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10220            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10221                return Err(invalid_feed(
10222                    "private staged sync source failed final integrity verification",
10223                ));
10224            }
10225            stage.write_atomic(Path::new(&entry.path), &bytes)?;
10226        }
10227        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10228            .map_err(|error| LinkError::InvalidPack {
10229                message: format!("v2 staging tree is not a valid db.md store: {error}"),
10230            })?;
10231        if rebuild_indexes {
10232            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10233                message: format!("could not materialize v2 local catalogs: {error}"),
10234            })?;
10235        }
10236        Ok(())
10237    })();
10238    if let Err(error) = prepared {
10239        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10240        return Err(error);
10241    }
10242    crate::fsx::rename_directory_beneath(
10243        &parent_capability,
10244        Path::new(&stage_name),
10245        Path::new(name),
10246    )?;
10247    Ok(())
10248}
10249
10250fn install_established_v2_delta(
10251    store: Store,
10252    entries: &[V2StagedFile],
10253    deleted: &[String],
10254    rebuild_indexes: bool,
10255    previous: Option<&V2SyncBaseline>,
10256    next: &V2VerifiedHead,
10257) -> LinkResult<()> {
10258    if load_v2_pull_journal(&store)?.is_some() {
10259        return Err(invalid_feed(
10260            "an interrupted pull must be recovered before installing",
10261        ));
10262    }
10263    let mut sources = std::collections::BTreeMap::new();
10264    for entry in entries {
10265        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10266            return Err(invalid_feed("pull mutation repeats a path"));
10267        }
10268    }
10269    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10270    paths.extend(deleted.iter().cloned());
10271    paths.sort();
10272    paths.dedup();
10273    if paths.is_empty() {
10274        return Ok(());
10275    }
10276    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10277    let mut journal = V2PullJournal {
10278        v: 1,
10279        phase: V2PullPhase::Preparing,
10280        brain: next.brain_id.clone(),
10281        previous: v2_pull_baseline_coordinate(previous),
10282        next: v2_pull_head_coordinate(next),
10283        backup_dir: backup_dir.clone(),
10284        entries: Vec::with_capacity(paths.len()),
10285    };
10286    for path in &paths {
10287        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10288        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10289            sha256: entry.sha256.clone(),
10290            bytes: entry.bytes,
10291        });
10292        if old == new {
10293            continue;
10294        }
10295        let index = journal.entries.len();
10296        journal.entries.push(V2PullJournalEntry {
10297            path: path.clone(),
10298            backup: old.as_ref().map(|_| format!("{index:08x}")),
10299            old,
10300            new,
10301        });
10302    }
10303    if journal.entries.is_empty() {
10304        return Ok(());
10305    }
10306    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10307        entry
10308            .old
10309            .as_ref()
10310            .map_or(Some(total), |old| total.checked_add(old.bytes))
10311    });
10312    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10313        return Err(LinkError::InvalidPack {
10314            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10315        });
10316    }
10317    validate_v2_pull_journal(&journal)?;
10318    store.write_private_atomic_new(
10319        Path::new(V2_PULL_JOURNAL),
10320        &v2_pull_journal_bytes(&journal)?,
10321    )?;
10322    let prepared = (|| -> LinkResult<()> {
10323        store.create_private_dir_all(Path::new(&backup_dir))?;
10324        for entry in &journal.entries {
10325            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10326                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10327                if content_sha256(&bytes) != old.sha256 {
10328                    return Err(invalid_feed("live pull source changed during backup"));
10329                }
10330                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10331            }
10332        }
10333        journal.phase = V2PullPhase::Ready;
10334        store.write_private_atomic(
10335            Path::new(V2_PULL_JOURNAL),
10336            &v2_pull_journal_bytes(&journal)?,
10337        )?;
10338        Ok(())
10339    })();
10340    if let Err(error) = prepared {
10341        let cleanup = cleanup_v2_pull_journal(&store, &journal);
10342        return match cleanup {
10343            Ok(()) => Err(error),
10344            Err(cleanup) => Err(LinkError::InvalidPack {
10345                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10346            }),
10347        };
10348    }
10349    let installed = (|| -> LinkResult<()> {
10350        for entry in &journal.entries {
10351            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10352                return Err(LinkError::InvalidPack {
10353                    message: format!("local path `{}` changed during pull", entry.path),
10354                });
10355            }
10356            if let Some(source) = sources.get(&entry.path) {
10357                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10358                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10359                    return Err(invalid_feed(
10360                        "private staged sync source failed final integrity verification",
10361                    ));
10362                }
10363                store.write_atomic(Path::new(&entry.path), &bytes)?;
10364            } else if entry.old.is_some() {
10365                store.remove_file(Path::new(&entry.path))?;
10366            }
10367        }
10368        if rebuild_indexes {
10369            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10370                message: format!("could not materialize v2 local catalogs: {error}"),
10371            })?;
10372        }
10373        Ok(())
10374    })();
10375    if let Err(error) = installed {
10376        return match rollback_v2_pull(&store, &journal) {
10377            Ok(()) => Err(error),
10378            Err(rollback) => Err(LinkError::InvalidPack {
10379                message: format!("{error}; durable pull rollback also failed: {rollback}"),
10380            }),
10381        };
10382    }
10383    Ok(())
10384}
10385
10386#[cfg(windows)]
10387fn install_pulled_delta_sources(
10388    dest: &Path,
10389    entries: &[V2StagedFile],
10390    deleted: &[String],
10391    rebuild_indexes: bool,
10392    previous: Option<&V2SyncBaseline>,
10393    next: &V2VerifiedHead,
10394) -> LinkResult<()> {
10395    match Store::open_strict(dest) {
10396        Ok(store) => {
10397            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10398        }
10399        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10400    }
10401}
10402
10403#[cfg(not(any(unix, windows)))]
10404fn install_pulled_delta_sources(
10405    _dest: &Path,
10406    _entries: &[V2StagedFile],
10407    _deleted: &[String],
10408    _rebuild_indexes: bool,
10409    _previous: Option<&V2SyncBaseline>,
10410    _next: &V2VerifiedHead,
10411) -> LinkResult<()> {
10412    Err(LinkError::UnsupportedPlatform {
10413        operation: "atomic v2 pull install",
10414    })
10415}
10416
10417#[cfg(unix)]
10418fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10419    install_pulled_delta(dest, entries, &[], false)
10420}
10421
10422#[cfg(not(windows))]
10423fn is_safe_slug(slug: &str) -> bool {
10424    !slug.is_empty()
10425        && slug.len() <= 63
10426        && !slug.starts_with('-')
10427        && !slug.ends_with('-')
10428        && slug
10429            .bytes()
10430            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10431}
10432
10433fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10434    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10435}
10436
10437fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10438    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10439}
10440
10441fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10442    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10443}
10444
10445fn preflight_zip_central_directory(
10446    bytes: &[u8],
10447    offset: usize,
10448    size: usize,
10449    count: u64,
10450) -> LinkResult<()> {
10451    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10452    let end = offset
10453        .checked_add(size)
10454        .filter(|end| *end <= bytes.len())
10455        .ok_or_else(|| LinkError::InvalidPack {
10456            message: "ZIP central directory is out of bounds".to_string(),
10457        })?;
10458    let mut cursor = offset;
10459    for _ in 0..count {
10460        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10461            return Err(LinkError::InvalidPack {
10462                message: "ZIP central directory entry count is inconsistent".to_string(),
10463            });
10464        }
10465        if le_u16(bytes, cursor + 34) != Some(0) {
10466            return Err(LinkError::InvalidPack {
10467                message: "multi-disk ZIP archives are not supported".to_string(),
10468            });
10469        }
10470        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10471            total.checked_add(le_u16(bytes, cursor + at)? as usize)
10472        });
10473        cursor = cursor
10474            .checked_add(46)
10475            .and_then(|fixed| fixed.checked_add(variable?))
10476            .filter(|cursor| *cursor <= end)
10477            .ok_or_else(|| LinkError::InvalidPack {
10478                message: "ZIP central directory entry is truncated".to_string(),
10479            })?;
10480    }
10481    if cursor != end {
10482        return Err(LinkError::InvalidPack {
10483            message: "ZIP central directory size is inconsistent".to_string(),
10484        });
10485    }
10486    Ok(())
10487}
10488
10489/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
10490/// metadata object per central-directory entry. Supports ordinary EOCD and the
10491/// Zip64 locator/record emitted for >65,535-entry archives.
10492fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10493    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10494    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10495    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10496    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10497    let eocd = bytes[search_start..]
10498        .windows(4)
10499        .rposition(|window| window == EOCD_SIG)
10500        .map(|offset| search_start + offset)
10501        .ok_or_else(|| LinkError::InvalidPack {
10502            message: "ZIP has no end-of-central-directory record".to_string(),
10503        })?;
10504    let invalid_end = || LinkError::InvalidPack {
10505        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10506    };
10507    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10508    if eocd
10509        .checked_add(22)
10510        .and_then(|end| end.checked_add(comment_len))
10511        != Some(bytes.len())
10512    {
10513        // Do not fall back to an earlier signature. ZipArchive does, and a
10514        // fake low-count EOCD appended after a real Zip64 entry-count bomb
10515        // would otherwise bypass this allocation preflight.
10516        return Err(invalid_end());
10517    }
10518    let disk = le_u16(bytes, eocd + 4);
10519    let central_disk = le_u16(bytes, eocd + 6);
10520    if disk != Some(0) || central_disk != Some(0) {
10521        return Err(LinkError::InvalidPack {
10522            message: "multi-disk ZIP archives are not supported".to_string(),
10523        });
10524    }
10525    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10526    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10527    if entries_on_disk != ordinary {
10528        return Err(LinkError::InvalidPack {
10529            message: "multi-disk ZIP archives are not supported".to_string(),
10530        });
10531    }
10532    let zip64_locator = eocd
10533        .checked_sub(20)
10534        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10535    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10536        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10537        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10538        if central_offset
10539            .checked_add(central_size)
10540            .filter(|end| *end == eocd)
10541            .is_none()
10542        {
10543            return Err(invalid_end());
10544        }
10545        (ordinary as u64, central_offset, central_size)
10546    } else {
10547        let Some(locator) = zip64_locator else {
10548            return Err(invalid_end());
10549        };
10550        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10551            return Err(LinkError::InvalidPack {
10552                message: "multi-disk ZIP64 archives are not supported".to_string(),
10553            });
10554        }
10555        let record = le_u64(bytes, locator + 8)
10556            .and_then(|offset| usize::try_from(offset).ok())
10557            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10558            .ok_or_else(|| LinkError::InvalidPack {
10559                message: "ZIP64 archive has an invalid end record".to_string(),
10560            })?;
10561        let record_size = le_u64(bytes, record + 4)
10562            .and_then(|size| usize::try_from(size).ok())
10563            .filter(|size| *size >= 44)
10564            .ok_or_else(invalid_end)?;
10565        if record
10566            .checked_add(12)
10567            .and_then(|end| end.checked_add(record_size))
10568            != Some(locator)
10569            || le_u32(bytes, record + 16) != Some(0)
10570            || le_u32(bytes, record + 20) != Some(0)
10571        {
10572            return Err(invalid_end());
10573        }
10574        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10575        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10576        let central_size = le_u64(bytes, record + 40)
10577            .and_then(|size| usize::try_from(size).ok())
10578            .ok_or_else(invalid_end)?;
10579        let central_offset = le_u64(bytes, record + 48)
10580            .and_then(|offset| usize::try_from(offset).ok())
10581            .ok_or_else(invalid_end)?;
10582        if zip64_on_disk != zip64_total
10583            || central_offset
10584                .checked_add(central_size)
10585                .filter(|end| *end == record)
10586                .is_none()
10587        {
10588            return Err(invalid_end());
10589        }
10590        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10591        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10592        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10593            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10594        {
10595            return Err(invalid_end());
10596        }
10597        (zip64_total, central_offset, central_size)
10598    };
10599    if count == 0 || count > max_entries as u64 {
10600        return Err(LinkError::InvalidPack {
10601            message: format!("invalid file count {count}"),
10602        });
10603    }
10604    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10605    Ok(())
10606}
10607
10608fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10609    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10610    let mut archive =
10611        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10612            message: format!("ZIP parse failed: {err}"),
10613        })?;
10614    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10615        return Err(LinkError::InvalidPack {
10616            message: format!("invalid file count {}", archive.len()),
10617        });
10618    }
10619    let mut total = 0u64;
10620    let mut seen = std::collections::HashSet::new();
10621    let mut entries = Vec::with_capacity(archive.len());
10622    for index in 0..archive.len() {
10623        let mut file = archive
10624            .by_index(index)
10625            .map_err(|err| LinkError::InvalidPack {
10626                message: format!("ZIP entry failed: {err}"),
10627            })?;
10628        if file.is_dir() {
10629            continue;
10630        }
10631        let path = file.name().to_string();
10632        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10633            return Err(LinkError::UnsafePath { path });
10634        }
10635        if file
10636            .unix_mode()
10637            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10638        {
10639            return Err(LinkError::InvalidPack {
10640                message: format!("non-file entry `{path}`"),
10641            });
10642        }
10643        if !seen.insert(path.clone()) {
10644            return Err(LinkError::InvalidPack {
10645                message: format!("duplicate path `{path}`"),
10646            });
10647        }
10648        let remaining = MAX_STORE_BYTES.saturating_sub(total);
10649        if file.size() > remaining {
10650            return Err(LinkError::InvalidPack {
10651                message: "expanded content exceeds the 512 MB limit".to_string(),
10652            });
10653        }
10654        let mut content = Vec::new();
10655        (&mut file)
10656            .take(remaining + 1)
10657            .read_to_end(&mut content)
10658            .map_err(|err| LinkError::InvalidPack {
10659                message: format!("could not decompress `{path}`: {err}"),
10660            })?;
10661        if content.len() as u64 > remaining {
10662            return Err(LinkError::InvalidPack {
10663                message: "expanded content exceeds the 512 MB limit".to_string(),
10664            });
10665        }
10666        if content.len() as u64 != file.size() {
10667            return Err(LinkError::InvalidPack {
10668                message: format!("length mismatch for `{path}`"),
10669            });
10670        }
10671        total += content.len() as u64;
10672        entries.push((path, content));
10673    }
10674    if entries.is_empty() {
10675        return Err(LinkError::InvalidPack {
10676            message: "pack contains no files".to_string(),
10677        });
10678    }
10679    Ok(entries)
10680}
10681
10682fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
10683    let mut expected = std::collections::BTreeMap::new();
10684    for file in signed {
10685        if !safe_store_rel_path(&file.path) {
10686            return Err(LinkError::UnsafePath {
10687                path: file.path.clone(),
10688            });
10689        }
10690        if !is_sha256(&file.sha256)
10691            || expected
10692                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10693                .is_some()
10694        {
10695            return Err(invalid_feed(
10696                "signed snapshot manifest contains an invalid or duplicate file",
10697            ));
10698        }
10699    }
10700    if expected.len() != entries.len() {
10701        return Err(invalid_feed(
10702            "downloaded pack file set differs from the signed snapshot manifest",
10703        ));
10704    }
10705    for (path, bytes) in entries {
10706        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
10707            return Err(invalid_feed(format!(
10708                "downloaded pack contains unsigned path `{path}`"
10709            )));
10710        };
10711        if *declared_bytes != bytes.len() as u64
10712            || *sha256 != format!("{:x}", Sha256::digest(bytes))
10713        {
10714            return Err(invalid_feed(format!(
10715                "downloaded file `{path}` differs from its signed manifest"
10716            )));
10717        }
10718    }
10719    Ok(())
10720}
10721
10722/// Collect the files a push sends: the store's owned text — `DB.md`,
10723/// `assets.jsonl` when present, and every content `.md` under `records/` and
10724/// `sources/` (the store walk, which already excludes hidden dirs like
10725/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
10726/// derives its own index, and local history stays local). Returns
10727/// `(store-relative path, content)` pairs, path-sorted.
10728pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
10729    require_hardened_filesystem("sync push")?;
10730    preflight_push_ownership(store)?;
10731    let mut out: Vec<(String, String)> = Vec::new();
10732    let mut total = 0u64;
10733
10734    let mut read_text = |rel: &str| -> LinkResult<String> {
10735        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
10736        total = total
10737            .checked_add(bytes.len() as u64)
10738            .ok_or_else(|| LinkError::PushTooLarge {
10739                detail: "uncompressed byte count overflow".to_string(),
10740            })?;
10741        if total > MAX_STORE_BYTES {
10742            return Err(LinkError::PushTooLarge {
10743                detail: format!("{total} uncompressed bytes"),
10744            });
10745        }
10746        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
10747            path: rel.to_string(),
10748        })
10749    };
10750
10751    out.push(("DB.md".to_string(), read_text("DB.md")?));
10752    if store
10753        .regular_file_exists(Path::new("assets.jsonl"))
10754        .unwrap_or(false)
10755    {
10756        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
10757    }
10758
10759    for rel in store.walk()? {
10760        let rel_str = rel.to_string_lossy().replace('\\', "/");
10761        if !safe_store_rel_path(&rel_str) {
10762            // A locally-legal name outside the hub's portable charset cannot
10763            // travel this wire; refusing beats silently dropping it.
10764            return Err(LinkError::UnsafePath { path: rel_str });
10765        }
10766        let content = read_text(&rel_str)?;
10767        out.push((rel_str, content));
10768    }
10769
10770    out.sort_by(|a, b| a.0.cmp(&b.0));
10771    Ok(out)
10772}
10773
10774/// Refuse to build a destructive whole-store snapshot from an ambiguous local
10775/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
10776/// silently omitted them could delete the hosted copies of those paths.
10777fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
10778    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
10779        return Err(LinkError::from(std::io::Error::new(
10780            std::io::ErrorKind::PermissionDenied,
10781            format!("cannot push: nested db.md store at {}", nested.display()),
10782        )));
10783    }
10784
10785    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
10786        return Err(LinkError::from(std::io::Error::new(
10787            std::io::ErrorKind::PermissionDenied,
10788            format!(
10789                "cannot push: {} is a symlink outside the store ownership model",
10790                symlink.display()
10791            ),
10792        )));
10793    }
10794    Ok(())
10795}
10796
10797/// Push `files` to `brain` as a whole-store snapshot — the hub's push
10798/// semantics: the hosted copy becomes exactly this set (pull first if the
10799/// hosted side may have records the local copy lacks). Client-side caps
10800/// mirror the hub's JSON-path limits so an oversized push fails before the
10801/// upload.
10802pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
10803    require_safe_ref(brain)?;
10804    let remote = verified_remote_head(cfg, brain, false)?;
10805    if files.len() > MAX_PUSH_FILES {
10806        return Err(LinkError::PushTooLarge {
10807            detail: format!("{} files", files.len()),
10808        });
10809    }
10810    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
10811    if raw_total > MAX_STORE_BYTES {
10812        return Err(LinkError::PushTooLarge {
10813            detail: format!("{raw_total} uncompressed bytes"),
10814        });
10815    }
10816
10817    // Self-custody (a brain key is configured): the JSON fast path is
10818    // hub-signed by construction, so every push goes through the pack flow
10819    // with a locally signed entry — the hub verifies and can never sign.
10820    if cfg.brain_key.is_none() {
10821        let body = json!({
10822            "files": files
10823                .iter()
10824                .map(|(p, c)| json!({ "path": p, "content": c }))
10825                .collect::<Vec<_>>(),
10826        });
10827        if body.to_string().len() <= MAX_PUSH_BYTES {
10828            let path = format!("/api/hub/brains/{brain}/push");
10829            let pushed = ensure_ok(
10830                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10831                "sync push",
10832            )?;
10833            return Ok(pushed);
10834        }
10835    }
10836
10837    let pack = build_store_pack(files)?;
10838    if pack.len() as u64 > MAX_PACK_BYTES {
10839        return Err(LinkError::PushTooLarge {
10840            detail: format!("{} pack bytes", pack.len()),
10841        });
10842    }
10843    let sha256 = format!("{:x}", Sha256::digest(&pack));
10844    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
10845    if let Some(key) = &cfg.brain_key {
10846        if !remote.head.verified {
10847            return Err(invalid_feed(
10848                "self-custody push requires a fully verified, unscoped feed head",
10849            ));
10850        }
10851        let identity = remote
10852            .identity
10853            .as_ref()
10854            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
10855        let current_multikey = format!("ed25519:{}", identity.fingerprint);
10856        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
10857            return Err(invalid_feed(
10858                "configured brain key is not the verified current brain identity",
10859            ));
10860        }
10861        // The verified state pins seq + prev; a concurrent writer surfaces as
10862        // the hub's 422 on commit (re-run to retry against the new head).
10863        let next_seq = remote
10864            .head
10865            .seq
10866            .checked_add(1)
10867            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
10868        let mut manifest: Vec<WireFeedFile> = files
10869            .iter()
10870            .map(|(path, content)| WireFeedFile {
10871                path: path.clone(),
10872                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
10873                bytes: content.len() as u64,
10874            })
10875            .collect();
10876        manifest.sort_by(|a, b| a.path.cmp(&b.path));
10877        let ts = crate::now()
10878            .with_timezone(&chrono::Utc)
10879            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
10880            .to_string();
10881        let entry = self_custody_entry(
10882            key,
10883            next_seq,
10884            ts,
10885            &sha256,
10886            &manifest,
10887            remote.head.feed_hash.as_deref(),
10888        )?;
10889        meta["entry"] = Value::String(entry);
10890    }
10891    let presigned = ensure_ok(
10892        request(
10893            cfg,
10894            "POST",
10895            &format!("/api/hub/brains/{brain}/packs/presign"),
10896            Some(&meta),
10897            Auth::Required,
10898        )?,
10899        "prepare pack upload",
10900    )?;
10901    let url = presigned
10902        .get("url")
10903        .and_then(Value::as_str)
10904        .ok_or_else(|| LinkError::InvalidPack {
10905            message: "the hub returned no upload URL".to_string(),
10906        })?;
10907    put_presigned(
10908        cfg,
10909        url,
10910        presigned.get("headers").unwrap_or(&Value::Null),
10911        &pack,
10912    )?;
10913    let committed = ensure_ok(
10914        request(
10915            cfg,
10916            "POST",
10917            &format!("/api/hub/brains/{brain}/packs/commit"),
10918            Some(&meta),
10919            Auth::Required,
10920        )?,
10921        "commit pack",
10922    )?;
10923    Ok(committed)
10924}
10925
10926fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
10927    const LOCAL_HEADER: u32 = 0x0403_4b50;
10928    const CENTRAL_HEADER: u32 = 0x0201_4b50;
10929    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
10930    const VERSION_20: u16 = 20;
10931    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
10932    const UTF8_FLAG: u16 = 1 << 11;
10933    const STORED: u16 = 0;
10934    const DOS_TIME_MIDNIGHT: u16 = 0;
10935    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
10936    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
10937
10938    struct CentralEntry<'a> {
10939        name: &'a [u8],
10940        crc32: u32,
10941        size: u32,
10942        local_offset: u32,
10943    }
10944
10945    fn push_u16(out: &mut Vec<u8>, value: u16) {
10946        out.extend_from_slice(&value.to_le_bytes());
10947    }
10948
10949    fn push_u32(out: &mut Vec<u8>, value: u32) {
10950        out.extend_from_slice(&value.to_le_bytes());
10951    }
10952
10953    if files.is_empty() {
10954        return Err(LinkError::InvalidPack {
10955            message: "cannot create an empty snapshot pack".to_string(),
10956        });
10957    }
10958    if files.len() > u16::MAX as usize {
10959        return Err(LinkError::PushTooLarge {
10960            detail: format!(
10961                "{} files (canonical ZIP32 packs cap at {})",
10962                files.len(),
10963                u16::MAX
10964            ),
10965        });
10966    }
10967
10968    let mut sorted: Vec<_> = files.iter().collect();
10969    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
10970    let mut previous: Option<&str> = None;
10971    for (path, content) in &sorted {
10972        if !safe_store_rel_path(path) {
10973            return Err(LinkError::UnsafePath {
10974                path: (*path).clone(),
10975            });
10976        }
10977        if previous == Some(path.as_str()) {
10978            return Err(LinkError::InvalidPack {
10979                message: format!("duplicate path `{path}`"),
10980            });
10981        }
10982        previous = Some(path.as_str());
10983        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
10984            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
10985        })?;
10986    }
10987
10988    let mut out = Vec::new();
10989    let mut central = Vec::with_capacity(sorted.len());
10990    for (path, content) in sorted {
10991        let name = path.as_bytes();
10992        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
10993            message: format!("ZIP entry name is too long: `{path}`"),
10994        })?;
10995        let bytes = content.as_bytes();
10996        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
10997            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
10998        })?;
10999        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11000            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11001        })?;
11002        let crc32 = crc32fast::hash(bytes);
11003
11004        // Canonical local header: sizes and CRC are known up front. Bit 3 is
11005        // deliberately clear, so there is no trailing data descriptor.
11006        push_u32(&mut out, LOCAL_HEADER);
11007        push_u16(&mut out, VERSION_20);
11008        push_u16(&mut out, UTF8_FLAG);
11009        push_u16(&mut out, STORED);
11010        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11011        push_u16(&mut out, DOS_DATE_1980_01_01);
11012        push_u32(&mut out, crc32);
11013        push_u32(&mut out, size);
11014        push_u32(&mut out, size);
11015        push_u16(&mut out, name_len);
11016        push_u16(&mut out, 0); // no local extra data
11017        out.extend_from_slice(name);
11018        out.extend_from_slice(bytes);
11019
11020        central.push(CentralEntry {
11021            name,
11022            crc32,
11023            size,
11024            local_offset,
11025        });
11026    }
11027
11028    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11029        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11030    })?;
11031    for entry in &central {
11032        push_u32(&mut out, CENTRAL_HEADER);
11033        push_u16(&mut out, MADE_BY_UNIX_20);
11034        push_u16(&mut out, VERSION_20);
11035        push_u16(&mut out, UTF8_FLAG);
11036        push_u16(&mut out, STORED);
11037        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11038        push_u16(&mut out, DOS_DATE_1980_01_01);
11039        push_u32(&mut out, entry.crc32);
11040        push_u32(&mut out, entry.size);
11041        push_u32(&mut out, entry.size);
11042        push_u16(&mut out, entry.name.len() as u16);
11043        push_u16(&mut out, 0); // no central extra data
11044        push_u16(&mut out, 0); // no file comment
11045        push_u16(&mut out, 0); // disk number
11046        push_u16(&mut out, 0); // internal attributes
11047        push_u32(&mut out, UNIX_REGULAR_0600);
11048        push_u32(&mut out, entry.local_offset);
11049        out.extend_from_slice(entry.name);
11050    }
11051    let central_size = u32::try_from(out.len())
11052        .ok()
11053        .and_then(|end| end.checked_sub(central_offset))
11054        .ok_or_else(|| LinkError::PushTooLarge {
11055            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11056        })?;
11057    let entry_count = central.len() as u16;
11058
11059    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11060    push_u16(&mut out, 0); // this disk
11061    push_u16(&mut out, 0); // central directory disk
11062    push_u16(&mut out, entry_count);
11063    push_u16(&mut out, entry_count);
11064    push_u32(&mut out, central_size);
11065    push_u32(&mut out, central_offset);
11066    push_u16(&mut out, 0); // no archive comment
11067
11068    if out.len() > u32::MAX as usize {
11069        return Err(LinkError::PushTooLarge {
11070            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11071        });
11072    }
11073    Ok(out)
11074}
11075
11076// ─────────────────────────────────────────────────────────────────────────────
11077// grant — issue / list / revoke capabilities (owner-side)
11078// ─────────────────────────────────────────────────────────────────────────────
11079
11080/// The two capabilities a v0 hub enforces.
11081#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11082pub enum Capability {
11083    /// Read the granted slice.
11084    Read,
11085    /// Read and push (whole-store; a path-scoped grant is read-only).
11086    Write,
11087}
11088
11089impl Capability {
11090    /// The wire form.
11091    pub fn as_str(self) -> &'static str {
11092        match self {
11093            Capability::Read => "read",
11094            Capability::Write => "write",
11095        }
11096    }
11097}
11098
11099/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
11100/// by email in v0 (the protocol's near-term simplification; key-named
11101/// grantees arrive with the signing layer). `scope` is a store-path prefix
11102/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
11103/// revoked.
11104pub fn grant_issue(
11105    cfg: &HubConfig,
11106    brain: &str,
11107    grantee: &str,
11108    can: Capability,
11109    scope: Option<&str>,
11110    until: Option<&str>,
11111) -> LinkResult<Value> {
11112    require_safe_ref(brain)?;
11113    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
11114    // multikey holder (link.md §6 cross-party keys — no hub account; the
11115    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
11116    // hub principal named by email.
11117    let is_key_grantee = URL_SAFE_NO_PAD
11118        .decode(grantee)
11119        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11120        .unwrap_or(false);
11121    if let Some(head) = v2_verified_head(cfg, brain)? {
11122        if is_key_grantee {
11123            let scope = scope.unwrap_or("");
11124            let preset = match can {
11125                Capability::Read => "viewer",
11126                Capability::Write => "editor",
11127            };
11128            let entropy = format!(
11129                "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11130                normalized_origin(&cfg.hub)?,
11131                head.brain_id,
11132                head.control_revision,
11133                grantee,
11134                preset,
11135                scope,
11136                until.unwrap_or("")
11137            );
11138            let mut body = json!({
11139                "context": "external",
11140                "expected_control_revision": head.control_revision,
11141                "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11142                "preset": preset,
11143                "principal_kind": "key",
11144                "public_key": grantee,
11145                "scope": scope,
11146                "scope_kind": "prefix",
11147            });
11148            if let Some(value) = until {
11149                body["expires_at"] = json!(value);
11150            }
11151            let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11152            let response = ensure_ok(
11153                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11154                "v2 grant issue",
11155            )?;
11156            let expected_fingerprint = identity_fingerprint(grantee)?;
11157            if response.get("v").and_then(Value::as_u64) != Some(2)
11158                || response
11159                    .get("id")
11160                    .and_then(Value::as_str)
11161                    .is_none_or(|id| !crate::ulid::is_ulid(id))
11162                || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11163                || response.get("principal_id").and_then(Value::as_str)
11164                    != Some(expected_fingerprint.as_str())
11165                || response
11166                    .get("control_revision")
11167                    .and_then(Value::as_str)
11168                    .is_none_or(|value| !is_sha256(value))
11169            {
11170                return Err(invalid_feed(
11171                    "v2 grant issue response is not authority-bound",
11172                ));
11173            }
11174            return Ok(response);
11175        }
11176        // Account invitations remain a product workflow. The compatibility
11177        // endpoint resolves email/account identity, then commits a native v2
11178        // grant with the same control-plane recovery boundary. The v2 head
11179        // above is already the authenticated authority checkpoint; never
11180        // fall through to the retired v1 feed verifier for this request.
11181        let mut body = json!({ "email": grantee, "capability": can.as_str() });
11182        if let Some(value) = scope {
11183            body["scopePrefix"] = json!(value);
11184        }
11185        if let Some(value) = until {
11186            body["expiresAt"] = json!(value);
11187        }
11188        let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11189        return ensure_ok(
11190            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11191            "account grant issue",
11192        );
11193    }
11194    let _ = verified_remote_head(cfg, brain, false)?;
11195    let mut body = if is_key_grantee {
11196        json!({ "keySpki": grantee, "capability": can.as_str() })
11197    } else {
11198        json!({ "email": grantee, "capability": can.as_str() })
11199    };
11200    if let Some(s) = scope {
11201        body["scopePrefix"] = json!(s);
11202    }
11203    if let Some(u) = until {
11204        body["expiresAt"] = json!(u);
11205    }
11206    let path = format!("/api/hub/brains/{brain}/grants");
11207    ensure_ok(
11208        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11209        "grant issue",
11210    )
11211}
11212
11213/// List the active grants (and pending invites) on `brain`. Owner-side.
11214pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11215    require_safe_ref(brain)?;
11216    if let Some(head) = v2_verified_head(cfg, brain)? {
11217        let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11218        let response = ensure_ok(
11219            request(cfg, "GET", &path, None, Auth::Required)?,
11220            "v2 grant list",
11221        )?;
11222        if response.get("v").and_then(Value::as_u64) != Some(2)
11223            || response.get("control_revision").and_then(Value::as_str)
11224                != Some(head.control_revision.as_str())
11225            || !response.get("grants").is_some_and(Value::is_array)
11226        {
11227            return Err(invalid_feed(
11228                "v2 grant list is not bound to the verified authority",
11229            ));
11230        }
11231        return Ok(response);
11232    }
11233    let _ = verified_remote_head(cfg, brain, false)?;
11234    let path = format!("/api/hub/brains/{brain}/grants");
11235    ensure_ok(
11236        request(cfg, "GET", &path, None, Auth::Required)?,
11237        "grant list",
11238    )
11239}
11240
11241/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
11242/// is soft on the hub (the audit trail survives).
11243pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11244    require_safe_ref(brain)?;
11245    require_safe_grant_id(grant_id)?;
11246    if let Some(head) = v2_verified_head(cfg, brain)? {
11247        let entropy = format!(
11248            "{}\0{}\0{}\0{}",
11249            normalized_origin(&cfg.hub)?,
11250            head.brain_id,
11251            head.control_revision,
11252            grant_id
11253        );
11254        let body = json!({
11255            "expected_control_revision": head.control_revision,
11256            "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11257        });
11258        let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11259        let response = ensure_ok(
11260            request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11261            "v2 grant revoke",
11262        )?;
11263        if response.get("v").and_then(Value::as_u64) != Some(2)
11264            || response.get("id").and_then(Value::as_str) != Some(grant_id)
11265            || response.get("revoked").and_then(Value::as_bool) != Some(true)
11266            || response
11267                .get("control_revision")
11268                .and_then(Value::as_str)
11269                .is_none_or(|value| !is_sha256(value))
11270        {
11271            return Err(invalid_feed(
11272                "v2 grant revocation response is not authority-bound",
11273            ));
11274        }
11275        return Ok(response);
11276    }
11277    let _ = verified_remote_head(cfg, brain, false)?;
11278    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11279    ensure_ok(
11280        request(cfg, "DELETE", &path, None, Auth::Required)?,
11281        "grant revoke",
11282    )
11283}
11284
11285// ─────────────────────────────────────────────────────────────────────────────
11286// permissioned v2 proposals — inspect, accept, reject
11287// ─────────────────────────────────────────────────────────────────────────────
11288
11289#[derive(Debug)]
11290struct VerifiedV2Proposal {
11291    value: Value,
11292    changes: Value,
11293    blobs: Vec<(String, u64, String)>,
11294}
11295
11296fn require_proposal_id(id: &str) -> LinkResult<()> {
11297    if crate::ulid::is_ulid(id) {
11298        Ok(())
11299    } else {
11300        Err(invalid_feed("proposal id is not a lowercase ULID"))
11301    }
11302}
11303
11304fn verified_v2_proposal(
11305    cfg: &HubConfig,
11306    head: &V2VerifiedHead,
11307    proposal_id: &str,
11308) -> LinkResult<VerifiedV2Proposal> {
11309    require_proposal_id(proposal_id)?;
11310    if head.view_kind != "full" {
11311        return Err(invalid_feed(
11312            "proposal review requires a full readable view",
11313        ));
11314    }
11315    let path = format!(
11316        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11317        head.brain_id
11318    );
11319    let value = ensure_ok(
11320        request_capped(
11321            cfg,
11322            "GET",
11323            &path,
11324            None,
11325            Auth::Required,
11326            MAX_FEED_RESPONSE_BYTES,
11327        )?,
11328        "v2 proposal",
11329    )?;
11330    verify_v2_proposal_value(head, proposal_id, value)
11331}
11332
11333fn verify_v2_proposal_value(
11334    head: &V2VerifiedHead,
11335    proposal_id: &str,
11336    value: Value,
11337) -> LinkResult<VerifiedV2Proposal> {
11338    if value.get("v").and_then(Value::as_u64) != Some(2) {
11339        return Err(invalid_feed("proposal response has an invalid version"));
11340    }
11341    let proposal = value
11342        .get("proposal")
11343        .and_then(Value::as_object)
11344        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11345    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11346        return Err(invalid_feed("proposal response changed its id"));
11347    }
11348    let payload_hash = proposal
11349        .get("payload_sha256")
11350        .and_then(Value::as_str)
11351        .filter(|hash| is_sha256(hash))
11352        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11353    let clear_hash = proposal
11354        .get("clear_sha256")
11355        .and_then(Value::as_str)
11356        .filter(|hash| is_sha256(hash))
11357        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11358    let submission_hash = proposal
11359        .get("submission_claim_sha256")
11360        .and_then(Value::as_str)
11361        .filter(|hash| is_sha256(hash))
11362        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11363    let submission = STANDARD
11364        .decode(
11365            proposal
11366                .get("submission_claim_base64")
11367                .and_then(Value::as_str)
11368                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11369        )
11370        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11371    let submission_value: Value = serde_json::from_slice(&submission)
11372        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11373    if crate::linkmd_v2::canonical_bytes(&submission_value)
11374        .map_err(|error| invalid_feed(error.to_string()))?
11375        != submission
11376        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11377            .map_err(|error| invalid_feed(error.to_string()))?
11378            != submission_hash
11379    {
11380        return Err(invalid_feed(
11381            "proposal submission claim is not canonical or addressed",
11382        ));
11383    }
11384    let envelope = submission_value
11385        .as_object()
11386        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11387    let claim = envelope
11388        .get("claim")
11389        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11390    let claim_object = claim
11391        .as_object()
11392        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11393    let actor_root = claim_object
11394        .get("actor_root")
11395        .and_then(Value::as_object)
11396        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11397    let public_key = envelope
11398        .get("public_key")
11399        .and_then(Value::as_str)
11400        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11401    let fingerprint = envelope
11402        .get("fingerprint")
11403        .and_then(Value::as_str)
11404        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11405    let signature = envelope
11406        .get("sig")
11407        .and_then(Value::as_str)
11408        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11409    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11410        .map_err(|error| invalid_feed(error.to_string()))?;
11411    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11412    let signer = format!("{fingerprint}:{public_key}");
11413    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11414    let grants = actor_root.get("grants").and_then(Value::as_array);
11415    let grants_are_canonical = grants.is_some_and(|items| {
11416        let mut prior: Option<&str> = None;
11417        items.iter().all(|item| {
11418            let Some(grant) = item.as_str() else {
11419                return false;
11420            };
11421            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11422                return false;
11423            }
11424            prior = Some(grant);
11425            true
11426        })
11427    });
11428    let optional_actor_field = |name: &str| {
11429        actor_root.get(name).is_some_and(|value| {
11430            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11431        })
11432    };
11433    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11434    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11435        || format!("{:x}", Sha256::digest(&der)) != fingerprint
11436        || head
11437            .trust
11438            .hub_signer
11439            .as_ref()
11440            .is_some_and(|known| known != &signer)
11441        || !matches!(
11442            actor_class,
11443            Some(
11444                "user"
11445                    | "owned_agent"
11446                    | "foreign_key"
11447                    | "curation"
11448                    | "inbox"
11449                    | "restore"
11450                    | "migration"
11451                    | "operator_recovery"
11452            )
11453        )
11454        || actor_root
11455            .get("principal")
11456            .and_then(Value::as_str)
11457            .is_none_or(|value| value.is_empty())
11458        || actor_root
11459            .get("credential")
11460            .and_then(Value::as_str)
11461            .is_none_or(|value| value.is_empty())
11462        || !optional_actor_field("organization")
11463        || !optional_actor_field("role")
11464        || !grants_are_canonical
11465        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11466        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11467        || !claim_object
11468            .get("mutation_id")
11469            .and_then(Value::as_str)
11470            .is_some_and(|value| {
11471                !value.is_empty()
11472                    && value.len() <= 128
11473                    && value.chars().enumerate().all(|(index, char)| {
11474                        char.is_ascii_alphanumeric()
11475                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11476                    })
11477            })
11478        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11479        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11480        || !claim_object
11481            .get("control_revision")
11482            .and_then(Value::as_str)
11483            .is_some_and(is_sha256)
11484        || submitted_at.is_none_or(|value| {
11485            chrono::DateTime::parse_from_rfc3339(value).is_err()
11486                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11487        })
11488        || !proposal
11489            .get("state")
11490            .and_then(Value::as_str)
11491            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11492        || proposal
11493            .get("expires_at")
11494            .and_then(Value::as_str)
11495            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11496        || proposal
11497            .get("proposer")
11498            .and_then(Value::as_object)
11499            .and_then(|value| value.get("class"))
11500            .and_then(Value::as_str)
11501            != actor_class
11502    {
11503        return Err(invalid_feed(
11504            "proposal submission claim does not bind the verified proposal",
11505        ));
11506    }
11507    let changes_b64 = proposal
11508        .get("changes_base64")
11509        .and_then(Value::as_str)
11510        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11511    let changes_bytes = STANDARD
11512        .decode(changes_b64)
11513        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11514    let changes: Value = serde_json::from_slice(&changes_bytes)
11515        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11516    if crate::linkmd_v2::canonical_bytes(&changes)
11517        .map_err(|error| invalid_feed(error.to_string()))?
11518        != changes_bytes
11519        || changes.get("v").and_then(Value::as_u64) != Some(2)
11520        || !changes.get("operations").is_some_and(Value::is_array)
11521    {
11522        return Err(invalid_feed("proposal changeset is not canonical v2"));
11523    }
11524    let blob_values = proposal
11525        .get("blobs")
11526        .and_then(Value::as_array)
11527        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11528    let mut blobs = Vec::with_capacity(blob_values.len());
11529    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11530    let mut prior_hash: Option<String> = None;
11531    for item in blob_values {
11532        let hash = item
11533            .get("sha256")
11534            .and_then(Value::as_str)
11535            .filter(|hash| is_sha256(hash))
11536            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11537        let bytes = item
11538            .get("bytes")
11539            .and_then(Value::as_u64)
11540            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11541            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11542        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11543            return Err(invalid_feed(
11544                "proposal blob declarations are not unique and sorted",
11545            ));
11546        }
11547        prior_hash = Some(hash.to_string());
11548        let endpoint = item
11549            .get("endpoint")
11550            .and_then(Value::as_str)
11551            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11552        let expected_endpoint = format!(
11553            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11554            head.brain_id
11555        );
11556        if endpoint != expected_endpoint {
11557            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11558        }
11559        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11560        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11561    }
11562    let descriptor = json!({
11563        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11564        "blobs": descriptor_blobs,
11565        "changes_base64": changes_b64,
11566        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11567        "v": 2,
11568    });
11569    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11570        .map_err(|error| invalid_feed(error.to_string()))?;
11571    if content_sha256(&descriptor_bytes) != clear_hash {
11572        return Err(invalid_feed(
11573            "proposal clear payload differs from its signed submission claim",
11574        ));
11575    }
11576    Ok(VerifiedV2Proposal {
11577        value,
11578        changes,
11579        blobs,
11580    })
11581}
11582
11583pub fn proposal_list(
11584    cfg: &HubConfig,
11585    brain: &str,
11586    state: &str,
11587    after: Option<&str>,
11588    limit: usize,
11589) -> LinkResult<Value> {
11590    require_safe_ref(brain)?;
11591    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11592        return Err(invalid_feed("proposal state is invalid"));
11593    }
11594    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11595        return Err(invalid_feed("proposal cursor is invalid"));
11596    }
11597    let head = v2_verified_head(cfg, brain)?
11598        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11599    let path = format!(
11600        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11601        head.brain_id,
11602        limit.clamp(1, 100),
11603        after.map_or_else(String::new, |value| format!("&after={value}"))
11604    );
11605    ensure_ok(
11606        request_capped(
11607            cfg,
11608            "GET",
11609            &path,
11610            None,
11611            Auth::Required,
11612            MAX_FEED_RESPONSE_BYTES,
11613        )?,
11614        "v2 proposal list",
11615    )
11616}
11617
11618pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11619    require_safe_ref(brain)?;
11620    let head = v2_verified_head(cfg, brain)?
11621        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11622    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11623}
11624
11625pub fn proposal_reject(
11626    cfg: &HubConfig,
11627    brain: &str,
11628    proposal_id: &str,
11629    mutation_id: &str,
11630    reason: &str,
11631) -> LinkResult<Value> {
11632    require_safe_ref(brain)?;
11633    require_proposal_id(proposal_id)?;
11634    let head = v2_verified_head(cfg, brain)?
11635        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11636    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11637    let body = json!({
11638        "mutation_id": mutation_id,
11639        "control_revision": head.control_revision,
11640        "reason": reason,
11641    });
11642    let path = format!(
11643        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11644        head.brain_id
11645    );
11646    ensure_ok(
11647        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11648        "v2 proposal rejection",
11649    )
11650}
11651
11652pub fn proposal_accept_exact(
11653    cfg: &HubConfig,
11654    brain: &str,
11655    proposal_id: &str,
11656    mutation_id: &str,
11657    reason: &str,
11658) -> LinkResult<Value> {
11659    require_safe_ref(brain)?;
11660    require_proposal_id(proposal_id)?;
11661    let head = v2_verified_head(cfg, brain)?
11662        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11663    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11664    let operations = proposal
11665        .changes
11666        .get("operations")
11667        .and_then(Value::as_array)
11668        .cloned()
11669        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11670    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11671        return Err(invalid_feed("proposal operation count is invalid"));
11672    }
11673    let mut downloaded = std::collections::BTreeMap::new();
11674    for (hash, bytes, endpoint) in &proposal.blobs {
11675        let body = ensure_raw_ok(
11676            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11677            "v2 proposal blob",
11678        )?;
11679        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11680            return Err(invalid_feed("proposal blob does not match its declaration"));
11681        }
11682        downloaded.insert(hash.clone(), body);
11683    }
11684    let remote = files_for_v2_view(
11685        &head,
11686        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
11687    );
11688    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
11689    let mut expected_candidate = remote.clone();
11690    let mut expected_candidate_assets = remote_assets;
11691    for operation in &operations {
11692        let op = operation
11693            .get("op")
11694            .and_then(Value::as_str)
11695            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
11696        match op {
11697            "put" | "restore" => {
11698                let path = operation
11699                    .get("path")
11700                    .and_then(Value::as_str)
11701                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
11702                crate::linkmd_v2::normalize_path(path)
11703                    .map_err(|error| invalid_feed(error.to_string()))?;
11704                let hash = operation
11705                    .get("blob")
11706                    .and_then(Value::as_str)
11707                    .filter(|hash| is_sha256(hash))
11708                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
11709                let bytes = operation
11710                    .get("bytes")
11711                    .and_then(Value::as_u64)
11712                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
11713                expected_candidate.insert(
11714                    path.to_string(),
11715                    V2BaselineFile {
11716                        sha256: hash.to_string(),
11717                        bytes,
11718                        proof: None,
11719                    },
11720                );
11721            }
11722            "delete" | "withdraw_from_hosting" => {
11723                let path = operation
11724                    .get("path")
11725                    .and_then(Value::as_str)
11726                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
11727                crate::linkmd_v2::normalize_path(path)
11728                    .map_err(|error| invalid_feed(error.to_string()))?;
11729                expected_candidate.remove(path);
11730            }
11731            "rename" => {
11732                let from = operation
11733                    .get("from")
11734                    .and_then(Value::as_str)
11735                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
11736                let to = operation
11737                    .get("to")
11738                    .and_then(Value::as_str)
11739                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
11740                crate::linkmd_v2::normalize_path(from)
11741                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
11742                    .map_err(|error| invalid_feed(error.to_string()))?;
11743                let hash = operation
11744                    .get("blob")
11745                    .and_then(Value::as_str)
11746                    .filter(|hash| is_sha256(hash))
11747                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
11748                let bytes = operation
11749                    .get("bytes")
11750                    .and_then(Value::as_u64)
11751                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
11752                expected_candidate.remove(from);
11753                expected_candidate.insert(
11754                    to.to_string(),
11755                    V2BaselineFile {
11756                        sha256: hash.to_string(),
11757                        bytes,
11758                        proof: None,
11759                    },
11760                );
11761            }
11762            "asset_delete" => {
11763                let path = operation
11764                    .get("path")
11765                    .and_then(Value::as_str)
11766                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
11767                expected_candidate_assets.remove(path);
11768            }
11769            "asset_withdraw" => {
11770                let path = operation
11771                    .get("path")
11772                    .and_then(Value::as_str)
11773                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
11774                let asset = expected_candidate_assets
11775                    .get_mut(path)
11776                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
11777                asset.disposition = "withheld".to_string();
11778                asset.leaf_hash.clear();
11779            }
11780            "asset_put" | "asset_resume" => {
11781                let path = operation
11782                    .get("path")
11783                    .and_then(Value::as_str)
11784                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
11785                let asset = operation
11786                    .get("asset")
11787                    .and_then(Value::as_object)
11788                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
11789                let blob_sha256 = asset
11790                    .get("blob_sha256")
11791                    .and_then(Value::as_str)
11792                    .filter(|hash| is_sha256(hash))
11793                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
11794                let bytes = asset
11795                    .get("bytes")
11796                    .and_then(Value::as_u64)
11797                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
11798                let media_type = asset
11799                    .get("media_type")
11800                    .and_then(Value::as_str)
11801                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
11802                let wrappers = asset
11803                    .get("wrappers")
11804                    .and_then(Value::as_array)
11805                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
11806                    .iter()
11807                    .map(|wrapper| {
11808                        wrapper
11809                            .as_str()
11810                            .map(str::to_string)
11811                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
11812                    })
11813                    .collect::<LinkResult<Vec<_>>>()?;
11814                let required = asset
11815                    .get("required")
11816                    .and_then(Value::as_bool)
11817                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
11818                let disposition = asset
11819                    .get("disposition")
11820                    .and_then(Value::as_str)
11821                    .filter(|value| matches!(*value, "hosted" | "withheld"))
11822                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
11823                expected_candidate_assets.insert(
11824                    path.to_string(),
11825                    V2BaselineAsset {
11826                        blob_sha256: blob_sha256.to_string(),
11827                        bytes,
11828                        media_type: media_type.to_string(),
11829                        wrappers,
11830                        required,
11831                        disposition: disposition.to_string(),
11832                        leaf_hash: String::new(),
11833                    },
11834                );
11835            }
11836            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
11837        }
11838    }
11839    let base = head.pointer.as_ref().map(|pointer| {
11840        json!({
11841            "seq": pointer.seq,
11842            "commit_hash": pointer.commit_hash,
11843            "content_root": pointer.content_root,
11844            "asset_root": pointer.asset_root,
11845        })
11846    });
11847    let mut body = json!({
11848        "mutation_id": mutation_id,
11849        "base": base,
11850        "rebase": "strict",
11851        "reason": reason,
11852        "operations": operations,
11853        "blobs": downloaded
11854            .iter()
11855            .map(|(sha256, bytes)| json!({
11856                "sha256": sha256,
11857                "bytes": bytes.len(),
11858                "content_base64": STANDARD.encode(bytes),
11859            }))
11860            .collect::<Vec<_>>(),
11861        "proposal_id": proposal_id,
11862        "proposal_mode": "exact",
11863    });
11864    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
11865        total
11866            .checked_add(bytes.len())
11867            .ok_or_else(|| LinkError::PushTooLarge {
11868                detail: "proposal changed-byte total overflow".to_string(),
11869            })
11870    })?;
11871    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
11872        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11873        for operation in &operations {
11874            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
11875                return Err(invalid_feed("proposal upload operation has no kind"));
11876            };
11877            let hash = match kind {
11878                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
11879                "asset_put" | "asset_resume" => operation
11880                    .get("asset")
11881                    .and_then(|asset| asset.get("blob_sha256"))
11882                    .and_then(Value::as_str),
11883                _ => None,
11884            };
11885            let Some(hash) = hash else { continue };
11886            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
11887            if kind == "rename" {
11888                for field in ["from", "to"] {
11889                    coordinates.insert(
11890                        operation
11891                            .get(field)
11892                            .and_then(Value::as_str)
11893                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
11894                            .to_string(),
11895                    );
11896                }
11897            } else {
11898                let path = operation
11899                    .get("path")
11900                    .and_then(Value::as_str)
11901                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
11902                coordinates.insert(if kind.starts_with("asset_") {
11903                    format!("assets/{path}")
11904                } else {
11905                    path.to_string()
11906                });
11907            }
11908        }
11909        let declarations = downloaded
11910            .iter()
11911            .map(|(sha256, bytes)| {
11912                json!({
11913                    "sha256": sha256,
11914                    "bytes": bytes.len(),
11915                    "coordinates": coordinates_by_hash
11916                        .get(sha256)
11917                        .into_iter()
11918                        .flatten()
11919                        .collect::<Vec<_>>(),
11920                })
11921            })
11922            .collect::<Vec<_>>();
11923        let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
11924        for batch in batch_upload_declarations(declarations) {
11925            let reserved = reserve_upload_window(
11926                cfg,
11927                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
11928                &json!({ "blobs": batch }),
11929                "prepare proposal blob transport",
11930            )?;
11931            let reserved_items = reserved
11932                .get("uploads")
11933                .and_then(Value::as_array)
11934                .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
11935            items.extend(reserved_items.iter().cloned());
11936        }
11937        if items.len() != downloaded.len() {
11938            return Err(invalid_feed("proposal upload reservation changed the set"));
11939        }
11940        let mut references = Vec::with_capacity(items.len());
11941        for item in items {
11942            let hash = item
11943                .get("sha256")
11944                .and_then(Value::as_str)
11945                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
11946            let bytes = downloaded
11947                .get(hash)
11948                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
11949            let reservation_id = item
11950                .get("reservation_id")
11951                .and_then(Value::as_str)
11952                .filter(|id| crate::ulid::is_ulid(id))
11953                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
11954            let expected_coordinates = coordinates_by_hash
11955                .get(hash)
11956                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
11957            let returned_coordinates = item
11958                .get("coordinates")
11959                .and_then(Value::as_array)
11960                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
11961            if returned_coordinates.len() != expected_coordinates.len()
11962                || returned_coordinates
11963                    .iter()
11964                    .zip(expected_coordinates)
11965                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
11966            {
11967                return Err(invalid_feed(
11968                    "proposal upload reservation changed its coordinates",
11969                ));
11970            }
11971            match item.get("status").and_then(Value::as_str) {
11972                Some("upload") => put_presigned(
11973                    cfg,
11974                    item.get("url")
11975                        .and_then(Value::as_str)
11976                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
11977                    item.get("headers").unwrap_or(&Value::Null),
11978                    bytes,
11979                )?,
11980                Some("already_present") => {}
11981                _ => return Err(invalid_feed("proposal upload status is invalid")),
11982            }
11983            references.push(json!({
11984                "sha256": hash,
11985                "bytes": bytes.len(),
11986                "reservation_id": reservation_id,
11987            }));
11988        }
11989        body["blobs"] = Value::Array(references);
11990    }
11991    // A proposal can be queued through the staged lane, so acceptance has to be
11992    // able to restate it. Capping only this side would make a large proposal
11993    // permanently unacceptable.
11994    stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
11995    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
11996    let mut result = ensure_ok(
11997        request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
11998        "exact proposal acceptance",
11999    )?;
12000    let mut candidate_hub_signer = None;
12001    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12002        let request_id = result
12003            .get("request_id")
12004            .and_then(Value::as_str)
12005            .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12006            .to_string();
12007        let challenge = result
12008            .get("signing_challenge")
12009            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12010        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12011            cfg,
12012            &head,
12013            &expected_candidate,
12014            &expected_candidate_assets,
12015            mutation_id,
12016            &v2_signed_request_view(&body, &operations),
12017            challenge,
12018        )?;
12019        body["signing_challenge_id"] = Value::String(challenge_id);
12020        body["signature_base64url"] = Value::String(signature);
12021        candidate_hub_signer = Some(actor_signer);
12022        result = ensure_ok(
12023            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12024            "signed exact proposal acceptance",
12025        )?;
12026    }
12027    let refreshed = v2_verified_head(cfg, brain)?
12028        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12029    if candidate_hub_signer
12030        .as_ref()
12031        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12032        || refreshed
12033            .pointer
12034            .as_ref()
12035            .map(|pointer| pointer.commit_hash.as_str())
12036            != result.get("commit_hash").and_then(Value::as_str)
12037        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12038        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12039    {
12040        return Err(LinkError::RemoteAdvancedDuringSync);
12041    }
12042    accept_v2_head(cfg, &refreshed)?;
12043    Ok(result)
12044}
12045
12046// ─────────────────────────────────────────────────────────────────────────────
12047// propose — write without trust: evidence into the owner's inbox
12048// ─────────────────────────────────────────────────────────────────────────────
12049
12050/// Submit `body` to the published site `handle`, addressed to its app page
12051/// `app` (a page that declares the `write-inbox` capability). Deliberately
12052/// unauthenticated — this is the cross-party door; the submission lands as
12053/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
12054/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
12055/// receipt.
12056pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12057    require_valid_handle(handle)?;
12058    if body.len() as u64 > MAX_PROPOSE_BYTES {
12059        return Err(LinkError::ProposeTooLarge {
12060            bytes: body.len() as u64,
12061        });
12062    }
12063    let payload = json!({ "app": app, "body": body });
12064    // A ULID-shaped target is a bare brain address (link.md §7.4's
12065    // generalization): the brain inbox door, open on public brains, where a
12066    // configured credential earns a bigger actor-class budget. Anything else
12067    // is a published-site handle: that door is unauthenticated by design.
12068    let (path, auth) = if crate::ulid::is_ulid(handle) {
12069        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12070    } else {
12071        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12072    };
12073    ensure_ok(
12074        request(cfg, "POST", &path, Some(&payload), auth)?,
12075        "propose",
12076    )
12077}
12078
12079// ─────────────────────────────────────────────────────────────────────────────
12080// subscribe — follow feed-head movement
12081// ─────────────────────────────────────────────────────────────────────────────
12082
12083/// One observation of a brain's feed head.
12084#[derive(Debug, serde::Serialize)]
12085pub struct Head {
12086    /// The brain id.
12087    pub brain: String,
12088    /// The hub's durable feed cursor — advances on every accepted write.
12089    pub seq: u64,
12090    /// The hub's `updatedAt` for the brain, when present.
12091    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12092    pub updated_at: Option<String>,
12093    /// SHA-256 of the exact signed head entry.
12094    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12095    pub feed_hash: Option<String>,
12096    /// Whether the head entry's content hash, identity, and Ed25519 signature
12097    /// were verified locally. Path-scoped grants get head movement only.
12098    pub verified: bool,
12099}
12100
12101struct BoundedVecVisitor<T, const MAX: usize> {
12102    label: &'static str,
12103    marker: std::marker::PhantomData<T>,
12104}
12105
12106impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12107where
12108    T: Deserialize<'de>,
12109{
12110    type Value = Vec<T>;
12111
12112    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12113        write!(formatter, "at most {MAX} {}", self.label)
12114    }
12115
12116    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12117    where
12118        A: serde::de::SeqAccess<'de>,
12119    {
12120        if sequence.size_hint().is_some_and(|size| size > MAX) {
12121            return Err(serde::de::Error::custom(format!(
12122                "{} exceeds the {MAX}-item limit",
12123                self.label
12124            )));
12125        }
12126        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12127        while let Some(value) = sequence.next_element()? {
12128            if values.len() == MAX {
12129                return Err(serde::de::Error::custom(format!(
12130                    "{} exceeds the {MAX}-item limit",
12131                    self.label
12132                )));
12133            }
12134            values.push(value);
12135        }
12136        Ok(values)
12137    }
12138}
12139
12140fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12141    deserializer: D,
12142    label: &'static str,
12143) -> Result<Vec<T>, D::Error>
12144where
12145    D: serde::Deserializer<'de>,
12146    T: Deserialize<'de>,
12147{
12148    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12149        label,
12150        marker: std::marker::PhantomData,
12151    })
12152}
12153
12154fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12155where
12156    D: serde::Deserializer<'de>,
12157{
12158    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12159}
12160
12161fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12162where
12163    D: serde::Deserializer<'de>,
12164{
12165    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12166}
12167
12168fn deserialize_previous_identities<'de, D>(
12169    deserializer: D,
12170) -> Result<Vec<PreviousIdentity>, D::Error>
12171where
12172    D: serde::Deserializer<'de>,
12173{
12174    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12175        deserializer,
12176        "previous identities",
12177    )
12178}
12179
12180fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12181where
12182    D: serde::Deserializer<'de>,
12183{
12184    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12185        deserializer,
12186        "rotation statements",
12187    )
12188}
12189
12190fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12191where
12192    D: serde::Deserializer<'de>,
12193{
12194    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12195}
12196
12197#[derive(Debug, Clone, Deserialize, Serialize)]
12198struct FeedFile {
12199    path: String,
12200    sha256: String,
12201    bytes: u64,
12202}
12203
12204#[cfg(test)]
12205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12206enum V1DisclosureError {
12207    DuplicateFile,
12208    DuplicateRemoved,
12209    PushManifestMismatch,
12210    EditMissingChange,
12211    EditFalseFile,
12212    RemovedMismatch,
12213}
12214
12215/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
12216/// the exact previous and resulting pack manifests. `edit` accepts the
12217/// historical superset form while still requiring every actual changed path.
12218#[cfg(test)]
12219fn verify_v1_manifest_disclosure(
12220    kind: &str,
12221    previous: &[FeedFile],
12222    resulting: &[FeedFile],
12223    files: &[FeedFile],
12224    removed: &[String],
12225) -> Result<(), V1DisclosureError> {
12226    fn as_map(
12227        files: &[FeedFile],
12228    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12229        let mut result = std::collections::BTreeMap::new();
12230        for file in files {
12231            if result
12232                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12233                .is_some()
12234            {
12235                return Err(V1DisclosureError::DuplicateFile);
12236            }
12237        }
12238        Ok(result)
12239    }
12240    let previous = as_map(previous)?;
12241    let resulting = as_map(resulting)?;
12242    let disclosed = as_map(files)?;
12243    let removed_set: std::collections::BTreeSet<&str> =
12244        removed.iter().map(String::as_str).collect();
12245    if removed_set.len() != removed.len() {
12246        return Err(V1DisclosureError::DuplicateRemoved);
12247    }
12248    let expected_removed: std::collections::BTreeSet<&str> = previous
12249        .keys()
12250        .copied()
12251        .filter(|path| !resulting.contains_key(path))
12252        .collect();
12253    if removed_set != expected_removed {
12254        return Err(V1DisclosureError::RemovedMismatch);
12255    }
12256    if kind == "push" {
12257        return if disclosed == resulting {
12258            Ok(())
12259        } else {
12260            Err(V1DisclosureError::PushManifestMismatch)
12261        };
12262    }
12263    if kind != "edit" {
12264        return Err(V1DisclosureError::EditFalseFile);
12265    }
12266    if disclosed
12267        .iter()
12268        .any(|(path, value)| resulting.get(path) != Some(value))
12269    {
12270        return Err(V1DisclosureError::EditFalseFile);
12271    }
12272    for (path, value) in &resulting {
12273        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12274            return Err(V1DisclosureError::EditMissingChange);
12275        }
12276    }
12277    Ok(())
12278}
12279
12280#[derive(Debug, Clone, Deserialize, Serialize)]
12281struct FeedEntry {
12282    v: u8,
12283    seq: u64,
12284    ts: String,
12285    brain: String,
12286    public_key: String,
12287    kind: String,
12288    op: String,
12289    pack_sha256: String,
12290    #[serde(deserialize_with = "deserialize_feed_files")]
12291    files: Vec<FeedFile>,
12292    #[serde(deserialize_with = "deserialize_removed_paths")]
12293    removed: Vec<String>,
12294    prev_entry_hash: Option<String>,
12295    sig: String,
12296}
12297
12298#[derive(Serialize)]
12299struct UnsignedFeedEntry<'a> {
12300    v: u8,
12301    seq: u64,
12302    ts: &'a str,
12303    brain: &'a str,
12304    public_key: &'a str,
12305    kind: &'a str,
12306    op: &'a str,
12307    pack_sha256: &'a str,
12308    files: &'a [FeedFile],
12309    removed: &'a [String],
12310    prev_entry_hash: &'a Option<String>,
12311}
12312
12313#[derive(Debug, Clone, Deserialize, Serialize)]
12314struct FeedItem {
12315    hash: String,
12316    entry: FeedEntry,
12317}
12318
12319#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12320struct FeedIdentity {
12321    fingerprint: String,
12322    #[serde(rename = "publicKeySpki")]
12323    public_key_spki: String,
12324    /// Rotation history (link.md §9.1): identities this brain previously
12325    /// signed as. Entries verify against current OR previous — rotation
12326    /// never invalidates history.
12327    #[serde(default, deserialize_with = "deserialize_previous_identities")]
12328    previous: Vec<PreviousIdentity>,
12329    /// Exact normative rotation statements, oldest first. A list of previous
12330    /// public keys without these old-key signatures is not a trust chain.
12331    #[serde(default, deserialize_with = "deserialize_rotations")]
12332    rotations: Vec<String>,
12333}
12334
12335#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12336struct PreviousIdentity {
12337    fingerprint: String,
12338    #[serde(rename = "publicKeySpki")]
12339    public_key_spki: String,
12340}
12341
12342#[derive(Debug, Deserialize)]
12343struct FeedResponse {
12344    #[serde(rename = "headSeq")]
12345    head_seq: u64,
12346    #[serde(rename = "feedHash")]
12347    feed_hash: Option<String>,
12348    identity: Option<FeedIdentity>,
12349    #[serde(deserialize_with = "deserialize_feed_items")]
12350    entries: Vec<FeedItem>,
12351    #[serde(rename = "scopeLimited")]
12352    scope_limited: bool,
12353}
12354
12355#[derive(Debug, Deserialize, Serialize)]
12356#[serde(deny_unknown_fields)]
12357struct RotationStatement {
12358    v: u8,
12359    op: String,
12360    brain: String,
12361    public_key: String,
12362    new_brain: String,
12363    new_public_key: String,
12364    prior_head_seq: u64,
12365    prior_feed_hash: Option<String>,
12366    ts: String,
12367    sig: String,
12368}
12369
12370#[derive(Debug, Clone, Deserialize, Serialize)]
12371struct TrustState {
12372    v: u8,
12373    origin: String,
12374    /// The exact caller-visible ref whose resolution this checkpoint pins.
12375    /// Slugs/handles are mutable names at the hub; once observed, they may not
12376    /// silently resolve to a different canonical brain.
12377    #[serde(default)]
12378    requested: String,
12379    /// Canonical hub brain id returned for `requested`.
12380    brain: String,
12381    /// Federation home origin when this ref was learned from a registry.
12382    /// Once observed, the registry cannot silently relocate the same handle.
12383    #[serde(default, skip_serializing_if = "Option::is_none")]
12384    home: Option<String>,
12385    anchor: String,
12386    current: String,
12387    #[serde(rename = "headSeq")]
12388    head_seq: u64,
12389    #[serde(rename = "feedHash")]
12390    feed_hash: Option<String>,
12391    /// Exact accepted old-key-signed rotation statements, oldest first. v2
12392    /// checkpoints require this vector to be an immutable prefix of every
12393    /// subsequently served identity chain.
12394    #[serde(default)]
12395    rotations: Vec<String>,
12396    /// Hub pointer-signing identity pinned on first v2 observation. This is
12397    /// separate from the brain key, which signs commits.
12398    #[serde(default, skip_serializing_if = "Option::is_none")]
12399    hub_signer: Option<String>,
12400    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
12401    /// are recognized by their pinned hub signer during one-way migration.
12402    #[serde(default, skip_serializing_if = "Option::is_none")]
12403    protocol_profile: Option<String>,
12404}
12405
12406fn accepted_as_v2(state: &TrustState) -> bool {
12407    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12408}
12409
12410fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12411    let directory = open_trust_dir(cfg)?;
12412    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12413        return Ok(true);
12414    }
12415    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12416        return Ok(false);
12417    };
12418    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12419}
12420
12421#[derive(Debug, Clone, Deserialize, Serialize)]
12422struct AliasBinding {
12423    v: u8,
12424    origin: String,
12425    requested: String,
12426    brain: String,
12427    #[serde(default, skip_serializing_if = "Option::is_none")]
12428    home: Option<String>,
12429}
12430
12431struct VerifiedRemote {
12432    head: Head,
12433    identity: Option<FeedIdentity>,
12434    head_entry: Option<FeedItem>,
12435    /// Present when the caller requested and verified the complete chain.
12436    entries: Vec<FeedItem>,
12437    anchor: Option<String>,
12438}
12439
12440fn invalid_feed(message: impl Into<String>) -> LinkError {
12441    LinkError::InvalidFeed {
12442        message: message.into(),
12443    }
12444}
12445
12446fn is_sha256(value: &str) -> bool {
12447    value.len() == 64
12448        && value
12449            .bytes()
12450            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12451}
12452
12453fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12454    let der = URL_SAFE_NO_PAD
12455        .decode(public_key_spki)
12456        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12457    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12458        return Err(invalid_feed(
12459            "identity public key is not a valid Ed25519 SPKI",
12460        ));
12461    }
12462    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12463}
12464
12465/// Verify the hub's current identity and every old-key-signed rotation that
12466/// leads from the original TOFU anchor to it. `previous` alone is metadata,
12467/// never authority.
12468fn verify_identity_chain(
12469    identity: &FeedIdentity,
12470    pinned: Option<&TrustState>,
12471) -> LinkResult<String> {
12472    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12473        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12474    {
12475        return Err(invalid_feed(
12476            "identity rotation history exceeds the client cap",
12477        ));
12478    }
12479    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12480        return Err(invalid_feed(
12481            "current identity fingerprint does not match its public key",
12482        ));
12483    }
12484    for previous in &identity.previous {
12485        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12486            return Err(invalid_feed(
12487                "previous identity fingerprint does not match its public key",
12488            ));
12489        }
12490    }
12491    if identity.rotations.len() != identity.previous.len() {
12492        return Err(invalid_feed(
12493            "identity history is missing an old-key-signed rotation statement",
12494        ));
12495    }
12496
12497    // The HTTP identity lists prior identities newest first. Rotation
12498    // statements are chronological, so verification walks the reversed list
12499    // and ends at the current identity.
12500    let mut chain: Vec<(&str, &str)> = identity
12501        .previous
12502        .iter()
12503        .rev()
12504        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12505        .collect();
12506    chain.push((&identity.fingerprint, &identity.public_key_spki));
12507
12508    for (index, raw) in identity.rotations.iter().enumerate() {
12509        let statement: RotationStatement = serde_json::from_str(raw)
12510            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12511        let (old_fingerprint, old_spki) = chain[index];
12512        let (new_fingerprint, new_spki) = chain[index + 1];
12513        if statement.v != 1
12514            || statement.op != "rotate"
12515            || statement.brain != format!("ed25519:{old_fingerprint}")
12516            || statement.public_key != old_spki
12517            || statement.new_brain != format!("ed25519:{new_fingerprint}")
12518            || statement.new_public_key != new_spki
12519            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12520            || (statement.prior_head_seq > 0
12521                && statement
12522                    .prior_feed_hash
12523                    .as_deref()
12524                    .is_none_or(|hash| !is_sha256(hash)))
12525        {
12526            return Err(invalid_feed(
12527                "rotation statement does not connect adjacent identities",
12528            ));
12529        }
12530        let unsigned = serde_json::to_string(&UnsignedRotation {
12531            v: statement.v,
12532            op: &statement.op,
12533            brain: &statement.brain,
12534            public_key: &statement.public_key,
12535            new_brain: &statement.new_brain,
12536            new_public_key: &statement.new_public_key,
12537            prior_head_seq: statement.prior_head_seq,
12538            prior_feed_hash: statement.prior_feed_hash.as_deref(),
12539            ts: statement.ts.clone(),
12540        })
12541        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12542        let exact = format!(
12543            "{},\"sig\":\"{}\"}}",
12544            &unsigned[..unsigned.len() - 1],
12545            statement.sig
12546        );
12547        if exact != *raw {
12548            return Err(invalid_feed(
12549                "rotation statement is not in normative serialization",
12550            ));
12551        }
12552        let der = URL_SAFE_NO_PAD
12553            .decode(old_spki)
12554            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12555        let signature = URL_SAFE_NO_PAD
12556            .decode(&statement.sig)
12557            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12558        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12559            .verify(unsigned.as_bytes(), &signature)
12560            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12561        if index > 0 {
12562            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12563                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12564            if statement.prior_head_seq < prior.prior_head_seq {
12565                return Err(invalid_feed("rotation feed boundaries move backward"));
12566            }
12567        }
12568    }
12569
12570    let anchor = format!("ed25519:{}", chain[0].0);
12571    let current = format!("ed25519:{}", identity.fingerprint);
12572    if let Some(pin) = pinned {
12573        if pin.anchor != anchor {
12574            return Err(invalid_feed(
12575                "served identity chain does not descend from the pinned anchor",
12576            ));
12577        }
12578        if !chain
12579            .iter()
12580            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12581        {
12582            return Err(invalid_feed(
12583                "served identity chain forked away from the last pinned identity",
12584            ));
12585        }
12586        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12587            return Err(invalid_feed("served identity discarded its rotation chain"));
12588        }
12589        if pin.v >= 2
12590            && (identity.rotations.len() < pin.rotations.len()
12591                || identity.rotations[..pin.rotations.len()] != pin.rotations)
12592        {
12593            return Err(invalid_feed(
12594                "served identity rewrote the locally accepted rotation history",
12595            ));
12596        }
12597    }
12598    Ok(anchor)
12599}
12600
12601fn verify_rotation_feed_boundaries(
12602    identity: &FeedIdentity,
12603    pinned: Option<&TrustState>,
12604    observed: &[FeedItem],
12605    advertised_seq: u64,
12606) -> LinkResult<()> {
12607    let mut chain: Vec<String> = identity
12608        .previous
12609        .iter()
12610        .rev()
12611        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12612        .collect();
12613    chain.push(format!("ed25519:{}", identity.fingerprint));
12614    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12615
12616    for (index, raw) in identity.rotations.iter().enumerate() {
12617        let rotation: RotationStatement = serde_json::from_str(raw)
12618            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12619        if rotation.prior_head_seq > advertised_seq {
12620            return Err(invalid_feed(
12621                "rotation claims a feed boundary beyond the advertised head",
12622            ));
12623        }
12624        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12625            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12626                return Err(invalid_feed(
12627                    "newly disclosed rotation predates the local feed checkpoint",
12628                ));
12629            }
12630        }
12631        let actual = if rotation.prior_head_seq == 0 {
12632            None
12633        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12634            pinned.and_then(|pin| pin.feed_hash.as_deref())
12635        } else {
12636            observed
12637                .iter()
12638                .find(|item| item.entry.seq == rotation.prior_head_seq)
12639                .map(|item| item.hash.as_str())
12640        };
12641        if let Some(actual) = actual {
12642            if rotation.prior_feed_hash.as_deref() != Some(actual) {
12643                return Err(invalid_feed(
12644                    "rotation statement does not commit the verified feed boundary",
12645                ));
12646            }
12647        } else if rotation.prior_head_seq == 0 {
12648            // The empty-feed boundary is represented by (0, null); there is
12649            // no entry hash to find in `observed`.
12650        } else if pinned.is_some_and(|pin| {
12651            pinned_index.is_some_and(|pin_index| index >= pin_index)
12652                || rotation.prior_head_seq >= pin.head_seq
12653        }) {
12654            return Err(invalid_feed(
12655                "rotation feed boundary was not present in the verified chain",
12656            ));
12657        }
12658    }
12659    Ok(())
12660}
12661
12662/// Once a client has checkpointed identity K, identities retired before K may
12663/// verify old history but can never regain authority over a later sequence.
12664/// This is also the safe migration rule for legacy v1 checkpoints that did not
12665/// persist the exact accepted rotation statements.
12666fn reject_retired_signer_after_checkpoint(
12667    identity: &FeedIdentity,
12668    pinned: Option<&TrustState>,
12669    item: &FeedItem,
12670) -> LinkResult<()> {
12671    let Some(pin) = pinned else {
12672        return Ok(());
12673    };
12674    if item.entry.seq <= pin.head_seq {
12675        return Ok(());
12676    }
12677    let mut chain: Vec<String> = identity
12678        .previous
12679        .iter()
12680        .rev()
12681        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12682        .collect();
12683    chain.push(format!("ed25519:{}", identity.fingerprint));
12684    let pinned_index = chain
12685        .iter()
12686        .position(|key| key == &pin.current)
12687        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
12688    let signer_index = chain
12689        .iter()
12690        .position(|key| key == &item.entry.brain)
12691        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
12692    if signer_index < pinned_index {
12693        return Err(invalid_feed(
12694            "a retired identity attempted to sign after the local checkpoint",
12695        ));
12696    }
12697    Ok(())
12698}
12699
12700fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
12701    let origin = normalized_origin(&cfg.hub)?;
12702    let key = format!(
12703        "{:x}",
12704        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
12705    );
12706    Ok(format!("{key}.json"))
12707}
12708
12709fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
12710    let origin = normalized_origin(&cfg.hub)?;
12711    let key = format!(
12712        "{:x}",
12713        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
12714    );
12715    Ok(format!("alias-{key}.json"))
12716}
12717
12718#[cfg(any(unix, windows))]
12719struct TrustLock {
12720    _file: std::fs::File,
12721}
12722
12723#[cfg(unix)]
12724fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12725    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12726
12727    let lock_string = format!(".{state_name}.lock");
12728    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
12729    let fd = unsafe {
12730        libc::openat(
12731            directory.as_raw_fd(),
12732            lock_name.as_ptr(),
12733            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12734            0o600,
12735        )
12736    };
12737    if fd < 0 {
12738        return Err(std::io::Error::last_os_error().into());
12739    }
12740    let file = unsafe { std::fs::File::from_raw_fd(fd) };
12741    if !file.metadata()?.is_file() {
12742        return Err(LinkError::UnsafePath { path: lock_string });
12743    }
12744    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
12745        return Err(std::io::Error::last_os_error().into());
12746    }
12747    Ok(TrustLock { _file: file })
12748}
12749
12750#[cfg(windows)]
12751fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12752    let lock_name = format!(".{state_name}.lock");
12753    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
12754    Ok(TrustLock { _file: file })
12755}
12756
12757#[cfg(any(unix, windows))]
12758fn lock_trust_many(
12759    cfg: &HubConfig,
12760    directory: &std::fs::File,
12761    refs: &[&str],
12762) -> LinkResult<Vec<TrustLock>> {
12763    let mut names = refs
12764        .iter()
12765        .map(|reference| trust_file_name(cfg, reference))
12766        .collect::<LinkResult<Vec<_>>>()?;
12767    names.sort();
12768    names.dedup();
12769    names
12770        .iter()
12771        .map(|name| lock_trust_name(directory, name))
12772        .collect()
12773}
12774
12775#[cfg(not(any(unix, windows)))]
12776fn lock_trust_many(
12777    _cfg: &HubConfig,
12778    _directory: &TrustDirectory,
12779    _refs: &[&str],
12780) -> LinkResult<Vec<()>> {
12781    Err(LinkError::UnsupportedPlatform {
12782        operation: "verified link.md state",
12783    })
12784}
12785
12786#[cfg(any(unix, windows))]
12787type TrustDirectory = std::fs::File;
12788
12789#[cfg(not(any(unix, windows)))]
12790struct TrustDirectory;
12791
12792#[cfg(unix)]
12793fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12794    use std::os::fd::AsRawFd as _;
12795
12796    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
12797    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
12798        return Err(std::io::Error::last_os_error().into());
12799    }
12800    directory.sync_all()?;
12801    Ok(directory)
12802}
12803
12804#[cfg(windows)]
12805fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12806    let marker = cfg.state_dir.join("trust").join(".directory");
12807    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
12808    Ok(crate::fsx::open_directory_nofollow(
12809        marker.parent().expect("trust marker has a parent"),
12810    )?)
12811}
12812
12813#[cfg(not(any(unix, windows)))]
12814fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12815    Err(LinkError::UnsupportedPlatform {
12816        operation: "verified link.md state",
12817    })
12818}
12819
12820#[cfg(unix)]
12821fn load_trust_in(
12822    cfg: &HubConfig,
12823    directory: &TrustDirectory,
12824    requested: &str,
12825) -> LinkResult<Option<TrustState>> {
12826    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12827
12828    let name_string = trust_file_name(cfg, requested)?;
12829    let name = c_name(name_string.as_bytes(), &name_string)?;
12830    let fd = unsafe {
12831        libc::openat(
12832            directory.as_raw_fd(),
12833            name.as_ptr(),
12834            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12835        )
12836    };
12837    if fd < 0 {
12838        let error = std::io::Error::last_os_error();
12839        if error.kind() == std::io::ErrorKind::NotFound {
12840            return Ok(None);
12841        }
12842        return Err(LinkError::UnsafePath { path: name_string });
12843    }
12844    let file = unsafe { std::fs::File::from_raw_fd(fd) };
12845    if !file.metadata()?.is_file() {
12846        return Err(LinkError::UnsafePath { path: name_string });
12847    }
12848    let mut bytes = Vec::new();
12849    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
12850    if bytes.len() > 1024 * 1024 {
12851        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
12852    }
12853    let mut state: TrustState = serde_json::from_slice(&bytes)
12854        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12855    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12856        return Err(invalid_feed(
12857            "local identity/feed checkpoint does not match this hub and brain",
12858        ));
12859    }
12860    if state.v == 1 {
12861        // Legacy files were keyed by canonical brain id. They can migrate only
12862        // when the caller used that exact id; old slug lookups had no durable
12863        // alias binding and therefore cannot be guessed safely.
12864        if state.brain != requested {
12865            return Err(invalid_feed(
12866                "legacy checkpoint is not bound to the requested brain id",
12867            ));
12868        }
12869        state.requested = requested.to_string();
12870    } else if state.requested != requested {
12871        return Err(invalid_feed(
12872            "local identity/feed checkpoint is bound to a different requested ref",
12873        ));
12874    }
12875    Ok(Some(state))
12876}
12877
12878#[cfg(windows)]
12879fn load_trust_in(
12880    cfg: &HubConfig,
12881    directory: &TrustDirectory,
12882    requested: &str,
12883) -> LinkResult<Option<TrustState>> {
12884    let name = trust_file_name(cfg, requested)?;
12885    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
12886    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
12887        Ok(bytes) => bytes,
12888        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
12889        Err(_) => return Err(LinkError::UnsafePath { path: name }),
12890    };
12891    let mut state: TrustState = serde_json::from_slice(&bytes)
12892        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12893    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12894        return Err(invalid_feed(
12895            "local identity/feed checkpoint does not match this hub and brain",
12896        ));
12897    }
12898    if state.v == 1 {
12899        if state.brain != requested {
12900            return Err(invalid_feed(
12901                "legacy checkpoint is not bound to the requested brain id",
12902            ));
12903        }
12904        state.requested = requested.to_string();
12905    } else if state.requested != requested {
12906        return Err(invalid_feed(
12907            "local identity/feed checkpoint is bound to a different requested ref",
12908        ));
12909    }
12910    Ok(Some(state))
12911}
12912
12913#[cfg(not(any(unix, windows)))]
12914fn load_trust_in(
12915    _cfg: &HubConfig,
12916    _directory: &TrustDirectory,
12917    _brain: &str,
12918) -> LinkResult<Option<TrustState>> {
12919    Err(LinkError::UnsupportedPlatform {
12920        operation: "verified link.md state",
12921    })
12922}
12923
12924#[cfg(all(test, any(unix, windows)))]
12925fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
12926    let directory = open_trust_dir(cfg)?;
12927    load_trust_in(cfg, &directory, requested)
12928}
12929
12930#[cfg(unix)]
12931fn save_trust_in(
12932    cfg: &HubConfig,
12933    directory: &TrustDirectory,
12934    state: &TrustState,
12935) -> LinkResult<()> {
12936    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12937
12938    let name_string = trust_file_name(cfg, &state.requested)?;
12939    let name = c_name(name_string.as_bytes(), &name_string)?;
12940    let mut bytes = serde_json::to_vec(state)
12941        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12942    bytes.push(b'\n');
12943
12944    let nonce = std::time::SystemTime::now()
12945        .duration_since(std::time::UNIX_EPOCH)
12946        .unwrap_or_default()
12947        .as_nanos();
12948    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
12949    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
12950    let fd = unsafe {
12951        libc::openat(
12952            directory.as_raw_fd(),
12953            temp.as_ptr(),
12954            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12955            0o600,
12956        )
12957    };
12958    if fd < 0 {
12959        return Err(std::io::Error::last_os_error().into());
12960    }
12961    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
12962    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
12963        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12964        return Err(error.into());
12965    }
12966    drop(file);
12967    if unsafe {
12968        libc::renameat(
12969            directory.as_raw_fd(),
12970            temp.as_ptr(),
12971            directory.as_raw_fd(),
12972            name.as_ptr(),
12973        )
12974    } != 0
12975    {
12976        let error = std::io::Error::last_os_error();
12977        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12978        return Err(error.into());
12979    }
12980    directory.sync_all()?;
12981    Ok(())
12982}
12983
12984#[cfg(windows)]
12985fn save_trust_in(
12986    cfg: &HubConfig,
12987    directory: &TrustDirectory,
12988    state: &TrustState,
12989) -> LinkResult<()> {
12990    let name = trust_file_name(cfg, &state.requested)?;
12991    let mut bytes = serde_json::to_vec(state)
12992        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12993    bytes.push(b'\n');
12994    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
12995    Ok(())
12996}
12997
12998#[cfg(not(any(unix, windows)))]
12999fn save_trust_in(
13000    _cfg: &HubConfig,
13001    _directory: &TrustDirectory,
13002    _state: &TrustState,
13003) -> LinkResult<()> {
13004    Err(LinkError::UnsupportedPlatform {
13005        operation: "verified link.md state",
13006    })
13007}
13008
13009#[cfg(unix)]
13010fn load_alias_in(
13011    cfg: &HubConfig,
13012    directory: &TrustDirectory,
13013    requested: &str,
13014) -> LinkResult<Option<AliasBinding>> {
13015    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13016
13017    let name_string = alias_file_name(cfg, requested)?;
13018    let name = c_name(name_string.as_bytes(), &name_string)?;
13019    let fd = unsafe {
13020        libc::openat(
13021            directory.as_raw_fd(),
13022            name.as_ptr(),
13023            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13024        )
13025    };
13026    if fd < 0 {
13027        let error = std::io::Error::last_os_error();
13028        if error.kind() == std::io::ErrorKind::NotFound {
13029            return Ok(None);
13030        }
13031        return Err(LinkError::UnsafePath { path: name_string });
13032    }
13033    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13034    if !file.metadata()?.is_file() {
13035        return Err(LinkError::UnsafePath { path: name_string });
13036    }
13037    let mut bytes = Vec::new();
13038    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13039    if bytes.len() > 64 * 1024 {
13040        return Err(invalid_feed("local alias binding is oversized"));
13041    }
13042    let alias: AliasBinding = serde_json::from_slice(&bytes)
13043        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13044    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13045    {
13046        return Err(invalid_feed(
13047            "local alias binding does not match this hub and requested ref",
13048        ));
13049    }
13050    Ok(Some(alias))
13051}
13052
13053#[cfg(windows)]
13054fn load_alias_in(
13055    cfg: &HubConfig,
13056    directory: &TrustDirectory,
13057    requested: &str,
13058) -> LinkResult<Option<AliasBinding>> {
13059    let name = alias_file_name(cfg, requested)?;
13060    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13061    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13062        Ok(bytes) => bytes,
13063        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13064        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13065    };
13066    let alias: AliasBinding = serde_json::from_slice(&bytes)
13067        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13068    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13069    {
13070        return Err(invalid_feed(
13071            "local alias binding does not match this hub and requested ref",
13072        ));
13073    }
13074    Ok(Some(alias))
13075}
13076
13077#[cfg(not(any(unix, windows)))]
13078fn load_alias_in(
13079    _cfg: &HubConfig,
13080    _directory: &TrustDirectory,
13081    _requested: &str,
13082) -> LinkResult<Option<AliasBinding>> {
13083    Err(LinkError::UnsupportedPlatform {
13084        operation: "verified link.md state",
13085    })
13086}
13087
13088#[cfg(unix)]
13089fn save_alias_in(
13090    cfg: &HubConfig,
13091    directory: &TrustDirectory,
13092    alias: &AliasBinding,
13093) -> LinkResult<()> {
13094    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13095
13096    let name_string = alias_file_name(cfg, &alias.requested)?;
13097    let name = c_name(name_string.as_bytes(), &name_string)?;
13098    let mut bytes = serde_json::to_vec(alias)
13099        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13100    bytes.push(b'\n');
13101    let nonce = std::time::SystemTime::now()
13102        .duration_since(std::time::UNIX_EPOCH)
13103        .unwrap_or_default()
13104        .as_nanos();
13105    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13106    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13107    let fd = unsafe {
13108        libc::openat(
13109            directory.as_raw_fd(),
13110            temp.as_ptr(),
13111            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13112            0o600,
13113        )
13114    };
13115    if fd < 0 {
13116        return Err(std::io::Error::last_os_error().into());
13117    }
13118    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13119    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13120        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13121        return Err(error.into());
13122    }
13123    drop(file);
13124    if unsafe {
13125        libc::renameat(
13126            directory.as_raw_fd(),
13127            temp.as_ptr(),
13128            directory.as_raw_fd(),
13129            name.as_ptr(),
13130        )
13131    } != 0
13132    {
13133        let error = std::io::Error::last_os_error();
13134        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13135        return Err(error.into());
13136    }
13137    directory.sync_all()?;
13138    Ok(())
13139}
13140
13141#[cfg(windows)]
13142fn save_alias_in(
13143    cfg: &HubConfig,
13144    directory: &TrustDirectory,
13145    alias: &AliasBinding,
13146) -> LinkResult<()> {
13147    let name = alias_file_name(cfg, &alias.requested)?;
13148    let mut bytes = serde_json::to_vec(alias)
13149        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13150    bytes.push(b'\n');
13151    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13152    Ok(())
13153}
13154
13155#[cfg(not(any(unix, windows)))]
13156fn save_alias_in(
13157    _cfg: &HubConfig,
13158    _directory: &TrustDirectory,
13159    _alias: &AliasBinding,
13160) -> LinkResult<()> {
13161    Err(LinkError::UnsupportedPlatform {
13162        operation: "verified link.md state",
13163    })
13164}
13165
13166/// Load the canonical checkpoint shared by every spelling of one brain and
13167/// the separate alias binding. This also performs the one-way migration from
13168/// pre-v3 checkpoints that stored a full trust state under the alias itself.
13169/// Callers hold both alias and canonical locks before entering.
13170fn load_canonical_pin(
13171    cfg: &HubConfig,
13172    directory: &TrustDirectory,
13173    requested: &str,
13174    resolved_brain: &str,
13175) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13176    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13177    if requested == resolved_brain {
13178        return Ok((canonical, None));
13179    }
13180
13181    let mut alias = load_alias_in(cfg, directory, requested)?;
13182    if let Some(binding) = &alias {
13183        if binding.brain != resolved_brain {
13184            return Err(LinkError::AliasRebindRequired {
13185                alias: requested.to_string(),
13186                from: binding.brain.clone(),
13187                to: resolved_brain.to_string(),
13188            });
13189        }
13190        return Ok((canonical, alias));
13191    }
13192
13193    // A v2 build stored the complete checkpoint under the requested slug.
13194    // Promote it to the canonical ULID key before creating the lightweight
13195    // alias binding. Never silently merge two independently advanced states.
13196    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13197        if legacy.brain != resolved_brain {
13198            return Err(invalid_feed(
13199                "legacy alias checkpoint names a different canonical brain",
13200            ));
13201        }
13202        if let Some(existing) = &canonical {
13203            if existing.brain != legacy.brain
13204                || existing.anchor != legacy.anchor
13205                || existing.current != legacy.current
13206                || existing.head_seq != legacy.head_seq
13207                || existing.feed_hash != legacy.feed_hash
13208                || existing.rotations != legacy.rotations
13209            {
13210                return Err(invalid_feed(
13211                    "legacy alias checkpoint conflicts with the canonical checkpoint",
13212                ));
13213            }
13214        } else {
13215            let mut promoted = legacy.clone();
13216            promoted.requested = resolved_brain.to_string();
13217            promoted.home = None;
13218            save_trust_in(cfg, directory, &promoted)?;
13219            canonical = Some(promoted);
13220        }
13221        alias = Some(AliasBinding {
13222            v: 1,
13223            origin: normalized_origin(&cfg.hub)?,
13224            requested: requested.to_string(),
13225            brain: resolved_brain.to_string(),
13226            home: legacy.home,
13227        });
13228        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13229    }
13230    Ok((canonical, alias))
13231}
13232
13233/// Explicitly replace one mutable alias binding after the operator reviews the
13234/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
13235/// or rewritten, so old history remains pinned and the new brain establishes
13236/// its own independently verified trust chain.
13237pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13238    require_hardened_filesystem("verified alias rebind")?;
13239    require_safe_ref(alias)?;
13240    require_safe_ref(from)?;
13241    require_safe_ref(to)?;
13242    if crate::ulid::is_ulid(alias)
13243        || !crate::ulid::is_ulid(from)
13244        || !crate::ulid::is_ulid(to)
13245        || from == to
13246    {
13247        return Err(LinkError::InvalidPack {
13248            message:
13249                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13250                    .to_string(),
13251        });
13252    }
13253
13254    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13255        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13256    })?;
13257    accept_v2_head(cfg, &verified)?;
13258
13259    let alias_response = ensure_ok(
13260        request(
13261            cfg,
13262            "GET",
13263            &format!("/api/hub/brains/{alias}/v2/head"),
13264            None,
13265            Auth::Required,
13266        )?,
13267        "resolve alias for explicit rebind",
13268    )?;
13269    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13270        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13271    if resolved.v != 2 || resolved.brain_id != to {
13272        return Err(LinkError::RemoteAdvancedDuringSync);
13273    }
13274
13275    let directory = open_trust_dir(cfg)?;
13276    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13277    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13278        message: "the requested alias has no existing local binding to replace".to_string(),
13279    })?;
13280    if binding.brain != from {
13281        return Err(LinkError::AliasRebindRequired {
13282            alias: alias.to_string(),
13283            from: binding.brain,
13284            to: to.to_string(),
13285        });
13286    }
13287    save_alias_in(
13288        cfg,
13289        &directory,
13290        &AliasBinding {
13291            v: 1,
13292            origin: normalized_origin(&cfg.hub)?,
13293            requested: alias.to_string(),
13294            brain: to.to_string(),
13295            home: binding.home,
13296        },
13297    )?;
13298    Ok(json!({
13299        "v": 2,
13300        "alias": alias,
13301        "from": from,
13302        "to": to,
13303        "outcome": "alias_rebound",
13304    }))
13305}
13306
13307fn save_canonical_pin_and_alias(
13308    cfg: &HubConfig,
13309    directory: &TrustDirectory,
13310    requested: &str,
13311    resolved_brain: &str,
13312    mut state: TrustState,
13313    existing_alias: Option<&AliasBinding>,
13314) -> LinkResult<()> {
13315    state.requested = resolved_brain.to_string();
13316    state.brain = resolved_brain.to_string();
13317    state.home = None;
13318    save_trust_in(cfg, directory, &state)?;
13319    if requested != resolved_brain {
13320        save_alias_in(
13321            cfg,
13322            directory,
13323            &AliasBinding {
13324                v: 1,
13325                origin: normalized_origin(&cfg.hub)?,
13326                requested: requested.to_string(),
13327                brain: resolved_brain.to_string(),
13328                home: existing_alias.and_then(|alias| alias.home.clone()),
13329            },
13330        )?;
13331    }
13332    Ok(())
13333}
13334
13335fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13336    const ED25519_SPKI_PREFIX: &[u8] = &[
13337        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13338    ];
13339    let entry = &item.entry;
13340    let public_der = URL_SAFE_NO_PAD
13341        .decode(&entry.public_key)
13342        .map_err(|_| invalid_feed("public key is not base64url"))?;
13343    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13344        || !public_der.starts_with(ED25519_SPKI_PREFIX)
13345    {
13346        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13347    }
13348    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13349    if entry.brain != format!("ed25519:{fingerprint}") {
13350        return Err(invalid_feed(
13351            "brain fingerprint does not match its public key",
13352        ));
13353    }
13354    // Verify the complete rotation authority before using any previous key.
13355    let _ = verify_identity_chain(identity, None)?;
13356    let mut chain: Vec<(&str, &str)> = identity
13357        .previous
13358        .iter()
13359        .rev()
13360        .map(|previous| {
13361            (
13362                previous.fingerprint.as_str(),
13363                previous.public_key_spki.as_str(),
13364            )
13365        })
13366        .collect();
13367    chain.push((&identity.fingerprint, &identity.public_key_spki));
13368    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13369        *known_fingerprint == fingerprint && *spki == entry.public_key
13370    });
13371    let Some(signer_index) = signer_index else {
13372        return Err(invalid_feed(
13373            "entry signer is not this brain's identity (current or rotated-from)",
13374        ));
13375    };
13376    let lower_boundary = if signer_index == 0 {
13377        None
13378    } else {
13379        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13380            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13381        Some(prior.prior_head_seq)
13382    };
13383    let upper_boundary = if signer_index == identity.rotations.len() {
13384        None
13385    } else {
13386        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13387            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13388        Some(next.prior_head_seq)
13389    };
13390    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13391        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13392    {
13393        return Err(invalid_feed(
13394            "entry signer is outside its authenticated rotation epoch",
13395        ));
13396    }
13397    let unsigned = UnsignedFeedEntry {
13398        v: entry.v,
13399        seq: entry.seq,
13400        ts: &entry.ts,
13401        brain: &entry.brain,
13402        public_key: &entry.public_key,
13403        kind: &entry.kind,
13404        op: &entry.op,
13405        pack_sha256: &entry.pack_sha256,
13406        files: &entry.files,
13407        removed: &entry.removed,
13408        prev_entry_hash: &entry.prev_entry_hash,
13409    };
13410    let message =
13411        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13412    let signature = URL_SAFE_NO_PAD
13413        .decode(&entry.sig)
13414        .map_err(|_| invalid_feed("signature is not base64url"))?;
13415    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13416        .verify(&message, &signature)
13417        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13418
13419    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13420    exact.push(b'\n');
13421    let actual_hash = format!("{:x}", Sha256::digest(&exact));
13422    if actual_hash != item.hash {
13423        return Err(invalid_feed("entry SHA-256 does not match"));
13424    }
13425    Ok(())
13426}
13427
13428// ─────────────────────────────────────────────────────────────────────────────
13429// key rotation — link.md §9.1: the new key, signed by the old one
13430// ─────────────────────────────────────────────────────────────────────────────
13431
13432/// The unsigned rotation statement in its normative field order.
13433#[derive(Serialize)]
13434struct UnsignedRotation<'a> {
13435    v: u8,
13436    op: &'a str,
13437    brain: &'a str,
13438    public_key: &'a str,
13439    new_brain: &'a str,
13440    new_public_key: &'a str,
13441    prior_head_seq: u64,
13442    prior_feed_hash: Option<&'a str>,
13443    ts: String,
13444}
13445
13446/// Durable intent for an in-flight key rotation. The hub's recovery contract
13447/// identifies an ambiguous retry by the exact statement bytes, so the
13448/// statement cannot be reconstructed from the key and feed boundary later:
13449/// its timestamp and signature would differ.
13450#[derive(Debug, Deserialize, Serialize)]
13451#[serde(deny_unknown_fields)]
13452struct RotationJournal {
13453    v: u8,
13454    origin: String,
13455    brain: String,
13456    old_brain: String,
13457    new_brain: String,
13458    prior_head_seq: u64,
13459    prior_feed_hash: Option<String>,
13460    statement: String,
13461}
13462
13463fn rotation_journal_path(key_path: &Path) -> PathBuf {
13464    let mut path = key_path.as_os_str().to_os_string();
13465    path.push(".rotation.json");
13466    PathBuf::from(path)
13467}
13468
13469fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13470    #[cfg(unix)]
13471    let file = {
13472        use std::os::fd::{AsRawFd as _, FromRawFd as _};
13473        use std::os::unix::ffi::OsStrExt as _;
13474        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13475            .map_err(|error| {
13476                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13477            })?;
13478        let leaf_name = path
13479            .file_name()
13480            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13481        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13482        let fd = unsafe {
13483            libc::openat(
13484                parent.as_raw_fd(),
13485                leaf.as_ptr(),
13486                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13487            )
13488        };
13489        if fd < 0 {
13490            return Err(bad_agent_key(
13491                "the rotation journal must be an existing regular file without symlink ancestors",
13492            ));
13493        }
13494        unsafe { std::fs::File::from_raw_fd(fd) }
13495    };
13496    #[cfg(not(unix))]
13497    let file = std::fs::File::open(path)
13498        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13499    let metadata = file
13500        .metadata()
13501        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13502    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13503        return Err(bad_agent_key(
13504            "the rotation journal must be a bounded regular file",
13505        ));
13506    }
13507    #[cfg(unix)]
13508    {
13509        use std::os::unix::fs::PermissionsExt as _;
13510        if metadata.permissions().mode() & 0o077 != 0 {
13511            return Err(bad_agent_key(
13512                "the rotation journal is accessible to group/other; set mode 0600",
13513            ));
13514        }
13515    }
13516    serde_json::from_reader(file)
13517        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13518}
13519
13520fn remove_rotation_journal(path: &Path) {
13521    #[cfg(unix)]
13522    {
13523        use std::os::fd::AsRawFd as _;
13524        use std::os::unix::ffi::OsStrExt as _;
13525        let Ok(parent) =
13526            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13527        else {
13528            return;
13529        };
13530        let Some(leaf_name) = path.file_name() else {
13531            return;
13532        };
13533        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13534            return;
13535        };
13536        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13537            let _ = parent.sync_all();
13538        }
13539    }
13540    #[cfg(not(unix))]
13541    {
13542        let _ = std::fs::remove_file(path);
13543    }
13544}
13545
13546fn validate_rotation_journal(
13547    journal: &RotationJournal,
13548    cfg: &HubConfig,
13549    canonical_brain: &str,
13550    old_key: &AgentSigningKey,
13551    new_key: &AgentSigningKey,
13552    head: &Head,
13553) -> LinkResult<()> {
13554    if journal.v != 1
13555        || journal.origin != normalized_origin(&cfg.hub)?
13556        || journal.brain != canonical_brain
13557        || journal.old_brain != old_key.multikey
13558        || journal.new_brain != new_key.multikey
13559        || journal.prior_head_seq != head.seq
13560        || journal.prior_feed_hash != head.feed_hash
13561    {
13562        return Err(invalid_feed(
13563            "rotation journal does not match the verified key and feed boundary",
13564        ));
13565    }
13566    let statement: RotationStatement = serde_json::from_str(&journal.statement)
13567        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13568    if statement.prior_head_seq != journal.prior_head_seq
13569        || statement.prior_feed_hash != journal.prior_feed_hash
13570        || statement.brain != old_key.multikey
13571        || statement.public_key != old_key.public_key_spki
13572        || statement.new_brain != new_key.multikey
13573        || statement.new_public_key != new_key.public_key_spki
13574    {
13575        return Err(invalid_feed(
13576            "rotation journal statement does not match its durable intent",
13577        ));
13578    }
13579    let identity = FeedIdentity {
13580        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13581        public_key_spki: new_key.public_key_spki.clone(),
13582        previous: vec![PreviousIdentity {
13583            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13584            public_key_spki: old_key.public_key_spki.clone(),
13585        }],
13586        rotations: vec![journal.statement.clone()],
13587    };
13588    verify_identity_chain(&identity, None)?;
13589    Ok(())
13590}
13591
13592/// What `dbmd key rotate` returns.
13593#[derive(Debug, Serialize)]
13594pub struct RotationReport {
13595    /// The brain id rotated.
13596    pub brain: String,
13597    /// The NEW identity the hub now serves.
13598    pub multikey: String,
13599    /// Where the new PKCS#8 secret landed (0600).
13600    #[serde(rename = "keyFile")]
13601    pub key_file: String,
13602    /// Prior identities (newest first) the feed still verifies against.
13603    pub previous: Vec<String>,
13604}
13605
13606/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
13607/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
13608/// OLD key in normative serialization — and send it to the hub. The new secret
13609/// is durably created at 0600 before the POST; an existing output is reused for
13610/// idempotent retry/reconciliation. The old key is left untouched.
13611pub fn rotate_brain_key(
13612    cfg: &HubConfig,
13613    brain: &str,
13614    old_key: &AgentSigningKey,
13615    out: &Path,
13616) -> LinkResult<RotationReport> {
13617    require_hardened_filesystem("key rotation")?;
13618    require_safe_ref(brain)?;
13619    // The new private key must be durable *before* the hub can accept its
13620    // public half. An existing file is the retry/reconciliation path after an
13621    // ambiguous network failure: reuse it, never generate another identity.
13622    let new_key = if out.exists() {
13623        load_signing_key(out)?
13624    } else {
13625        let rng = ring::rand::SystemRandom::new();
13626        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13627            .map_err(|_| bad_agent_key("key generation failed"))?;
13628        let pair = agent_keypair(pkcs8.as_ref())?;
13629        let (public_key_spki, multikey) = public_identity_for(&pair);
13630        write_secret_new(
13631            out,
13632            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13633        )?;
13634        AgentSigningKey {
13635            pkcs8: pkcs8.as_ref().to_vec(),
13636            multikey,
13637            public_key_spki,
13638        }
13639    };
13640    let new_spki = new_key.public_key_spki.clone();
13641    let new_multikey = new_key.multikey.clone();
13642    let journal_path = rotation_journal_path(out);
13643    let before_v2 = v2_verified_head(cfg, brain)?;
13644    let (canonical_brain, served_identity, observed_head, v2_profile) =
13645        if let Some(head) = before_v2 {
13646            let observed = Head {
13647                brain: head.brain_id.clone(),
13648                seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13649                updated_at: head
13650                    .pointer
13651                    .as_ref()
13652                    .map(|pointer| pointer.signed_at.clone()),
13653                feed_hash: head
13654                    .pointer
13655                    .as_ref()
13656                    .map(|pointer| pointer.feed_hash.clone()),
13657                verified: true,
13658            };
13659            let identity = v2_identity(&head.identity);
13660            let canonical = head.brain_id.clone();
13661            accept_v2_head(cfg, &head)?;
13662            (canonical, identity, observed, true)
13663        } else {
13664            let remote = verified_remote_head(cfg, brain, false)?;
13665            let identity = remote
13666                .identity
13667                .clone()
13668                .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13669            (remote.head.brain.clone(), identity, remote.head, false)
13670        };
13671    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13672    let already_rotated = served_multikey == new_multikey;
13673    // A completed invocation removes the journal only after the server has
13674    // acknowledged its recovery boundary. If the verified identity advanced
13675    // but the journal remains, the prior response was ambiguous: replay the
13676    // exact signed statement so a v2 hub can repair/confirm its checkpoint.
13677    if already_rotated && !journal_path.exists() {
13678        remove_rotation_journal(&journal_path);
13679        return Ok(RotationReport {
13680            brain: brain.to_string(),
13681            multikey: new_multikey,
13682            key_file: out.display().to_string(),
13683            previous: served_identity
13684                .previous
13685                .iter()
13686                .map(|identity| format!("ed25519:{}", identity.fingerprint))
13687                .collect(),
13688        });
13689    }
13690    if !already_rotated && served_multikey != old_key.multikey {
13691        return Err(invalid_feed(
13692            "the supplied old key is not the brain's verified current identity",
13693        ));
13694    }
13695
13696    let journal = if journal_path.exists() {
13697        read_rotation_journal(&journal_path)?
13698    } else {
13699        let ts = crate::now()
13700            .with_timezone(&chrono::Utc)
13701            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
13702            .to_string();
13703        let unsigned = serde_json::to_string(&UnsignedRotation {
13704            v: 1,
13705            op: "rotate",
13706            brain: &old_key.multikey,
13707            public_key: &old_key.public_key_spki,
13708            new_brain: &new_multikey,
13709            new_public_key: &new_spki,
13710            prior_head_seq: observed_head.seq,
13711            prior_feed_hash: observed_head.feed_hash.as_deref(),
13712            ts,
13713        })
13714        .expect("serialize rotation");
13715        let old_pair = agent_keypair(&old_key.pkcs8)?;
13716        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13717        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
13718        let journal = RotationJournal {
13719            v: 1,
13720            origin: normalized_origin(&cfg.hub)?,
13721            brain: canonical_brain.clone(),
13722            old_brain: old_key.multikey.clone(),
13723            new_brain: new_multikey.clone(),
13724            prior_head_seq: observed_head.seq,
13725            prior_feed_hash: observed_head.feed_hash.clone(),
13726            statement,
13727        };
13728        let mut exact = serde_json::to_vec(&journal)
13729            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
13730        exact.push(b'\n');
13731        if write_secret_new(&journal_path, &exact).is_err() {
13732            // A concurrent retry may have won the O_EXCL race. Only an exact,
13733            // fully validated journal is allowed to recover that race.
13734            read_rotation_journal(&journal_path)?
13735        } else {
13736            journal
13737        }
13738    };
13739    validate_rotation_journal(
13740        &journal,
13741        cfg,
13742        &canonical_brain,
13743        old_key,
13744        &new_key,
13745        &observed_head,
13746    )?;
13747
13748    let body = json!({ "statement": journal.statement });
13749    let path = format!("/api/hub/brains/{brain}/rotate");
13750    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
13751    let attempted_failure = match attempted {
13752        Ok(response) if (200..300).contains(&response.status) => None,
13753        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
13754        Err(error) => Some(error),
13755    };
13756
13757    // A 2xx body is not authority, and a failed response may have followed a
13758    // committed mutation. In both cases success means the normal verifier sees
13759    // an append-only rotation chain ending at the durable new key.
13760    let identity = if v2_profile {
13761        match v2_verified_head(cfg, brain) {
13762            Ok(Some(after)) => {
13763                let identity = v2_identity(&after.identity);
13764                accept_v2_head(cfg, &after)?;
13765                identity
13766            }
13767            Ok(None) => {
13768                return Err(attempted_failure.unwrap_or_else(|| {
13769                    invalid_feed("rotated v2 brain no longer serves a v2 head")
13770                }));
13771            }
13772            Err(error) => return Err(attempted_failure.unwrap_or(error)),
13773        }
13774    } else {
13775        match verified_remote_head(cfg, brain, false) {
13776            Ok(after) => after
13777                .identity
13778                .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
13779            Err(error) => return Err(attempted_failure.unwrap_or(error)),
13780        }
13781    };
13782    if format!("ed25519:{}", identity.fingerprint) != new_multikey
13783        || identity.public_key_spki != new_spki
13784    {
13785        return Err(attempted_failure.unwrap_or_else(|| {
13786            invalid_feed("hub acknowledged rotation without committing the verified new identity")
13787        }));
13788    }
13789    if v2_profile {
13790        if let Some(error) = attempted_failure {
13791            // Seeing the new public identity is insufficient: v2 rotation is
13792            // successful only after the endpoint has forced and acknowledged
13793            // the recovery checkpoint that contains it. Keep the journal for
13794            // an exact retry after any non-2xx or transport ambiguity.
13795            return Err(error);
13796        }
13797    }
13798    let previous = identity
13799        .previous
13800        .iter()
13801        .map(|prior| format!("ed25519:{}", prior.fingerprint))
13802        .collect();
13803    remove_rotation_journal(&journal_path);
13804
13805    Ok(RotationReport {
13806        brain: brain.to_string(),
13807        multikey: new_multikey,
13808        key_file: out.display().to_string(),
13809        previous,
13810    })
13811}
13812
13813// ─────────────────────────────────────────────────────────────────────────────
13814// mirror — verified replication: the whole feed + files, re-servable
13815// ─────────────────────────────────────────────────────────────────────────────
13816
13817/// What `dbmd mirror` materialized.
13818#[derive(Debug, Serialize)]
13819pub struct MirrorReport {
13820    /// The brain id.
13821    pub brain: String,
13822    /// The mirrored feed head.
13823    #[serde(rename = "headSeq")]
13824    pub head_seq: u64,
13825    /// The head entry hash (the feed's advertised converged state).
13826    #[serde(rename = "feedHash")]
13827    pub feed_hash: Option<String>,
13828    /// Signed feed entries verified and stored.
13829    pub entries: u64,
13830    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
13831    pub pinned: String,
13832    /// Store files materialized by the pull.
13833    pub files: usize,
13834}
13835
13836/// The mirror state directory, relative to the mirror root.
13837pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
13838
13839/// Fully re-verified mirror material suitable for a read-only re-server.
13840#[derive(Debug)]
13841pub struct VerifiedMirrorMaterial {
13842    pub brain: String,
13843    pub head_seq: u64,
13844    pub feed_hash: Option<String>,
13845    pub identity: serde_json::Value,
13846    /// Sequence, exact normative entry JSON without newline, and entry hash.
13847    pub entries: Vec<(u64, String, String)>,
13848    pub pack_sha256: Option<String>,
13849}
13850
13851#[derive(Deserialize)]
13852#[serde(deny_unknown_fields)]
13853struct StoredMirrorHead {
13854    brain: String,
13855    #[serde(rename = "headSeq")]
13856    head_seq: u64,
13857    #[serde(rename = "feedHash")]
13858    feed_hash: Option<String>,
13859}
13860
13861/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
13862/// exact snapshot pack before `dbmd serve` exposes any bytes.
13863pub fn verify_mirror_material(
13864    head_bytes: &[u8],
13865    identity_bytes: &[u8],
13866    feed_bytes: &[Vec<u8>],
13867    snapshot_pack: Option<&[u8]>,
13868    expected_anchor: &str,
13869) -> LinkResult<VerifiedMirrorMaterial> {
13870    let snapshot_hash = snapshot_pack
13871        .filter(|pack| !pack.is_empty())
13872        .map(content_sha256);
13873    verify_mirror_material_with_pack_hash(
13874        head_bytes,
13875        identity_bytes,
13876        feed_bytes,
13877        snapshot_hash.as_deref(),
13878        expected_anchor,
13879    )
13880}
13881
13882/// Re-verify mirror metadata against a snapshot digest computed from a held
13883/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
13884/// large pack without ever buffering the pack in process memory.
13885pub fn verify_mirror_material_with_pack_hash(
13886    head_bytes: &[u8],
13887    identity_bytes: &[u8],
13888    feed_bytes: &[Vec<u8>],
13889    snapshot_pack_sha256: Option<&str>,
13890    expected_anchor: &str,
13891) -> LinkResult<VerifiedMirrorMaterial> {
13892    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
13893        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
13894    require_safe_ref(&head.brain)?;
13895    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
13896        return Err(invalid_feed(
13897            "stored mirror feed count does not match its bounded head sequence",
13898        ));
13899    }
13900    let aggregate = feed_bytes
13901        .iter()
13902        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
13903        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
13904    if aggregate > MAX_FEED_REPLAY_BYTES {
13905        return Err(invalid_feed(
13906            "stored mirror feed metadata exceeds the aggregate limit",
13907        ));
13908    }
13909    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
13910        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
13911    let anchor = verify_identity_chain(&identity, None)?;
13912    if anchor != expected_anchor {
13913        return Err(invalid_feed(
13914            "stored mirror identity does not descend from the explicitly trusted anchor",
13915        ));
13916    }
13917
13918    let mut entries = Vec::with_capacity(feed_bytes.len());
13919    let mut items = Vec::with_capacity(feed_bytes.len());
13920    let mut previous_hash = None;
13921    let mut pack_sha256 = None;
13922    for (index, bytes) in feed_bytes.iter().enumerate() {
13923        let exact = bytes
13924            .strip_suffix(b"\n")
13925            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
13926        if exact.ends_with(b"\n") {
13927            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
13928        }
13929        let entry: FeedEntry = serde_json::from_slice(exact)
13930            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
13931        let expected_seq = index as u64 + 1;
13932        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
13933            return Err(invalid_feed(
13934                "stored mirror feed is not contiguous and hash-chained",
13935            ));
13936        }
13937        let canonical = serde_json::to_vec(&entry)
13938            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
13939        if canonical != exact {
13940            return Err(invalid_feed(
13941                "stored feed entry is not in normative serialization",
13942            ));
13943        }
13944        let hash = content_sha256(bytes);
13945        let item = FeedItem {
13946            hash: hash.clone(),
13947            entry,
13948        };
13949        verify_feed_item(&item, &identity)?;
13950        previous_hash = Some(hash.clone());
13951        if expected_seq == head.head_seq {
13952            pack_sha256 = Some(item.entry.pack_sha256.clone());
13953        }
13954        entries.push((
13955            expected_seq,
13956            std::str::from_utf8(exact)
13957                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
13958                .to_string(),
13959            hash,
13960        ));
13961        items.push(item);
13962    }
13963    if previous_hash != head.feed_hash {
13964        return Err(invalid_feed(
13965            "stored mirror feed does not converge on its advertised head",
13966        ));
13967    }
13968    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
13969    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
13970        (0, None, None) => {}
13971        (_, Some(actual), Some(expected)) if actual == expected => {}
13972        _ => {
13973            return Err(LinkError::InvalidPack {
13974                message: "stored snapshot pack does not match the signed head digest".to_string(),
13975            });
13976        }
13977    }
13978    let identity_value = serde_json::to_value(&identity)
13979        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
13980    Ok(VerifiedMirrorMaterial {
13981        brain: head.brain,
13982        head_seq: head.head_seq,
13983        feed_hash: head.feed_hash,
13984        identity: identity_value,
13985        entries,
13986        pack_sha256,
13987    })
13988}
13989
13990/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
13991/// entry hash every consumer recomputes (SPEC §5.3).
13992pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
13993    format!(
13994        "{:x}",
13995        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
13996    )
13997}
13998
13999/// SHA-256 hex for content a signed manifest names. Exposed for the thin
14000/// `dbmd serve` adapter; cryptographic verification remains centralized here.
14001pub fn content_sha256(bytes: &[u8]) -> String {
14002    format!("{:x}", Sha256::digest(bytes))
14003}
14004
14005/// SHA-256 a stream with a fixed-size working buffer.
14006pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14007    let mut digest = Sha256::new();
14008    let mut buffer = [0u8; 64 * 1024];
14009    loop {
14010        let read = reader.read(&mut buffer)?;
14011        if read == 0 {
14012            break;
14013        }
14014        digest.update(&buffer[..read]);
14015    }
14016    Ok(format!("{:x}", digest.finalize()))
14017}
14018
14019/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
14020/// chain, not just the head): every entry's signature, hash, sequence
14021/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
14022/// checked in a sibling staging directory. Only then is the old mirror swapped
14023/// out through an atomic directory exchange. Every stage, install, and cleanup
14024/// operation is relative to one held no-follow parent-directory capability, so
14025/// renaming an ancestor cannot redirect any write or deletion.
14026#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14027pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14028    require_hardened_filesystem("mirror")?;
14029    require_safe_ref(brain)?;
14030    #[cfg(windows)]
14031    {
14032        let _ = (cfg, dest);
14033        return Err(LinkError::UnsupportedPlatform {
14034            operation: "atomic whole-mirror replacement on Windows",
14035        });
14036    }
14037    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14038    let name = dest
14039        .file_name()
14040        .and_then(|name| name.to_str())
14041        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14042        .ok_or_else(|| LinkError::UnsafePath {
14043            path: dest.display().to_string(),
14044        })?;
14045    #[cfg(unix)]
14046    let parent_dir = open_or_create_dir_nofollow(parent)?;
14047    #[cfg(unix)]
14048    use std::os::fd::AsRawFd as _;
14049    #[cfg(unix)]
14050    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14051    #[cfg(unix)]
14052    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14053        None => false,
14054        Some(true) => true,
14055        Some(false) => {
14056            return Err(LinkError::UnsafePath {
14057                path: dest.display().to_string(),
14058            });
14059        }
14060    };
14061
14062    // A fixed backup was used by pre-hardening builds. Never interpret or
14063    // delete an attacker-planted entry at that name; require manual recovery.
14064    #[cfg(unix)]
14065    let legacy_backup_name = c_name(
14066        format!(".{name}.dbmd-backup").as_bytes(),
14067        &dest.display().to_string(),
14068    )?;
14069    #[cfg(unix)]
14070    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14071        return Err(LinkError::UnsafePath {
14072            path: parent
14073                .join(format!(".{name}.dbmd-backup"))
14074                .display()
14075                .to_string(),
14076        });
14077    }
14078
14079    let nonce = std::time::SystemTime::now()
14080        .duration_since(std::time::UNIX_EPOCH)
14081        .unwrap_or_default()
14082        .as_nanos();
14083    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14084    #[cfg(unix)]
14085    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14086    #[cfg(unix)]
14087    let stage_dir = create_dir_exclusive_at(
14088        parent_dir.as_raw_fd(),
14089        &stage_name,
14090        &dest.display().to_string(),
14091    )?;
14092
14093    let assembled = (|| -> LinkResult<MirrorReport> {
14094        let remote = verified_remote_head(cfg, brain, true)?;
14095        let brain_id = remote.head.brain.clone();
14096        let identity = remote
14097            .identity
14098            .as_ref()
14099            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14100        let anchor = remote
14101            .anchor
14102            .clone()
14103            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14104        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14105        let snapshot_entries = parse_store_pack(pack.clone())?;
14106        let snapshot_count = snapshot_entries.len();
14107        let mut staged_entries = snapshot_entries;
14108        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14109        for item in &remote.entries {
14110            let mut exact = serde_json::to_vec(&item.entry)
14111                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14112            exact.push(b'\n');
14113            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14114                return Err(invalid_feed(
14115                    "serialized mirror entry differs from its verified hash",
14116                ));
14117            }
14118            staged_entries.push((
14119                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14120                exact,
14121            ));
14122        }
14123        let mut identity_bytes = serde_json::to_vec(identity)
14124            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14125        identity_bytes.push(b'\n');
14126        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14127        let mut head_bytes = serde_json::to_vec(&json!({
14128            "brain": brain_id,
14129            "headSeq": remote.head.seq,
14130            "feedHash": remote.head.feed_hash,
14131        }))
14132        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14133        head_bytes.push(b'\n');
14134        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14135        staged_entries.push((
14136            CONFIG_REL_PATH.to_string(),
14137            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14138        ));
14139        #[cfg(unix)]
14140        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14141
14142        Ok(MirrorReport {
14143            brain: brain_id,
14144            head_seq: remote.head.seq,
14145            feed_hash: remote.head.feed_hash,
14146            entries: remote.entries.len() as u64,
14147            pinned: anchor,
14148            files: snapshot_count,
14149        })
14150    })();
14151
14152    let report = match assembled {
14153        Ok(report) => report,
14154        Err(error) => {
14155            #[cfg(unix)]
14156            let _ = remove_tree_at(
14157                parent_dir.as_raw_fd(),
14158                &stage_name,
14159                &dest.display().to_string(),
14160            );
14161            return Err(error);
14162        }
14163    };
14164
14165    #[cfg(unix)]
14166    if let Err(error) =
14167        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14168    {
14169        let _ = remove_tree_at(
14170            parent_dir.as_raw_fd(),
14171            &stage_name,
14172            &dest.display().to_string(),
14173        );
14174        return Err(error);
14175    }
14176    // An exchange leaves the old mirror at the unique stage name. Cleanup is
14177    // capability-relative and never follows symlinks in hostile old content.
14178    #[cfg(unix)]
14179    if dest_exists {
14180        remove_tree_at(
14181            parent_dir.as_raw_fd(),
14182            &stage_name,
14183            &dest.display().to_string(),
14184        )?;
14185    }
14186    #[cfg(unix)]
14187    parent_dir.sync_all()?;
14188    Ok(report)
14189}
14190
14191fn verified_remote_head(
14192    cfg: &HubConfig,
14193    brain: &str,
14194    require_full_chain: bool,
14195) -> LinkResult<VerifiedRemote> {
14196    require_hardened_filesystem("verified link.md state")?;
14197    require_safe_ref(brain)?;
14198    // Refuse an unsafe state root before sending credentials or consulting an
14199    // untrusted card, and retain this exact directory inode for the complete
14200    // checkpoint transaction.
14201    let trust_directory = open_trust_dir(cfg)?;
14202    let path = format!("/api/hub/brains/{brain}");
14203    let body = ensure_ok(
14204        request(cfg, "GET", &path, None, Auth::Required)?,
14205        "subscribe",
14206    )?;
14207    let resolved_brain = body
14208        .get("id")
14209        .and_then(Value::as_str)
14210        .filter(|id| crate::ulid::is_ulid(id))
14211        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14212        .to_string();
14213    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14214        return Err(invalid_feed(
14215            "brain card id differs from the explicitly requested brain id",
14216        ));
14217    }
14218    // The card supplies the canonical ULID. Lock alias + canonical keys in
14219    // deterministic filename order, then hold both through verify + save.
14220    // Concurrent aliases for the same brain therefore converge on one
14221    // checkpoint instead of establishing independent TOFU universes.
14222    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14223    let (pinned, alias_binding) =
14224        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14225    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14226    let advertised_hash = body
14227        .get("feedHash")
14228        .and_then(Value::as_str)
14229        .map(str::to_string);
14230    let updated_at = body
14231        .get("updatedAt")
14232        .and_then(Value::as_str)
14233        .map(str::to_string);
14234    if let Some(pin) = &pinned {
14235        if seq < pin.head_seq {
14236            return Err(invalid_feed(format!(
14237                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14238                pin.head_seq
14239            )));
14240        }
14241        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14242            return Err(invalid_feed(
14243                "feed equivocation: the checkpoint sequence now has a different hash",
14244            ));
14245        }
14246    }
14247    if seq == 0 {
14248        if advertised_hash.is_some() {
14249            return Err(invalid_feed("an empty feed advertised a head hash"));
14250        }
14251        let identity: FeedIdentity = serde_json::from_value(
14252            body.get("identity")
14253                .cloned()
14254                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14255        )
14256        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14257        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14258        // A valid old-key-signed rotation is still inconsistent if it commits
14259        // to history that the same card now claims never existed. Run the
14260        // identical feed-boundary proof used by non-empty heads before this
14261        // identity can become a durable TOFU checkpoint.
14262        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14263        save_canonical_pin_and_alias(
14264            cfg,
14265            &trust_directory,
14266            brain,
14267            &resolved_brain,
14268            TrustState {
14269                v: 2,
14270                origin: normalized_origin(&cfg.hub)?,
14271                requested: resolved_brain.clone(),
14272                brain: resolved_brain.clone(),
14273                home: None,
14274                anchor: anchor.clone(),
14275                current: format!("ed25519:{}", identity.fingerprint),
14276                head_seq: 0,
14277                feed_hash: None,
14278                rotations: identity.rotations.clone(),
14279                hub_signer: None,
14280                protocol_profile: None,
14281            },
14282            alias_binding.as_ref(),
14283        )?;
14284        return Ok(VerifiedRemote {
14285            head: Head {
14286                brain: resolved_brain,
14287                seq,
14288                updated_at,
14289                feed_hash: None,
14290                verified: true,
14291            },
14292            identity: Some(identity),
14293            head_entry: None,
14294            entries: Vec::new(),
14295            anchor: Some(anchor),
14296        });
14297    }
14298    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14299        return Err(invalid_feed(
14300            "non-empty feed did not advertise a valid SHA-256 head",
14301        ));
14302    }
14303
14304    // On first contact the signed head itself is the TOFU checkpoint. A full
14305    // history replay cannot add authority before an anchor exists; mirrors
14306    // still request the complete chain because they promise an archival copy.
14307    let replay_head_only = !require_full_chain
14308        && pinned
14309            .as_ref()
14310            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14311    let mut after = if replay_head_only {
14312        seq - 1
14313    } else if require_full_chain || pinned.is_none() {
14314        0
14315    } else {
14316        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14317    };
14318    let mut expected_seq = after + 1;
14319    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14320        None
14321    } else {
14322        pinned
14323            .as_ref()
14324            .and_then(|checkpoint| checkpoint.feed_hash.clone())
14325    };
14326    let mut identity: Option<FeedIdentity> = None;
14327    let mut anchor: Option<String> = None;
14328    let mut head_entry: Option<FeedItem> = None;
14329    let mut all_entries = Vec::new();
14330    let mut observed_entries = Vec::new();
14331    let replay_count = seq
14332        .checked_sub(after)
14333        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14334    if replay_count > MAX_FEED_REPLAY_ENTRIES {
14335        return Err(invalid_feed(format!(
14336            "feed replay requires {replay_count} entries, over the client cap"
14337        )));
14338    }
14339    let mut replay_bytes = 0u64;
14340
14341    loop {
14342        let feed_bytes = ensure_raw_ok(
14343            request_raw(
14344                cfg,
14345                "GET",
14346                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14347                None,
14348                Auth::Required,
14349                MAX_FEED_RESPONSE_BYTES,
14350            )?,
14351            "subscribe feed",
14352        )?;
14353        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14354            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14355        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14356            return Err(invalid_feed("brain card and feed head disagree"));
14357        }
14358        if feed.entries.len() > FEED_PAGE_LIMIT {
14359            return Err(invalid_feed("feed page exceeds the requested entry limit"));
14360        }
14361        if feed.scope_limited {
14362            if require_full_chain {
14363                return Err(invalid_feed(
14364                    "path-scoped grants cannot verify a full snapshot chain",
14365                ));
14366            }
14367            return Ok(VerifiedRemote {
14368                head: Head {
14369                    brain: resolved_brain,
14370                    seq,
14371                    updated_at,
14372                    feed_hash: advertised_hash,
14373                    verified: false,
14374                },
14375                identity: None,
14376                head_entry: None,
14377                entries: Vec::new(),
14378                anchor: None,
14379            });
14380        }
14381        let page_identity = feed
14382            .identity
14383            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14384        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14385        if identity
14386            .as_ref()
14387            .is_some_and(|existing| existing != &page_identity)
14388        {
14389            return Err(invalid_feed("identity changed while reading the feed"));
14390        }
14391        if anchor
14392            .as_ref()
14393            .is_some_and(|existing| existing != &page_anchor)
14394        {
14395            return Err(invalid_feed(
14396                "identity anchor changed while reading the feed",
14397            ));
14398        }
14399        identity = Some(page_identity.clone());
14400        if anchor.is_none() {
14401            anchor = Some(page_anchor);
14402        }
14403        if feed.entries.is_empty() {
14404            return Err(invalid_feed("feed page was empty before the signed head"));
14405        }
14406
14407        for item in feed.entries {
14408            if item.entry.seq != expected_seq {
14409                return Err(invalid_feed(format!(
14410                    "expected entry {expected_seq}, feed served {}",
14411                    item.entry.seq
14412                )));
14413            }
14414            if item.entry.seq > seq {
14415                return Err(invalid_feed("feed advanced past the card snapshot"));
14416            }
14417            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14418                return Err(invalid_feed(format!(
14419                    "entry {} does not chain to the local checkpoint",
14420                    item.entry.seq
14421                )));
14422            }
14423            verify_feed_item(&item, &page_identity)?;
14424            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14425            replay_bytes = replay_bytes.saturating_add(
14426                serde_json::to_vec(&item)
14427                    .map_err(|_| invalid_feed("could not size feed entry"))?
14428                    .len() as u64,
14429            );
14430            if replay_bytes > MAX_FEED_REPLAY_BYTES {
14431                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14432            }
14433            previous_hash = Some(item.hash.clone());
14434            after = item.entry.seq;
14435            expected_seq = expected_seq
14436                .checked_add(1)
14437                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14438            if require_full_chain {
14439                all_entries.push(item.clone());
14440            }
14441            observed_entries.push(item.clone());
14442            head_entry = Some(item);
14443        }
14444        if after == seq {
14445            break;
14446        }
14447    }
14448
14449    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14450        return Err(invalid_feed(
14451            "verified chain does not converge on the advertised head",
14452        ));
14453    }
14454    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14455    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14456    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14457    save_canonical_pin_and_alias(
14458        cfg,
14459        &trust_directory,
14460        brain,
14461        &resolved_brain,
14462        TrustState {
14463            v: 2,
14464            origin: normalized_origin(&cfg.hub)?,
14465            requested: resolved_brain.clone(),
14466            brain: resolved_brain.clone(),
14467            home: None,
14468            anchor: anchor.clone(),
14469            current: format!("ed25519:{}", identity.fingerprint),
14470            head_seq: seq,
14471            feed_hash: advertised_hash.clone(),
14472            rotations: identity.rotations.clone(),
14473            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14474            protocol_profile: pinned
14475                .as_ref()
14476                .and_then(|state| state.protocol_profile.clone()),
14477        },
14478        alias_binding.as_ref(),
14479    )?;
14480    Ok(VerifiedRemote {
14481        head: Head {
14482            brain: resolved_brain,
14483            seq,
14484            updated_at,
14485            feed_hash: advertised_hash,
14486            verified: true,
14487        },
14488        identity: Some(identity),
14489        head_entry,
14490        entries: all_entries,
14491        anchor: Some(anchor),
14492    })
14493}
14494
14495/// Read and locally verify the brain's current signed feed head. Identity
14496/// rotation is accepted only through an old-key-signed chain rooted at the
14497/// local TOFU anchor; sequence and hash checkpoints reject rollback and
14498/// equivocation across invocations.
14499pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14500    if let Some(verified) = v2_verified_head(cfg, brain)? {
14501        let observation = Head {
14502            brain: verified.brain_id.clone(),
14503            seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14504            updated_at: verified
14505                .pointer
14506                .as_ref()
14507                .map(|pointer| pointer.signed_at.clone()),
14508            feed_hash: verified
14509                .pointer
14510                .as_ref()
14511                .map(|pointer| pointer.feed_hash.clone()),
14512            verified: true,
14513        };
14514        accept_v2_head(cfg, &verified)?;
14515        return Ok(observation);
14516    }
14517    Ok(verified_remote_head(cfg, brain, false)?.head)
14518}
14519
14520#[cfg(test)]
14521mod tests {
14522    use super::*;
14523
14524    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14525
14526    fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14527        json!({
14528            "sha256": "a".repeat(64),
14529            "bytes": 10,
14530            "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14531        })
14532    }
14533
14534    #[test]
14535    fn upload_reservations_batch_by_count_and_by_size() {
14536        // A real store's first push declares one blob per changed file, which
14537        // does not fit in one request. Every batch has to stay inside both
14538        // bounds, and concatenating them has to reproduce the original order.
14539        let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14540        let batches = batch_upload_declarations(declarations.clone());
14541
14542        assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14543        for batch in &batches {
14544            assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14545            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14546                .expect("batch serializes")
14547                .len();
14548            assert!(
14549                bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14550                "batch body {bytes} exceeds the reservation budget"
14551            );
14552        }
14553        let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14554        assert_eq!(
14555            flattened, declarations,
14556            "batching must preserve the set and order"
14557        );
14558    }
14559
14560    #[test]
14561    fn only_load_shaped_hub_answers_are_worth_asking_again() {
14562        // A migration opens dozens of staging windows, so a busy hub's 429 or
14563        // 5xx must not end the push — while a refusal that states something
14564        // about the request itself has to surface immediately rather than
14565        // being repeated six times behind a minute of waiting.
14566        for status in [408, 429, 500, 502, 503, 504] {
14567            assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14568        }
14569        for status in [400, 401, 403, 404, 409, 413, 422] {
14570            assert!(
14571                !is_retryable_hub_status(status),
14572                "{status} states something about the request"
14573            );
14574        }
14575        // The waits must reach past a whole limiter window to be worth taking.
14576        let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14577        assert!(total >= 60_000, "backoff totals only {total}ms");
14578    }
14579
14580    #[test]
14581    fn a_batch_shares_a_connection_only_within_one_authority() {
14582        // Sharing a pooled agent is what makes a migration stage in minutes,
14583        // but it must never widen where bytes can go: a batch that spans two
14584        // authorities, or names an unsafe URL, falls back to per-object agents
14585        // rather than reusing a connection pinned to somewhere else.
14586        let cfg = HubConfig {
14587            hub: "https://www.sevrahq.com".to_string(),
14588            key: Some("k".to_string()),
14589            agent_key: None,
14590            brain_key: None,
14591            state_dir: PathBuf::from("."),
14592            store_selected: false,
14593        };
14594        assert!(shared_staging_agent(&cfg, &[]).is_none());
14595        assert!(
14596            shared_staging_agent(
14597                &cfg,
14598                &[
14599                    "https://one.example.com/a?sig=1",
14600                    "https://two.example.com/b?sig=2",
14601                ]
14602            )
14603            .is_none(),
14604            "two authorities must not share a pinned pool"
14605        );
14606        assert!(
14607            shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14608            "an unsafe object-store URL must not produce an agent"
14609        );
14610        assert!(
14611            shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14612            "credentials in the URL must not produce an agent"
14613        );
14614    }
14615
14616    #[test]
14617    fn a_staged_change_states_only_operations_and_blobs() {
14618        // The hub refuses a manifest with any other key, so the two sides have
14619        // to agree exactly. A drifting extra field would fail a whole migration
14620        // at the commit, after every byte had already been staged.
14621        let operations = vec![json!({
14622            "op": "put",
14623            "path": "records/a.md",
14624            "blob": "a".repeat(64),
14625            "bytes": 3,
14626        })];
14627        let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14628        let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14629        let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14630        let keys: Vec<&str> = parsed
14631            .as_object()
14632            .expect("manifest is an object")
14633            .keys()
14634            .map(String::as_str)
14635            .collect();
14636        assert_eq!(keys, ["blobs", "operations"]);
14637        assert_eq!(parsed["operations"], Value::Array(operations));
14638        assert_eq!(parsed["blobs"], blobs);
14639    }
14640
14641    #[test]
14642    fn a_staged_push_signs_the_change_not_the_transport() {
14643        // Self-custody signs a request hash the hub recomputes from the parsed
14644        // change. A staged body no longer states its operations, so the signed
14645        // view has to restore them or every large self-custodied push would
14646        // sign a different request than the one the hub validated.
14647        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14648        let staged = json!({
14649            "mutation_id": "dbmd-1",
14650            "rebase": "strict",
14651            "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14652        });
14653        let view = v2_signed_request_view(&staged, &operations);
14654        assert_eq!(view["operations"], Value::Array(operations.clone()));
14655        assert!(view.get("staged_change").is_none());
14656        assert_eq!(view["mutation_id"], staged["mutation_id"]);
14657
14658        let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14659        assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14660    }
14661
14662    #[test]
14663    fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14664        let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
14665        let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
14666            .expect_err("an oversized change must not be staged");
14667        assert!(
14668            matches!(error, LinkError::PushTooLarge { .. }),
14669            "expected a size refusal, got {error:?}"
14670        );
14671    }
14672
14673    #[test]
14674    fn a_push_that_fits_the_request_is_left_inline() {
14675        // The staged lane costs a reservation and a round trip, so it must
14676        // engage only when the body genuinely does not fit. The hub here is a
14677        // closed port: reaching for it at all would fail this test.
14678        let cfg = HubConfig {
14679            hub: "http://127.0.0.1:9".to_string(),
14680            key: Some("k".to_string()),
14681            agent_key: None,
14682            brain_key: None,
14683            state_dir: PathBuf::from("."),
14684            store_selected: false,
14685        };
14686        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14687        let mut body = json!({
14688            "mutation_id": "dbmd-1",
14689            "operations": operations,
14690            "blobs": [],
14691        });
14692        stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
14693        assert!(body.get("staged_change").is_none());
14694        assert_eq!(body["operations"], Value::Array(operations));
14695    }
14696
14697    #[test]
14698    fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
14699        // Byte budget, not just count. One hash can carry many coordinates
14700        // (the same bytes reachable at several paths), so a batch sized only by
14701        // count would blow the body budget on a deduplicated store.
14702        let declarations: Vec<Value> = (0..2_000)
14703            .map(|index| {
14704                json!({
14705                    "sha256": "a".repeat(64),
14706                    "bytes": 10,
14707                    "coordinates": (0..24)
14708                        .map(|slot| format!(
14709                            "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
14710                        ))
14711                        .collect::<Vec<_>>(),
14712                })
14713            })
14714            .collect();
14715        let batches = batch_upload_declarations(declarations);
14716        assert!(
14717            batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
14718            "wide coordinate sets must bound the batch by size"
14719        );
14720        for batch in &batches {
14721            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14722                .expect("batch serializes")
14723                .len();
14724            assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
14725        }
14726    }
14727
14728    #[test]
14729    fn a_small_push_still_rides_exactly_one_request() {
14730        let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
14731        assert_eq!(batch_upload_declarations(declarations).len(), 1);
14732        assert!(batch_upload_declarations(Vec::new()).is_empty());
14733    }
14734
14735    #[test]
14736    fn exact_source_move_becomes_one_provenance_preserving_rename() {
14737        let hash = "a".repeat(64);
14738        let operations = vec![
14739            json!({
14740                "op": "put",
14741                "path": "sources/curated/item.md",
14742                "expected": { "kind": "absent" },
14743                "blob": hash,
14744                "bytes": 19,
14745            }),
14746            json!({
14747                "op": "delete",
14748                "path": "sources/inbox/item.md",
14749                "expected": { "kind": "blob", "hash": hash },
14750            }),
14751        ];
14752
14753        assert_eq!(
14754            infer_exact_source_promotions(operations),
14755            vec![json!({
14756                "op": "rename",
14757                "from": "sources/inbox/item.md",
14758                "to": "sources/curated/item.md",
14759                "expected_from": { "kind": "blob", "hash": hash },
14760                "expected_to": { "kind": "absent" },
14761                "blob": hash,
14762                "bytes": 19,
14763            })]
14764        );
14765    }
14766
14767    #[test]
14768    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
14769        let hash = "b".repeat(64);
14770        let operations = vec![
14771            json!({
14772                "op": "delete",
14773                "path": "sources/inbox/a.md",
14774                "expected": { "kind": "blob", "hash": hash },
14775            }),
14776            json!({
14777                "op": "delete",
14778                "path": "sources/inbox/b.md",
14779                "expected": { "kind": "blob", "hash": hash },
14780            }),
14781            json!({
14782                "op": "put",
14783                "path": "sources/curated/item.md",
14784                "expected": { "kind": "absent" },
14785                "blob": hash,
14786                "bytes": 19,
14787            }),
14788        ];
14789
14790        assert_eq!(
14791            infer_exact_source_promotions(operations.clone()),
14792            operations,
14793            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
14794        );
14795    }
14796
14797    #[test]
14798    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
14799        let hash = "c".repeat(64);
14800        let mut candidate = std::collections::BTreeMap::from([(
14801            "sources/inbox/item.md".to_string(),
14802            V2BaselineFile {
14803                sha256: hash.clone(),
14804                bytes: 19,
14805                proof: None,
14806            },
14807        )]);
14808        let mut candidate_assets = std::collections::BTreeMap::new();
14809        let operations = vec![
14810            json!({
14811                "op": "rename",
14812                "from": "sources/inbox/item.md",
14813                "to": "sources/curated/item.md",
14814                "expected_from": { "kind": "blob", "hash": hash },
14815                "expected_to": { "kind": "absent" },
14816                "blob": hash,
14817                "bytes": 19,
14818            }),
14819            json!({
14820                "op": "put",
14821                "path": "records/rsvps/item.md",
14822                "expected": { "kind": "absent" },
14823                "blob": "d".repeat(64),
14824                "bytes": 23,
14825            }),
14826        ];
14827
14828        assert!(!apply_generated_v2_operations(
14829            &operations,
14830            &std::collections::BTreeMap::new(),
14831            &mut candidate,
14832            &mut candidate_assets,
14833        )
14834        .unwrap());
14835        assert!(!candidate.contains_key("sources/inbox/item.md"));
14836        assert_eq!(
14837            candidate
14838                .get("sources/curated/item.md")
14839                .map(|file| (&file.sha256, file.bytes)),
14840            Some((&hash, 19))
14841        );
14842        assert_eq!(
14843            candidate
14844                .get("records/rsvps/item.md")
14845                .map(|file| (file.sha256.as_str(), file.bytes)),
14846            Some((
14847                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
14848                23
14849            ))
14850        );
14851    }
14852
14853    fn merge_fixture(
14854        base: Option<&str>,
14855        remote: Option<&str>,
14856        local: Option<&str>,
14857        keep_local: bool,
14858    ) -> V2PulledMerge<String> {
14859        let map = |value: Option<&str>| {
14860            value
14861                .map(|value| [("records/a.md".to_string(), value.to_string())])
14862                .into_iter()
14863                .flatten()
14864                .collect::<std::collections::BTreeMap<_, _>>()
14865        };
14866        merge_v2_pulled_records(
14867            &map(base),
14868            &map(remote),
14869            &map(local),
14870            |value, _| value.clone(),
14871            |value, _| value.clone(),
14872            |_| keep_local,
14873        )
14874    }
14875
14876    #[test]
14877    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
14878        let path = "records/a.md".to_string();
14879
14880        let local_add = merge_fixture(None, None, Some("local"), false);
14881        assert_eq!(
14882            local_add.records.get(&path).map(String::as_str),
14883            Some("local")
14884        );
14885        assert!(local_add.accept_remote.is_empty());
14886        assert!(local_add.conflicts.is_empty());
14887
14888        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
14889        assert_eq!(
14890            local_edit.records.get(&path).map(String::as_str),
14891            Some("local")
14892        );
14893        assert!(local_edit.accept_remote.is_empty());
14894        assert!(local_edit.conflicts.is_empty());
14895
14896        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
14897        assert!(!local_delete.records.contains_key(&path));
14898        assert!(local_delete.accept_remote.is_empty());
14899        assert!(local_delete.conflicts.is_empty());
14900
14901        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
14902        assert_eq!(
14903            remote_edit.records.get(&path).map(String::as_str),
14904            Some("remote")
14905        );
14906        assert!(remote_edit.accept_remote.contains(&path));
14907        assert!(remote_edit.conflicts.is_empty());
14908
14909        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
14910        assert!(!remote_delete.records.contains_key(&path));
14911        assert!(remote_delete.accept_remote.contains(&path));
14912        assert!(remote_delete.conflicts.is_empty());
14913
14914        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
14915        assert_eq!(
14916            same_edit.records.get(&path).map(String::as_str),
14917            Some("same")
14918        );
14919        assert!(same_edit.accept_remote.contains(&path));
14920        assert!(same_edit.conflicts.is_empty());
14921
14922        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
14923        assert_eq!(conflict.conflicts, vec![path.clone()]);
14924        assert_eq!(
14925            conflict.records.get(&path).map(String::as_str),
14926            Some("local")
14927        );
14928        assert!(conflict.accept_remote.is_empty());
14929
14930        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
14931        assert_eq!(
14932            kept_home.records.get(&path).map(String::as_str),
14933            Some("local")
14934        );
14935        assert!(kept_home.accept_remote.is_empty());
14936        assert!(kept_home.conflicts.is_empty());
14937    }
14938
14939    #[test]
14940    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
14941        let path = "sources/report.pdf";
14942        let record = crate::AssetRecord {
14943            path: path.to_string(),
14944            sha256: "a".repeat(64),
14945            bytes: 42,
14946            media_type: "application/pdf".to_string(),
14947            wrappers: vec!["gzip".to_string()],
14948            required: true,
14949        };
14950        let mut remote = V2BaselineAsset {
14951            blob_sha256: record.sha256.clone(),
14952            bytes: record.bytes,
14953            media_type: record.media_type.clone(),
14954            wrappers: record.wrappers.clone(),
14955            required: record.required,
14956            disposition: "withheld".to_string(),
14957            leaf_hash: "b".repeat(64),
14958        };
14959
14960        assert!(v2_asset_resumes_hosting(
14961            Some(&remote),
14962            path,
14963            &record,
14964            "hosted"
14965        ));
14966        assert!(!v2_asset_resumes_hosting(
14967            Some(&remote),
14968            path,
14969            &record,
14970            "withheld"
14971        ));
14972
14973        remote.disposition = "hosted".to_string();
14974        assert!(!v2_asset_resumes_hosting(
14975            Some(&remote),
14976            path,
14977            &record,
14978            "hosted"
14979        ));
14980
14981        remote.disposition = "withheld".to_string();
14982        remote.blob_sha256 = "c".repeat(64);
14983        assert!(!v2_asset_resumes_hosting(
14984            Some(&remote),
14985            path,
14986            &record,
14987            "hosted"
14988        ));
14989        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
14990    }
14991
14992    #[test]
14993    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
14994        let path = "records/team/alpha.md".to_string();
14995        let deleted_path = "records/team/deleted.md".to_string();
14996        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
14997            sha256,
14998            bytes,
14999            file: None,
15000        };
15001        let files = vec![
15002            V2ConflictFile {
15003                path: path.clone(),
15004                base: coordinate(None, None),
15005                local: coordinate(Some("b".repeat(64)), Some(7)),
15006                remote: coordinate(Some("a".repeat(64)), Some(5)),
15007            },
15008            V2ConflictFile {
15009                path: deleted_path.clone(),
15010                base: coordinate(Some("c".repeat(64)), Some(9)),
15011                local: coordinate(Some("d".repeat(64)), Some(11)),
15012                remote: coordinate(None, None),
15013            },
15014        ];
15015        let proven = V2BaselineFile {
15016            sha256: "a".repeat(64),
15017            bytes: 5,
15018            proof: None,
15019        };
15020        let current = [(path.clone(), proven.clone())]
15021            .into_iter()
15022            .collect::<std::collections::BTreeMap<_, _>>();
15023
15024        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
15025        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15026        assert_eq!(deleted, vec![deleted_path.clone()]);
15027
15028        let changed = [(
15029            path.clone(),
15030            V2BaselineFile {
15031                sha256: "e".repeat(64),
15032                bytes: 5,
15033                proof: None,
15034            },
15035        )]
15036        .into_iter()
15037        .collect::<std::collections::BTreeMap<_, _>>();
15038        assert!(v2_take_remote_selection(&files, &changed).is_err());
15039
15040        let resurrected = [
15041            (path, proven),
15042            (
15043                deleted_path,
15044                V2BaselineFile {
15045                    sha256: "f".repeat(64),
15046                    bytes: 13,
15047                    proof: None,
15048                },
15049            ),
15050        ]
15051        .into_iter()
15052        .collect::<std::collections::BTreeMap<_, _>>();
15053        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15054    }
15055
15056    #[cfg(target_os = "linux")]
15057    #[test]
15058    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15059        use std::os::fd::AsRawFd as _;
15060
15061        let sandbox = tempfile::TempDir::new().unwrap();
15062        let parent = std::fs::File::open(sandbox.path()).unwrap();
15063        let stage = std::ffi::CString::new("stage").unwrap();
15064        let destination = std::ffi::CString::new("brain").unwrap();
15065
15066        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15067        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15068        install_stage_at(
15069            parent.as_raw_fd(),
15070            stage.as_c_str(),
15071            destination.as_c_str(),
15072            false,
15073        )
15074        .unwrap();
15075        assert!(!sandbox.path().join("stage").exists());
15076        assert_eq!(
15077            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15078            b"created"
15079        );
15080
15081        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15082        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15083        install_stage_at(
15084            parent.as_raw_fd(),
15085            stage.as_c_str(),
15086            destination.as_c_str(),
15087            true,
15088        )
15089        .unwrap();
15090        assert_eq!(
15091            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15092            b"replacement"
15093        );
15094        assert_eq!(
15095            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15096            b"created",
15097            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15098        );
15099    }
15100
15101    struct SignedRemoteFixture {
15102        card: String,
15103        feed: String,
15104        key: AgentSigningKey,
15105        identity: FeedIdentity,
15106    }
15107
15108    fn signed_remote_fixture() -> SignedRemoteFixture {
15109        let rng = ring::rand::SystemRandom::new();
15110        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15111        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15112        let (public_key, multikey) = public_identity_for(&pair);
15113        let identity = FeedIdentity {
15114            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15115            public_key_spki: public_key.clone(),
15116            previous: Vec::new(),
15117            rotations: Vec::new(),
15118        };
15119        let mut entry = FeedEntry {
15120            v: 1,
15121            seq: 1,
15122            ts: "2026-07-30T12:00:00.000Z".to_string(),
15123            brain: multikey.clone(),
15124            public_key: public_key.clone(),
15125            kind: "push".to_string(),
15126            op: "snapshot".to_string(),
15127            pack_sha256: "a".repeat(64),
15128            files: Vec::new(),
15129            removed: Vec::new(),
15130            prev_entry_hash: None,
15131            sig: String::new(),
15132        };
15133        let unsigned = UnsignedFeedEntry {
15134            v: entry.v,
15135            seq: entry.seq,
15136            ts: &entry.ts,
15137            brain: &entry.brain,
15138            public_key: &entry.public_key,
15139            kind: &entry.kind,
15140            op: &entry.op,
15141            pack_sha256: &entry.pack_sha256,
15142            files: &entry.files,
15143            removed: &entry.removed,
15144            prev_entry_hash: &entry.prev_entry_hash,
15145        };
15146        entry.sig =
15147            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15148        let mut exact = serde_json::to_vec(&entry).unwrap();
15149        exact.push(b'\n');
15150        let hash = content_sha256(&exact);
15151        let card = json!({
15152            "id": TEST_BRAIN_ID,
15153            "headSeq": 1,
15154            "feedHash": hash,
15155            "identity": identity.clone(),
15156        })
15157        .to_string();
15158        let feed = json!({
15159            "headSeq": 1,
15160            "feedHash": hash,
15161            "identity": identity.clone(),
15162            "entries": [{"hash": hash, "entry": entry}],
15163            "scopeLimited": false,
15164        })
15165        .to_string();
15166        SignedRemoteFixture {
15167            card,
15168            feed,
15169            key: AgentSigningKey {
15170                pkcs8: pkcs8.as_ref().to_vec(),
15171                multikey,
15172                public_key_spki: public_key,
15173            },
15174            identity,
15175        }
15176    }
15177
15178    #[test]
15179    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15180        let file = |path: &str, byte: char| FeedFile {
15181            path: path.to_string(),
15182            sha256: byte.to_string().repeat(64),
15183            bytes: 1,
15184        };
15185        let a0 = file("records/a.md", 'a');
15186        let a1 = file("records/a.md", 'b');
15187        let stable = file("records/stable.md", 'c');
15188        let added = file("records/added.md", 'd');
15189        let removed_file = file("records/removed.md", 'e');
15190        let previous = vec![a0, stable.clone(), removed_file.clone()];
15191        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15192        let removed = vec![removed_file.path.clone()];
15193
15194        assert_eq!(
15195            verify_v1_manifest_disclosure(
15196                "edit",
15197                &previous,
15198                &resulting,
15199                &[a1.clone(), added.clone()],
15200                &removed,
15201            ),
15202            Ok(())
15203        );
15204        assert_eq!(
15205            verify_v1_manifest_disclosure(
15206                "edit",
15207                &previous,
15208                &resulting,
15209                &[stable.clone(), added.clone(), a1.clone()],
15210                &removed,
15211            ),
15212            Ok(())
15213        );
15214        assert_eq!(
15215            verify_v1_manifest_disclosure(
15216                "edit",
15217                &previous,
15218                &resulting,
15219                std::slice::from_ref(&added),
15220                &removed,
15221            ),
15222            Err(V1DisclosureError::EditMissingChange)
15223        );
15224        assert_eq!(
15225            verify_v1_manifest_disclosure(
15226                "edit",
15227                &previous,
15228                &resulting,
15229                &[file("records/a.md", 'f'), added.clone()],
15230                &removed,
15231            ),
15232            Err(V1DisclosureError::EditFalseFile)
15233        );
15234        assert_eq!(
15235            verify_v1_manifest_disclosure(
15236                "edit",
15237                &previous,
15238                &resulting,
15239                &[a1.clone(), added.clone()],
15240                &[],
15241            ),
15242            Err(V1DisclosureError::RemovedMismatch)
15243        );
15244        assert_eq!(
15245            verify_v1_manifest_disclosure(
15246                "push",
15247                &previous,
15248                &resulting,
15249                &[added.clone(), stable, a1],
15250                &removed,
15251            ),
15252            Ok(())
15253        );
15254        assert_eq!(
15255            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15256            Err(V1DisclosureError::PushManifestMismatch)
15257        );
15258    }
15259
15260    #[test]
15261    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15262        let fixture = signed_remote_fixture();
15263        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15264        let item = feed["entries"][0].to_string();
15265        let oversized_page = format!(
15266            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15267            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15268                .collect::<Vec<_>>()
15269                .join(",")
15270        );
15271        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15272
15273        let oversized_identity = format!(
15274            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15275            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15276                .collect::<Vec<_>>()
15277                .join(",")
15278        );
15279        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15280
15281        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15282        let oversized_entry = format!(
15283            "{{\"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\"}}",
15284            "a".repeat(64),
15285            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15286                .collect::<Vec<_>>()
15287                .join(",")
15288        );
15289        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15290    }
15291
15292    #[test]
15293    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15294        let id = "01arz3ndektsv4rrffq69g5fav";
15295        let digest = "a".repeat(64);
15296        assert_eq!(
15297            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15298            V2BulkConfirmation {
15299                id: id.to_string(),
15300                digest,
15301            }
15302        );
15303        for invalid in [
15304            "",
15305            "01arz3ndektsv4rrffq69g5fav",
15306            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15307            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15308            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15309        ] {
15310            assert!(matches!(
15311                V2BulkConfirmation::parse(invalid),
15312                Err(LinkError::InvalidPack { .. })
15313            ));
15314        }
15315    }
15316
15317    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15318        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15319        use std::net::TcpListener;
15320
15321        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15322        let url = format!("http://{}", listener.local_addr().unwrap());
15323        let handle = std::thread::spawn(move || {
15324            for (status, body) in responses {
15325                let (stream, _) = listener.accept().unwrap();
15326                let mut reader = BufReader::new(stream);
15327                let mut line = String::new();
15328                reader.read_line(&mut line).unwrap();
15329                let mut content_length = 0usize;
15330                loop {
15331                    line.clear();
15332                    reader.read_line(&mut line).unwrap();
15333                    if line == "\r\n" || line == "\n" || line.is_empty() {
15334                        break;
15335                    }
15336                    if let Some((name, value)) = line.split_once(':') {
15337                        if name.eq_ignore_ascii_case("content-length") {
15338                            content_length = value.trim().parse().unwrap();
15339                        }
15340                    }
15341                }
15342                let mut request_body = vec![0_u8; content_length];
15343                reader.read_exact(&mut request_body).unwrap();
15344                let response = format!(
15345                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15346                    body.len()
15347                );
15348                reader.get_mut().write_all(response.as_bytes()).unwrap();
15349            }
15350        });
15351        (url, handle)
15352    }
15353
15354    fn routed_json_hub(
15355        requests: usize,
15356        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15357    ) -> (String, std::thread::JoinHandle<()>) {
15358        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15359        use std::net::TcpListener;
15360
15361        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15362        let url = format!("http://{}", listener.local_addr().unwrap());
15363        let handle = std::thread::spawn(move || {
15364            for _ in 0..requests {
15365                let (stream, _) = listener.accept().unwrap();
15366                let mut reader = BufReader::new(stream);
15367                let mut line = String::new();
15368                reader.read_line(&mut line).unwrap();
15369                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15370                let mut content_length = 0usize;
15371                loop {
15372                    line.clear();
15373                    reader.read_line(&mut line).unwrap();
15374                    if line == "\r\n" || line == "\n" || line.is_empty() {
15375                        break;
15376                    }
15377                    if let Some((name, value)) = line.split_once(':') {
15378                        if name.eq_ignore_ascii_case("content-length") {
15379                            content_length = value.trim().parse().unwrap();
15380                        }
15381                    }
15382                }
15383                let mut request_body = vec![0_u8; content_length];
15384                reader.read_exact(&mut request_body).unwrap();
15385                let (status, body) = respond(&path);
15386                let response = format!(
15387                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15388                    body.len()
15389                );
15390                reader.get_mut().write_all(response.as_bytes()).unwrap();
15391            }
15392        });
15393        (url, handle)
15394    }
15395
15396    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15397        HubConfig {
15398            hub,
15399            key: Some("test-key".to_string()),
15400            agent_key: None,
15401            brain_key: None,
15402            state_dir,
15403            store_selected: false,
15404        }
15405    }
15406
15407    #[test]
15408    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15409        use ring::signature::KeyPair as _;
15410
15411        let rng = ring::rand::SystemRandom::new();
15412        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15413        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15414        let (spki, multikey) = public_identity_for(&pair);
15415        let key = AgentSigningKey {
15416            pkcs8: pkcs8.as_ref().to_vec(),
15417            multikey,
15418            public_key_spki: spki,
15419        };
15420        let header = linkmd_sig_header(
15421            &key,
15422            "https://hub-a.example",
15423            "post",
15424            "/api/hub/brains/brain/push?mode=exact",
15425            Some("{\"ok\":true}"),
15426        )
15427        .unwrap();
15428        assert!(header.starts_with("LinkMD-Sig v2,"));
15429        let ts = header
15430            .split(",ts=")
15431            .nth(1)
15432            .unwrap()
15433            .split(',')
15434            .next()
15435            .unwrap();
15436        let signature = URL_SAFE_NO_PAD
15437            .decode(header.rsplit(",sig=").next().unwrap())
15438            .unwrap();
15439        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15440        let accepted = format!(
15441            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15442        );
15443        let replayed = format!(
15444            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15445        );
15446        let public = pair.public_key().as_ref();
15447        assert!(UnparsedPublicKey::new(&ED25519, public)
15448            .verify(accepted.as_bytes(), &signature)
15449            .is_ok());
15450        assert!(
15451            UnparsedPublicKey::new(&ED25519, public)
15452                .verify(replayed.as_bytes(), &signature)
15453                .is_err(),
15454            "a proof captured at hub A must not authenticate at hub B"
15455        );
15456    }
15457
15458    #[test]
15459    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15460        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15461        let card = json!({
15462            "id": other,
15463            "headSeq": 0,
15464            "identity": signed_remote_fixture().identity,
15465        })
15466        .to_string();
15467        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15468        let state = tempfile::tempdir().unwrap();
15469        let cfg = test_hub_config(hub, state.path().to_path_buf());
15470        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15471        assert!(
15472            error.contains("differs from the explicitly requested"),
15473            "{error}"
15474        );
15475        server.join().unwrap();
15476    }
15477
15478    #[test]
15479    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
15480        let first = signed_remote_fixture().identity;
15481        let second = signed_remote_fixture().identity;
15482        let card = |identity: FeedIdentity| {
15483            json!({
15484                "id": TEST_BRAIN_ID,
15485                "headSeq": 0,
15486                "identity": identity,
15487            })
15488            .to_string()
15489        };
15490        let (hub, server) = scripted_json_hub(vec![
15491            (404, "{}".to_string()),
15492            (200, card(first)),
15493            (404, "{}".to_string()),
15494            (200, card(second)),
15495        ]);
15496        let state = tempfile::tempdir().unwrap();
15497        let cfg = test_hub_config(hub, state.path().to_path_buf());
15498        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15499        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15500        assert!(
15501            error.contains("pinned anchor") || error.contains("forked away"),
15502            "{error}"
15503        );
15504        server.join().unwrap();
15505    }
15506
15507    #[test]
15508    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
15509        let old = signed_remote_fixture();
15510        let new = signed_remote_fixture();
15511        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
15512        let unsigned = serde_json::to_string(&UnsignedRotation {
15513            v: 1,
15514            op: "rotate",
15515            brain: &old.key.multikey,
15516            public_key: &old.key.public_key_spki,
15517            new_brain: &new.key.multikey,
15518            new_public_key: &new.key.public_key_spki,
15519            prior_head_seq: 1,
15520            prior_feed_hash: Some(&"a".repeat(64)),
15521            ts: "2026-07-30T12:00:00.000Z".to_string(),
15522        })
15523        .unwrap();
15524        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
15525        let rotation = format!(
15526            "{},\"sig\":\"{}\"}}",
15527            &unsigned[..unsigned.len() - 1],
15528            signature
15529        );
15530        let identity = FeedIdentity {
15531            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
15532            public_key_spki: new.key.public_key_spki,
15533            previous: vec![PreviousIdentity {
15534                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
15535                public_key_spki: old.key.public_key_spki,
15536            }],
15537            rotations: vec![rotation],
15538        };
15539        let card = json!({
15540            "id": TEST_BRAIN_ID,
15541            "headSeq": 0,
15542            "feedHash": null,
15543            "identity": identity,
15544        })
15545        .to_string();
15546        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15547        let state = tempfile::tempdir().unwrap();
15548        let cfg = test_hub_config(hub, state.path().to_path_buf());
15549        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15550        assert!(
15551            error.contains("rotation claims a feed boundary beyond the advertised head"),
15552            "{error}"
15553        );
15554        assert!(
15555            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
15556            "an inconsistent empty-head identity must not become the TOFU checkpoint"
15557        );
15558        server.join().unwrap();
15559    }
15560
15561    #[test]
15562    fn trust_checkpoint_rejects_a_later_fork() {
15563        let fixture = signed_remote_fixture();
15564        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
15565        fork["feedHash"] = Value::String("b".repeat(64));
15566        let (hub, server) = scripted_json_hub(vec![
15567            (404, "{}".to_string()),
15568            (200, fixture.card),
15569            (200, fixture.feed),
15570            (404, "{}".to_string()),
15571            (200, fork.to_string()),
15572        ]);
15573        let state = tempfile::tempdir().unwrap();
15574        let cfg = test_hub_config(hub, state.path().to_path_buf());
15575        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15576        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
15577        server.join().unwrap();
15578    }
15579
15580    #[test]
15581    fn alias_and_canonical_id_share_one_identity_checkpoint() {
15582        let trusted = signed_remote_fixture();
15583        let attacker = signed_remote_fixture();
15584        let (hub, server) = scripted_json_hub(vec![
15585            (404, "{}".to_string()),
15586            (200, trusted.card),
15587            (200, trusted.feed),
15588            (404, "{}".to_string()),
15589            (200, attacker.card),
15590        ]);
15591        let state = tempfile::tempdir().unwrap();
15592        let cfg = test_hub_config(hub, state.path().to_path_buf());
15593        assert!(head(&cfg, "trusted-slug").unwrap().verified);
15594        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15595        assert!(
15596            error.contains("equivocation")
15597                || error.contains("pinned")
15598                || error.contains("identity"),
15599            "{error}"
15600        );
15601        server.join().unwrap();
15602    }
15603
15604    #[test]
15605    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
15606        let state = tempfile::tempdir().unwrap();
15607        let cfg = test_hub_config(
15608            "https://hub.example".to_string(),
15609            state.path().to_path_buf(),
15610        );
15611        let directory = open_trust_dir(&cfg).unwrap();
15612        let old = TEST_BRAIN_ID;
15613        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15614        save_alias_in(
15615            &cfg,
15616            &directory,
15617            &AliasBinding {
15618                v: 1,
15619                origin: normalized_origin(&cfg.hub).unwrap(),
15620                requested: "company-brain".to_string(),
15621                brain: old.to_string(),
15622                home: Some("company-brain".to_string()),
15623            },
15624        )
15625        .unwrap();
15626
15627        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
15628        assert!(matches!(
15629            error,
15630            LinkError::AliasRebindRequired {
15631                alias,
15632                from,
15633                to
15634            } if alias == "company-brain" && from == old && to == new
15635        ));
15636        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
15637            .unwrap()
15638            .unwrap();
15639        assert_eq!(unchanged.brain, old);
15640        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
15641    }
15642
15643    #[test]
15644    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
15645        let alpha = signed_remote_fixture();
15646        let beta = signed_remote_fixture();
15647        let alpha_card = alpha.card.clone();
15648        let alpha_feed = alpha.feed.clone();
15649        let beta_card = beta.card.clone();
15650        let beta_feed = beta.feed.clone();
15651        let (hub, server) = routed_json_hub(5, move |path| {
15652            if path.ends_with("/v2/head") {
15653                (404, "{}".to_string())
15654            } else if path.contains("/alpha/feed?") {
15655                (200, alpha_feed.clone())
15656            } else if path.contains("/beta/feed?") {
15657                (200, beta_feed.clone())
15658            } else if path.ends_with("/alpha") {
15659                (200, alpha_card.clone())
15660            } else if path.ends_with("/beta") {
15661                (200, beta_card.clone())
15662            } else {
15663                (500, r#"{"error":"unexpected path"}"#.to_string())
15664            }
15665        });
15666        let state = tempfile::tempdir().unwrap();
15667        let cfg = test_hub_config(hub, state.path().to_path_buf());
15668        let alpha_cfg = cfg.clone();
15669        let beta_cfg = cfg;
15670        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
15671        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
15672        let results = [first.join().unwrap(), second.join().unwrap()];
15673        assert_eq!(
15674            results.iter().filter(|result| result.is_ok()).count(),
15675            1,
15676            "only one alias identity may establish canonical TOFU: {results:?}"
15677        );
15678        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
15679        server.join().unwrap();
15680    }
15681
15682    #[cfg(unix)]
15683    #[test]
15684    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
15685        use std::os::unix::fs::symlink;
15686
15687        let fixture = signed_remote_fixture();
15688        let card = json!({
15689            "id": TEST_BRAIN_ID,
15690            "headSeq": 0,
15691            "feedHash": Value::Null,
15692            "identity": fixture.identity,
15693        })
15694        .to_string();
15695        let work = tempfile::tempdir().unwrap();
15696        let outside = tempfile::tempdir().unwrap();
15697        let state = work.path().join("state");
15698        let moved = work.path().join("state-held");
15699        let swap_state = state.clone();
15700        let swap_moved = moved.clone();
15701        let outside_path = outside.path().to_path_buf();
15702        let (hub, server) = routed_json_hub(1, move |_| {
15703            // The client has already opened state/trust before this response.
15704            std::fs::rename(&swap_state, &swap_moved).unwrap();
15705            symlink(&outside_path, &swap_state).unwrap();
15706            (200, card.clone())
15707        });
15708        let cfg = test_hub_config(hub, state);
15709
15710        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
15711        assert_eq!(verified.head.seq, 0);
15712        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
15713        assert!(std::fs::read_dir(moved.join("trust"))
15714            .unwrap()
15715            .flatten()
15716            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
15717        server.join().unwrap();
15718    }
15719
15720    #[test]
15721    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
15722        let remote = signed_remote_fixture();
15723        let unrelated = signed_remote_fixture().key;
15724        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
15725        let state = tempfile::tempdir().unwrap();
15726        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
15727        cfg.brain_key = Some(unrelated);
15728        let error = sync_push(
15729            &cfg,
15730            TEST_BRAIN_ID,
15731            &[("DB.md".to_string(), "signed local content".to_string())],
15732        )
15733        .unwrap_err()
15734        .to_string();
15735        assert!(
15736            error.contains("not the verified current brain identity"),
15737            "{error}"
15738        );
15739        server.join().unwrap();
15740    }
15741
15742    #[test]
15743    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
15744        let remote = signed_remote_fixture();
15745        let new = signed_remote_fixture().key;
15746        let state = tempfile::tempdir().unwrap();
15747        let new_file = state.path().join("new.key");
15748        std::fs::write(
15749            &new_file,
15750            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
15751        )
15752        .unwrap();
15753        #[cfg(unix)]
15754        {
15755            use std::os::unix::fs::PermissionsExt as _;
15756            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
15757        }
15758        let forged = json!({
15759            "brain": TEST_BRAIN_ID,
15760            "identity": {
15761                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
15762                "publicKeySpki": new.public_key_spki,
15763            }
15764        })
15765        .to_string();
15766        let (hub, server) = scripted_json_hub(vec![
15767            (404, "{}".to_string()),
15768            (200, remote.card.clone()),
15769            (200, remote.feed.clone()),
15770            (200, forged),
15771            (200, remote.card),
15772            (200, remote.feed),
15773        ]);
15774        let cfg = test_hub_config(hub, state.path().to_path_buf());
15775        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
15776            .unwrap_err()
15777            .to_string();
15778        assert!(
15779            error.contains("without committing the verified new identity"),
15780            "{error}"
15781        );
15782        server.join().unwrap();
15783    }
15784
15785    #[test]
15786    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
15787        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15788        let raw = format!(
15789            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15790        );
15791        let pack = build_store_pack(&[
15792            (
15793                "DB.md".to_string(),
15794                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
15795            ),
15796            ("records/clients/truth.md".to_string(), raw.clone()),
15797        ])
15798        .unwrap();
15799        let by_id = resolve_from_verified_pack(
15800            "01j5qc3v9k4ym8rwbn2tqe6f7d",
15801            &AddressTarget::Id(record_id.to_string()),
15802            pack.clone(),
15803        )
15804        .unwrap();
15805        assert_eq!(by_id["document"]["summary"], "Signed truth");
15806        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
15807        assert_eq!(
15808            by_id["document"]["contentSha"],
15809            content_sha256(raw.as_bytes())
15810        );
15811
15812        let by_path = resolve_from_verified_pack(
15813            "01j5qc3v9k4ym8rwbn2tqe6f7d",
15814            &AddressTarget::Path("records/clients/truth.md".to_string()),
15815            pack,
15816        )
15817        .unwrap();
15818        assert_eq!(by_path["document"]["id"], record_id);
15819        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
15820
15821        let wrong_id = resolve_from_verified_record_bytes(
15822            TEST_BRAIN_ID,
15823            &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
15824            "records/clients/truth.md".to_string(),
15825            raw.as_bytes().to_vec(),
15826        )
15827        .unwrap_err()
15828        .to_string();
15829        assert!(wrong_id.contains("id differs"), "{wrong_id}");
15830
15831        let wrong_path = resolve_from_verified_record_bytes(
15832            TEST_BRAIN_ID,
15833            &AddressTarget::Path("records/clients/other.md".to_string()),
15834            "records/clients/truth.md".to_string(),
15835            raw.into_bytes(),
15836        )
15837        .unwrap_err()
15838        .to_string();
15839        assert!(wrong_path.contains("path differs"), "{wrong_path}");
15840    }
15841
15842    #[test]
15843    fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
15844        let path = "records/clients/truth.md";
15845        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15846        let raw = format!(
15847            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15848        );
15849        let sha256 = content_sha256(raw.as_bytes());
15850        let mut nonce = 0_u128;
15851        let tree = crate::linkmd_v2::build_content_tree(
15852            &[crate::linkmd_v2::ContentFile {
15853                path: path.to_string(),
15854                blob_hash: sha256.clone(),
15855                bytes: raw.len() as u64,
15856            }],
15857            None,
15858            &mut || {
15859                nonce += 1;
15860                format!("{nonce:032x}")
15861            },
15862        )
15863        .unwrap();
15864        let root = tree.root.clone().unwrap();
15865        let mut directory_root = root.clone();
15866        let mut proof = Vec::new();
15867        for component in path.split('/') {
15868            let inclusion =
15869                crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
15870            let child = match &inclusion {
15871                crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
15872                crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
15873                    panic!("fixture path must have an inclusion proof")
15874                }
15875            };
15876            proof.push(json!({
15877                "directory_root": directory_root,
15878                "component": component,
15879                "proof": inclusion,
15880            }));
15881            directory_root = child;
15882        }
15883        let commit_hash = "c".repeat(64);
15884        let pointer = V2PointerBody {
15885            v: 2,
15886            brain: TEST_BRAIN_ID.to_string(),
15887            seq: 1,
15888            commit_hash: commit_hash.clone(),
15889            feed_hash: "f".repeat(64),
15890            content_root: Some(root.clone()),
15891            asset_root: None,
15892            materializer: "dbmd-projection-v1".to_string(),
15893            signer_epoch: 1,
15894            control_revision: "d".repeat(64),
15895            backup_preparation: "e".repeat(64),
15896            prior_pointer_hash: None,
15897            signed_at: "2026-08-21T12:00:00.000Z".to_string(),
15898        };
15899        let manifest = json!({
15900            "v": 2,
15901            "commit": commit_hash,
15902            "content_root": root,
15903            "files": [{
15904                "path": path,
15905                "sha256": sha256,
15906                "bytes": raw.len(),
15907                "proof": proof,
15908            }],
15909            "next_cursor": Value::Null,
15910        })
15911        .to_string();
15912
15913        let path_manifest = manifest.clone();
15914        let (hub, server) = routed_json_hub(1, move |request| {
15915            assert_eq!(
15916                request,
15917                format!(
15918                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
15919                    "c".repeat(64)
15920                )
15921            );
15922            (200, path_manifest.clone())
15923        });
15924        let state = tempfile::tempdir().unwrap();
15925        let cfg = test_hub_config(hub, state.path().to_path_buf());
15926        let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
15927            .unwrap()
15928            .unwrap();
15929        assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
15930        assert!(by_path.proof.is_some());
15931        server.join().unwrap();
15932
15933        let id_manifest = manifest;
15934        let (hub, server) = routed_json_hub(1, move |request| {
15935            assert_eq!(
15936                request,
15937                format!(
15938                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
15939                    "c".repeat(64)
15940                )
15941            );
15942            (200, id_manifest.clone())
15943        });
15944        let state = tempfile::tempdir().unwrap();
15945        let cfg = test_hub_config(hub, state.path().to_path_buf());
15946        let (located_path, by_id) =
15947            v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
15948        assert_eq!(located_path, path);
15949        assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
15950        server.join().unwrap();
15951    }
15952
15953    #[test]
15954    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
15955        let unsorted = vec![
15956            ("records/a.md".to_string(), "alpha\n".to_string()),
15957            ("DB.md".to_string(), "# db\n".to_string()),
15958        ];
15959        let sorted = vec![
15960            ("DB.md".to_string(), "# db\n".to_string()),
15961            ("records/a.md".to_string(), "alpha\n".to_string()),
15962        ];
15963        let pack = build_store_pack(&unsorted).unwrap();
15964
15965        // This digest is shared with the hub implementation. It locks every
15966        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
15967        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
15968        // absence of descriptors/extras/comments/ZIP64.
15969        assert_eq!(pack.len(), 219);
15970        assert_eq!(
15971            content_sha256(&pack),
15972            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
15973        );
15974        assert_eq!(pack, build_store_pack(&sorted).unwrap());
15975        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
15976        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
15977        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
15978
15979        assert_eq!(
15980            parse_store_pack(pack).unwrap(),
15981            vec![
15982                ("DB.md".to_string(), b"# db\n".to_vec()),
15983                ("records/a.md".to_string(), b"alpha\n".to_vec()),
15984            ]
15985        );
15986    }
15987
15988    #[test]
15989    fn canonical_store_pack_validates_every_path_before_writing() {
15990        let duplicate = vec![
15991            ("DB.md".to_string(), "first".to_string()),
15992            ("DB.md".to_string(), "second".to_string()),
15993        ];
15994        assert!(build_store_pack(&duplicate)
15995            .unwrap_err()
15996            .to_string()
15997            .contains("duplicate path"));
15998        assert!(matches!(
15999            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16000            Err(LinkError::UnsafePath { .. })
16001        ));
16002    }
16003
16004    #[test]
16005    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16006        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16007        // One byte stands in for the central directory. The trailer itself is
16008        // structurally valid; the count is the sole reason for refusal.
16009        let mut bytes = vec![0_u8];
16010        let zip64_offset = bytes.len() as u64;
16011        bytes.extend_from_slice(b"PK\x06\x06");
16012        bytes.extend_from_slice(&44_u64.to_le_bytes());
16013        bytes.extend_from_slice(&[0_u8; 12]);
16014        bytes.extend_from_slice(&COUNT.to_le_bytes());
16015        bytes.extend_from_slice(&COUNT.to_le_bytes());
16016        bytes.extend_from_slice(&1_u64.to_le_bytes());
16017        bytes.extend_from_slice(&0_u64.to_le_bytes());
16018        bytes.extend_from_slice(b"PK\x06\x07");
16019        bytes.extend_from_slice(&0_u32.to_le_bytes());
16020        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16021        bytes.extend_from_slice(&1_u32.to_le_bytes());
16022        bytes.extend_from_slice(b"PK\x05\x06");
16023        bytes.extend_from_slice(&0_u16.to_le_bytes());
16024        bytes.extend_from_slice(&0_u16.to_le_bytes());
16025        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16026        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16027        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16028        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16029        bytes.extend_from_slice(&0_u16.to_le_bytes());
16030
16031        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16032            .unwrap_err()
16033            .to_string();
16034        assert!(error.contains("invalid file count"), "{error}");
16035    }
16036
16037    #[test]
16038    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16039        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16040        let mut bytes = vec![0_u8];
16041        let zip64_offset = bytes.len() as u64;
16042        bytes.extend_from_slice(b"PK\x06\x06");
16043        bytes.extend_from_slice(&44_u64.to_le_bytes());
16044        bytes.extend_from_slice(&[0_u8; 12]);
16045        bytes.extend_from_slice(&COUNT.to_le_bytes());
16046        bytes.extend_from_slice(&COUNT.to_le_bytes());
16047        bytes.extend_from_slice(&1_u64.to_le_bytes());
16048        bytes.extend_from_slice(&0_u64.to_le_bytes());
16049        bytes.extend_from_slice(b"PK\x06\x07");
16050        bytes.extend_from_slice(&0_u32.to_le_bytes());
16051        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16052        bytes.extend_from_slice(&1_u32.to_le_bytes());
16053        bytes.extend_from_slice(b"PK\x05\x06");
16054        bytes.extend_from_slice(&0_u16.to_le_bytes());
16055        bytes.extend_from_slice(&0_u16.to_le_bytes());
16056        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16057        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16058        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16059        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16060        bytes.extend_from_slice(&0_u16.to_le_bytes());
16061        // The old parser trusted this last low-count signature, while
16062        // ZipArchive fell back to the real Zip64 directory and allocated for
16063        // COUNT entries. Its central-directory offsets are deliberately fake.
16064        let fake_eocd = bytes.len() as u32;
16065        bytes.extend_from_slice(b"PK\x05\x06");
16066        bytes.extend_from_slice(&0_u16.to_le_bytes());
16067        bytes.extend_from_slice(&0_u16.to_le_bytes());
16068        bytes.extend_from_slice(&1_u16.to_le_bytes());
16069        bytes.extend_from_slice(&1_u16.to_le_bytes());
16070        bytes.extend_from_slice(&0_u32.to_le_bytes());
16071        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16072        bytes.extend_from_slice(&0_u16.to_le_bytes());
16073
16074        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16075            .unwrap_err()
16076            .to_string();
16077        assert!(error.contains("central directory"), "{error}");
16078    }
16079
16080    #[test]
16081    fn strict_http_status_handling_rejects_redirects_without_panicking() {
16082        let error = ensure_ok(
16083            HubResponse {
16084                status: 302,
16085                body: Some(json!({"redirect": "/elsewhere"})),
16086            },
16087            "mutation",
16088        )
16089        .unwrap_err();
16090        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16091
16092        let error = ensure_raw_ok(
16093            RawHubResponse {
16094                status: 302,
16095                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16096            },
16097            "feed",
16098        )
16099        .unwrap_err();
16100        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16101    }
16102
16103    #[cfg(unix)]
16104    #[test]
16105    fn collect_push_files_refuses_external_symlink_and_nested_store() {
16106        use std::os::unix::fs::symlink;
16107
16108        let root = tempfile::tempdir().unwrap();
16109        std::fs::write(
16110            root.path().join("DB.md"),
16111            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16112        )
16113        .unwrap();
16114        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16115
16116        let external = tempfile::tempdir().unwrap();
16117        let secret = external.path().join("secret.md");
16118        std::fs::write(&secret, "TOP SECRET").unwrap();
16119        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16120
16121        let store = Store::open_strict(root.path()).unwrap();
16122        let err = collect_push_files(&store).unwrap_err().to_string();
16123        assert!(err.contains("cannot push"), "{err}");
16124        assert!(
16125            !err.contains("TOP SECRET"),
16126            "external bytes must never leak"
16127        );
16128
16129        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16130        let nested = root.path().join("records/nested");
16131        std::fs::create_dir_all(&nested).unwrap();
16132        std::fs::write(
16133            nested.join("DB.md"),
16134            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16135        )
16136        .unwrap();
16137        let err = collect_push_files(&store).unwrap_err().to_string();
16138        assert!(err.contains("nested db.md store"), "{err}");
16139    }
16140
16141    #[cfg(unix)]
16142    #[test]
16143    fn remote_push_uses_opened_root_after_path_replacement() {
16144        use std::os::unix::fs::symlink;
16145
16146        let sandbox = tempfile::tempdir().unwrap();
16147        let root = sandbox.path().join("store");
16148        std::fs::create_dir_all(root.join("records/notes")).unwrap();
16149        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16150        std::fs::write(
16151            root.join("records/notes/owned.md"),
16152            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16153        )
16154        .unwrap();
16155        let store = Store::open_strict(&root).unwrap();
16156        let detached = sandbox.path().join("detached");
16157        std::fs::rename(&root, &detached).unwrap();
16158
16159        let replacement = sandbox.path().join("replacement");
16160        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16161        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16162        std::fs::write(
16163            replacement.join("records/notes/secret.md"),
16164            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16165        )
16166        .unwrap();
16167        symlink(&replacement, &root).unwrap();
16168
16169        let files = collect_push_files(&store).unwrap();
16170        let wire_text = files
16171            .iter()
16172            .map(|(path, content)| format!("{path}\n{content}"))
16173            .collect::<Vec<_>>()
16174            .join("\n");
16175        assert!(wire_text.contains("owned upload"));
16176        assert!(!wire_text.contains("replacement sentinel"));
16177        assert!(!wire_text.contains("records/notes/secret.md"));
16178
16179        let remote = signed_remote_fixture();
16180        let (hub, server) = scripted_json_hub(vec![
16181            (200, remote.card),
16182            (200, remote.feed),
16183            (200, json!({"ok": true}).to_string()),
16184        ]);
16185        let state = tempfile::tempdir().unwrap();
16186        let cfg = test_hub_config(hub, state.path().to_path_buf());
16187        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16188        assert_eq!(pushed, json!({"ok": true}));
16189        server.join().unwrap();
16190    }
16191
16192    #[test]
16193    fn signed_feed_item_verifies_identity_hash_and_signature() {
16194        use ring::rand::SystemRandom;
16195        use ring::signature::{Ed25519KeyPair, KeyPair};
16196
16197        const PREFIX: &[u8] = &[
16198            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16199        ];
16200        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16201        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16202        let mut spki = PREFIX.to_vec();
16203        spki.extend_from_slice(pair.public_key().as_ref());
16204        let public_key = URL_SAFE_NO_PAD.encode(&spki);
16205        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16206        let mut entry = FeedEntry {
16207            v: 1,
16208            seq: 1,
16209            ts: "2026-07-14T00:00:00.000Z".to_string(),
16210            brain: format!("ed25519:{fingerprint}"),
16211            public_key: public_key.clone(),
16212            kind: "push".to_string(),
16213            op: "snapshot".to_string(),
16214            pack_sha256: "a".repeat(64),
16215            files: vec![FeedFile {
16216                path: "DB.md".to_string(),
16217                sha256: "b".repeat(64),
16218                bytes: 3,
16219            }],
16220            removed: vec![],
16221            prev_entry_hash: None,
16222            sig: String::new(),
16223        };
16224        let unsigned = UnsignedFeedEntry {
16225            v: entry.v,
16226            seq: entry.seq,
16227            ts: &entry.ts,
16228            brain: &entry.brain,
16229            public_key: &entry.public_key,
16230            kind: &entry.kind,
16231            op: &entry.op,
16232            pack_sha256: &entry.pack_sha256,
16233            files: &entry.files,
16234            removed: &entry.removed,
16235            prev_entry_hash: &entry.prev_entry_hash,
16236        };
16237        entry.sig =
16238            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16239        let mut exact = serde_json::to_vec(&entry).unwrap();
16240        exact.push(b'\n');
16241        let item = FeedItem {
16242            hash: format!("{:x}", Sha256::digest(&exact)),
16243            entry,
16244        };
16245        let identity = FeedIdentity {
16246            fingerprint,
16247            public_key_spki: public_key,
16248            previous: Vec::new(),
16249            rotations: Vec::new(),
16250        };
16251        assert!(verify_feed_item(&item, &identity).is_ok());
16252        let mut tampered = item;
16253        tampered.entry.pack_sha256 = "c".repeat(64);
16254        assert!(verify_feed_item(&tampered, &identity).is_err());
16255    }
16256
16257    #[test]
16258    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16259        let rng = ring::rand::SystemRandom::new();
16260        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16261        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16262        let (spki, multikey) = public_identity_for(&pair);
16263        let identity = V2HeadIdentity {
16264            custody: "self".to_string(),
16265            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16266            public_key_spki: spki.clone(),
16267            previous: Vec::new(),
16268            rotations: Vec::new(),
16269        };
16270        let unsigned = json!({
16271            "actor_ref": "a".repeat(64),
16272            "asset_root": Value::Null,
16273            "brain": multikey,
16274            "changes_sha256": "b".repeat(64),
16275            "control_revision": "c".repeat(64),
16276            "materializer": "dbmd-projection-v1",
16277            "op": "changeset",
16278            "parent_asset_root": Value::Null,
16279            "parent_commit": Value::Null,
16280            "parent_root": Value::Null,
16281            "prev_entry_hash": Value::Null,
16282            "public_key": spki,
16283            "seq": 1,
16284            "signer_epoch": 1,
16285            "state_root": "d".repeat(64),
16286            "ts": "2026-08-19T12:00:00.000Z",
16287            "v": 2,
16288            "v1_bridge": {
16289                "feed_hash": "e".repeat(64),
16290                "head_seq": 7,
16291                "pack_sha256": "f".repeat(64),
16292            },
16293        });
16294        let sign_value = |value: Value| {
16295            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16296            let mut object = value.as_object().unwrap().clone();
16297            object.insert(
16298                "sig".to_string(),
16299                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16300            );
16301            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16302        };
16303        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16304
16305        let mut extra = unsigned.clone();
16306        extra
16307            .as_object_mut()
16308            .unwrap()
16309            .insert("future".to_string(), Value::Bool(true));
16310        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16311
16312        let mut missing = unsigned.clone();
16313        missing.as_object_mut().unwrap().remove("v1_bridge");
16314        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16315
16316        let mut invalid_bridge = unsigned;
16317        invalid_bridge.as_object_mut().unwrap().insert(
16318            "v1_bridge".to_string(),
16319            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16320        );
16321        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16322    }
16323
16324    #[test]
16325    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16326        let vector: Value = serde_json::from_str(include_str!(
16327            "../tests/vectors/linkmd-v2-commit-bridge.json"
16328        ))
16329        .unwrap();
16330        let identity_value = vector.get("identity").unwrap();
16331        let identity = V2HeadIdentity {
16332            custody: "self".to_string(),
16333            fingerprint: identity_value
16334                .get("fingerprint")
16335                .and_then(Value::as_str)
16336                .unwrap()
16337                .to_string(),
16338            public_key_spki: identity_value
16339                .get("public_key_spki")
16340                .and_then(Value::as_str)
16341                .unwrap()
16342                .to_string(),
16343            previous: Vec::new(),
16344            rotations: Vec::new(),
16345        };
16346        let private = URL_SAFE_NO_PAD
16347            .decode(
16348                identity_value
16349                    .get("private_key_pkcs8")
16350                    .and_then(Value::as_str)
16351                    .unwrap(),
16352            )
16353            .unwrap();
16354        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16355            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16356            .unwrap();
16357        let base = vector.get("body").unwrap().as_object().unwrap();
16358
16359        for item in vector.get("valid").unwrap().as_array().unwrap() {
16360            let mut body = base.clone();
16361            body.insert(
16362                "v1_bridge".to_string(),
16363                item.get("v1_bridge").unwrap().clone(),
16364            );
16365            body.insert(
16366                "sig".to_string(),
16367                item.get("signature_base64url").unwrap().clone(),
16368            );
16369            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16370            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16371            assert_eq!(
16372                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16373                item.get("commit_hash").and_then(Value::as_str).unwrap()
16374            );
16375            assert_eq!(
16376                format!("{:x}", Sha256::digest(&signed)),
16377                item.get("feed_hash").and_then(Value::as_str).unwrap()
16378            );
16379        }
16380
16381        for item in vector.get("invalid").unwrap().as_array().unwrap() {
16382            let mut body = base.clone();
16383            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16384                for field in remove {
16385                    body.remove(field.as_str().unwrap());
16386                }
16387            }
16388            if let Some(set) = item.get("set").and_then(Value::as_object) {
16389                for (field, value) in set {
16390                    body.insert(field.clone(), value.clone());
16391                }
16392            }
16393            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16394            body.insert(
16395                "sig".to_string(),
16396                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16397            );
16398            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16399            assert!(
16400                verified_v2_commit_object(&signed, &identity).is_err(),
16401                "accepted invalid shared vector {}",
16402                item.get("reason").and_then(Value::as_str).unwrap()
16403            );
16404        }
16405    }
16406
16407    #[test]
16408    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16409        let vector: Value = serde_json::from_str(include_str!(
16410            "../tests/vectors/linkmd-v2-changeset-withheld.json"
16411        ))
16412        .unwrap();
16413        assert_eq!(
16414            vector.get("profile").and_then(Value::as_str),
16415            Some("link.md-v2-changeset-withheld")
16416        );
16417        let canonical =
16418            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16419        let expected = STANDARD
16420            .decode(
16421                vector
16422                    .get("canonical_base64")
16423                    .and_then(Value::as_str)
16424                    .unwrap(),
16425            )
16426            .unwrap();
16427        assert_eq!(canonical, expected);
16428        assert_eq!(
16429            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16430            vector.get("domain_hash").and_then(Value::as_str).unwrap()
16431        );
16432    }
16433
16434    #[test]
16435    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16436        let remote = signed_remote_fixture();
16437        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16438        let legacy_item = legacy.entries.first().unwrap();
16439        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16440        let body = json!({
16441            "actor_ref": "a".repeat(64),
16442            "asset_root": Value::Null,
16443            "brain": remote.key.multikey,
16444            "changes_sha256": "b".repeat(64),
16445            "control_revision": "c".repeat(64),
16446            "materializer": "dbmd-projection-v1",
16447            "op": "changeset",
16448            "parent_asset_root": Value::Null,
16449            "parent_commit": Value::Null,
16450            "parent_root": Value::Null,
16451            "prev_entry_hash": Value::Null,
16452            "public_key": remote.key.public_key_spki,
16453            "seq": 1,
16454            "signer_epoch": 1,
16455            "state_root": "d".repeat(64),
16456            "ts": "2026-08-19T12:00:00.000Z",
16457            "v": 2,
16458            "v1_bridge": {
16459                "feed_hash": legacy_item.hash,
16460                "head_seq": legacy_item.entry.seq,
16461                "pack_sha256": legacy_item.entry.pack_sha256,
16462            },
16463        });
16464        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
16465        let mut signed = body.as_object().unwrap().clone();
16466        signed.insert(
16467            "sig".to_string(),
16468            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16469        );
16470        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
16471        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
16472        let feed_hash = content_sha256(&raw);
16473        let pointer = V2PointerBody {
16474            v: 2,
16475            brain: TEST_BRAIN_ID.to_string(),
16476            seq: 1,
16477            commit_hash: commit_hash.clone(),
16478            feed_hash: feed_hash.clone(),
16479            content_root: Some("d".repeat(64)),
16480            asset_root: None,
16481            materializer: "dbmd-projection-v1".to_string(),
16482            signer_epoch: 1,
16483            control_revision: "c".repeat(64),
16484            backup_preparation: "e".repeat(64),
16485            prior_pointer_hash: None,
16486            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
16487        };
16488        let v2_page = json!({
16489            "v": 2,
16490            "head_seq": 1,
16491            "head_commit_hash": commit_hash,
16492            "head_feed_hash": feed_hash,
16493            "entries": [{
16494                "seq": 1,
16495                "commit_hash": pointer.commit_hash,
16496                "feed_hash": pointer.feed_hash,
16497                "bytes_base64": STANDARD.encode(&raw),
16498            }],
16499            "next_after": 1,
16500            "complete": true,
16501        })
16502        .to_string();
16503        let identity = V2HeadIdentity {
16504            custody: "self".to_string(),
16505            fingerprint: remote.identity.fingerprint.clone(),
16506            public_key_spki: remote.identity.public_key_spki.clone(),
16507            previous: Vec::new(),
16508            rotations: Vec::new(),
16509        };
16510        let checkpoint = TrustState {
16511            v: 2,
16512            origin: "unused".to_string(),
16513            requested: TEST_BRAIN_ID.to_string(),
16514            brain: TEST_BRAIN_ID.to_string(),
16515            home: None,
16516            anchor: remote.key.multikey.clone(),
16517            current: remote.key.multikey,
16518            head_seq: legacy_item.entry.seq,
16519            feed_hash: Some(legacy_item.hash.clone()),
16520            rotations: Vec::new(),
16521            hub_signer: None,
16522            protocol_profile: None,
16523        };
16524        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
16525        let state = tempfile::tempdir().unwrap();
16526        let cfg = test_hub_config(hub, state.path().to_path_buf());
16527        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
16528        server.join().unwrap();
16529
16530        let mut wrong = checkpoint;
16531        wrong.feed_hash = Some("0".repeat(64));
16532        let (hub, server) = scripted_json_hub(vec![(
16533            200,
16534            json!({
16535                "v": 2,
16536                "head_seq": 1,
16537                "head_commit_hash": pointer.commit_hash,
16538                "head_feed_hash": pointer.feed_hash,
16539                "entries": [{
16540                    "seq": 1,
16541                    "commit_hash": pointer.commit_hash,
16542                    "feed_hash": pointer.feed_hash,
16543                    "bytes_base64": STANDARD.encode(&raw),
16544                }],
16545                "next_after": 1,
16546                "complete": true,
16547            })
16548            .to_string(),
16549        )]);
16550        let state = tempfile::tempdir().unwrap();
16551        let cfg = test_hub_config(hub, state.path().to_path_buf());
16552        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
16553        server.join().unwrap();
16554    }
16555
16556    #[test]
16557    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
16558        let rng = ring::rand::SystemRandom::new();
16559        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16560        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16561        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16562        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16563        let (old_spki, old_multikey) = public_identity_for(&old);
16564        let (new_spki, new_multikey) = public_identity_for(&new);
16565        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
16566            v: 1,
16567            op: "rotate",
16568            brain: &old_multikey,
16569            public_key: &old_spki,
16570            new_brain: &new_multikey,
16571            new_public_key: &new_spki,
16572            prior_head_seq: 1,
16573            prior_feed_hash: Some(&"9".repeat(64)),
16574            ts: "2026-08-19T12:01:00.000Z".to_string(),
16575        })
16576        .unwrap();
16577        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
16578        let rotation = format!(
16579            "{},\"sig\":\"{}\"}}",
16580            &rotation_unsigned[..rotation_unsigned.len() - 1],
16581            rotation_sig
16582        );
16583        let identity = V2HeadIdentity {
16584            custody: "self".to_string(),
16585            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16586            public_key_spki: new_spki.clone(),
16587            previous: vec![V2PreviousIdentity {
16588                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16589                public_key_spki: old_spki.clone(),
16590            }],
16591            rotations: vec![rotation],
16592        };
16593        let commit = |seq: u64,
16594                      epoch: u64,
16595                      multikey: &str,
16596                      spki: &str,
16597                      pair: &ring::signature::Ed25519KeyPair| {
16598            let value = json!({
16599                "actor_ref": "a".repeat(64),
16600                "asset_root": Value::Null,
16601                "brain": multikey,
16602                "changes_sha256": "b".repeat(64),
16603                "control_revision": "c".repeat(64),
16604                "materializer": "dbmd-projection-v1",
16605                "op": "changeset",
16606                "parent_asset_root": Value::Null,
16607                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
16608                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
16609                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
16610                "public_key": spki,
16611                "seq": seq,
16612                "signer_epoch": epoch,
16613                "state_root": "1".repeat(64),
16614                "ts": "2026-08-19T12:00:00.000Z",
16615                "v": 2,
16616                "v1_bridge": Value::Null,
16617            });
16618            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16619            let mut object = value.as_object().unwrap().clone();
16620            object.insert(
16621                "sig".to_string(),
16622                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16623            );
16624            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16625        };
16626
16627        assert!(verified_v2_commit_object(
16628            &commit(1, 1, &old_multikey, &old_spki, &old),
16629            &identity,
16630        )
16631        .is_ok());
16632        assert!(verified_v2_commit_object(
16633            &commit(2, 2, &new_multikey, &new_spki, &new),
16634            &identity,
16635        )
16636        .is_ok());
16637        assert!(verified_v2_commit_object(
16638            &commit(2, 1, &old_multikey, &old_spki, &old),
16639            &identity,
16640        )
16641        .is_err());
16642        assert!(verified_v2_commit_object(
16643            &commit(1, 2, &new_multikey, &new_spki, &new),
16644            &identity,
16645        )
16646        .is_err());
16647    }
16648
16649    #[test]
16650    fn a_self_custody_entry_verifies_like_any_hub_entry() {
16651        let rng = ring::rand::SystemRandom::new();
16652        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16653        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16654        let (spki, multikey) = public_identity_for(&pair);
16655        let key = AgentSigningKey {
16656            pkcs8: pkcs8.as_ref().to_vec(),
16657            multikey: multikey.clone(),
16658            public_key_spki: spki.clone(),
16659        };
16660        let files = vec![WireFeedFile {
16661            path: "DB.md".to_string(),
16662            sha256: "a".repeat(64),
16663            bytes: 3,
16664        }];
16665        let raw = self_custody_entry(
16666            &key,
16667            1,
16668            "2026-07-23T12:00:00.000Z".to_string(),
16669            &"c".repeat(64),
16670            &files,
16671            None,
16672        )
16673        .unwrap();
16674        // The exact client serialization parses as a feed entry and passes the
16675        // SAME verifier every subscribe read runs — the self-custody path
16676        // produces first-class wire-profile-v1 entries.
16677        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
16678        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
16679        let item = FeedItem { hash, entry };
16680        let identity = FeedIdentity {
16681            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16682            public_key_spki: spki,
16683            previous: Vec::new(),
16684            rotations: Vec::new(),
16685        };
16686        assert!(verify_feed_item(&item, &identity).is_ok());
16687    }
16688
16689    #[test]
16690    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
16691        let rng = ring::rand::SystemRandom::new();
16692        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16693        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16694        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16695        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16696        let (old_spki, old_multikey) = public_identity_for(&old);
16697        let (new_spki, new_multikey) = public_identity_for(&new);
16698        let unsigned = serde_json::to_string(&UnsignedRotation {
16699            v: 1,
16700            op: "rotate",
16701            brain: &old_multikey,
16702            public_key: &old_spki,
16703            new_brain: &new_multikey,
16704            new_public_key: &new_spki,
16705            prior_head_seq: 1,
16706            prior_feed_hash: Some(&"a".repeat(64)),
16707            ts: "2026-07-30T12:00:00.000Z".to_string(),
16708        })
16709        .unwrap();
16710        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
16711        let rotation = format!(
16712            "{},\"sig\":\"{}\"}}",
16713            &unsigned[..unsigned.len() - 1],
16714            signature
16715        );
16716        let identity = FeedIdentity {
16717            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16718            public_key_spki: new_spki,
16719            previous: vec![PreviousIdentity {
16720                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16721                public_key_spki: old_spki,
16722            }],
16723            rotations: vec![rotation],
16724        };
16725        let pin = TrustState {
16726            v: 2,
16727            origin: "https://hub.example".to_string(),
16728            requested: "brain".to_string(),
16729            brain: "brain".to_string(),
16730            home: None,
16731            anchor: old_multikey.clone(),
16732            current: old_multikey.clone(),
16733            head_seq: 1,
16734            feed_hash: Some("a".repeat(64)),
16735            rotations: Vec::new(),
16736            hub_signer: None,
16737            protocol_profile: None,
16738        };
16739        assert_eq!(
16740            verify_identity_chain(&identity, Some(&pin)).unwrap(),
16741            old_multikey
16742        );
16743        let mut accepted = pin.clone();
16744        accepted.current = new_multikey.clone();
16745        accepted.rotations = identity.rotations.clone();
16746        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
16747            v: 1,
16748            op: "rotate",
16749            brain: &old_multikey,
16750            public_key: &identity.previous[0].public_key_spki,
16751            new_brain: &new_multikey,
16752            new_public_key: &identity.public_key_spki,
16753            prior_head_seq: 1,
16754            prior_feed_hash: Some(&"a".repeat(64)),
16755            ts: "2026-07-30T12:00:01.000Z".to_string(),
16756        })
16757        .unwrap();
16758        let alternate_signature =
16759            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
16760        let mut rewritten = identity.clone();
16761        rewritten.rotations[0] = format!(
16762            "{},\"sig\":\"{}\"}}",
16763            &alternate_unsigned[..alternate_unsigned.len() - 1],
16764            alternate_signature
16765        );
16766        assert!(
16767            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
16768            "an alternate valid statement must not rewrite accepted history"
16769        );
16770
16771        let mut stale_entry = FeedEntry {
16772            v: 1,
16773            seq: 2,
16774            ts: "2026-07-30T12:01:00.000Z".to_string(),
16775            brain: pin.current.clone(),
16776            public_key: identity.previous[0].public_key_spki.clone(),
16777            kind: "push".to_string(),
16778            op: "snapshot".to_string(),
16779            pack_sha256: "b".repeat(64),
16780            files: Vec::new(),
16781            removed: Vec::new(),
16782            prev_entry_hash: pin.feed_hash.clone(),
16783            sig: String::new(),
16784        };
16785        let stale_unsigned = UnsignedFeedEntry {
16786            v: stale_entry.v,
16787            seq: stale_entry.seq,
16788            ts: &stale_entry.ts,
16789            brain: &stale_entry.brain,
16790            public_key: &stale_entry.public_key,
16791            kind: &stale_entry.kind,
16792            op: &stale_entry.op,
16793            pack_sha256: &stale_entry.pack_sha256,
16794            files: &stale_entry.files,
16795            removed: &stale_entry.removed,
16796            prev_entry_hash: &stale_entry.prev_entry_hash,
16797        };
16798        stale_entry.sig = URL_SAFE_NO_PAD.encode(
16799            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
16800                .as_ref(),
16801        );
16802        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
16803        stale_exact.push(b'\n');
16804        let stale_item = FeedItem {
16805            hash: content_sha256(&stale_exact),
16806            entry: stale_entry,
16807        };
16808        assert!(
16809            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
16810                .is_err(),
16811            "a key retired before the checkpoint must never append after it"
16812        );
16813        assert!(
16814            verify_feed_item(&stale_item, &identity).is_err(),
16815            "an old key must never append after its signed rotation boundary"
16816        );
16817
16818        let mut missing = identity.clone();
16819        missing.rotations.clear();
16820        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
16821
16822        let mut tampered = identity;
16823        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
16824        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
16825    }
16826
16827    #[cfg(unix)]
16828    #[test]
16829    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
16830        use std::os::unix::fs::symlink;
16831
16832        let dir = tempfile::tempdir().unwrap();
16833        let target = dir.path().join("valuable.txt");
16834        let planted = dir.path().join("agent.key");
16835        std::fs::write(&target, "do not overwrite").unwrap();
16836        symlink(&target, &planted).unwrap();
16837
16838        assert!(matches!(
16839            generate_agent_key(&planted),
16840            Err(LinkError::BadAgentKey { .. })
16841        ));
16842        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
16843    }
16844
16845    #[cfg(unix)]
16846    #[test]
16847    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
16848        use std::os::unix::fs::symlink;
16849
16850        let root = tempfile::tempdir().unwrap();
16851        let outside = tempfile::tempdir().unwrap();
16852        symlink(outside.path(), root.path().join("redirect")).unwrap();
16853
16854        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
16855        assert!(!outside.path().join("agent.key").exists());
16856    }
16857
16858    // ── Address parsing ─────────────────────────────────────────────────────
16859
16860    #[test]
16861    fn address_bare_brain_with_and_without_sigil() {
16862        for raw in ["@acme-ops", "acme-ops"] {
16863            let a = Address::parse(raw).expect(raw);
16864            assert_eq!(a.brain, "acme-ops");
16865            assert_eq!(a.target, None);
16866        }
16867    }
16868
16869    #[test]
16870    fn address_ulid_target_parses_as_id() {
16871        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
16872        assert_eq!(a.brain, "acme");
16873        assert_eq!(
16874            a.target,
16875            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
16876        );
16877    }
16878
16879    #[test]
16880    fn address_md_path_target_parses_as_path() {
16881        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
16882        assert_eq!(
16883            a.target,
16884            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
16885        );
16886    }
16887
16888    #[test]
16889    fn address_rejects_malformed_forms() {
16890        for raw in [
16891            "",
16892            "@",
16893            "@/x",
16894            "@acme/",
16895            "@acme/../etc/passwd",
16896            "@acme/records/.hidden.md",
16897            "@ACME",             // uppercase is not a hub ref shape
16898            "@acme/notes/x.txt", // target is neither ULID nor .md path
16899            "@a b",              // whitespace
16900        ] {
16901            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
16902        }
16903    }
16904
16905    // ── Path safety ─────────────────────────────────────────────────────────
16906
16907    #[test]
16908    fn safe_paths_accept_store_shapes_and_reject_escapes() {
16909        for ok in [
16910            "DB.md",
16911            "assets.jsonl",
16912            "records/clients/lumio.md",
16913            "sources/emails/2026/07/x.md",
16914        ] {
16915            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
16916        }
16917        for bad in [
16918            "",
16919            "/etc/passwd",
16920            "../up.md",
16921            "records/../../up.md",
16922            "records//x.md",
16923            ".dbmd/config",
16924            "records/.hidden/x.md",
16925            "records/a b.md",
16926            "records\\win.md",
16927        ] {
16928            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
16929        }
16930    }
16931
16932    #[cfg(unix)]
16933    #[test]
16934    fn opened_destination_capability_survives_an_ancestor_path_swap() {
16935        use std::os::unix::fs::symlink;
16936
16937        let work = tempfile::tempdir().unwrap();
16938        let outside = tempfile::tempdir().unwrap();
16939        let original = work.path().join("destination");
16940        let moved = work.path().join("destination-moved");
16941        let directory = open_or_create_dir_nofollow(&original).unwrap();
16942
16943        std::fs::rename(&original, &moved).unwrap();
16944        symlink(outside.path(), &original).unwrap();
16945        write_pull_entries_beneath_dir(
16946            &directory,
16947            &[("records/note.md".to_string(), b"held inode".to_vec())],
16948        )
16949        .unwrap();
16950
16951        assert_eq!(
16952            std::fs::read(moved.join("records/note.md")).unwrap(),
16953            b"held inode"
16954        );
16955        assert!(!outside.path().join("records/note.md").exists());
16956    }
16957
16958    // ── Config resolution (flag + file precedence; env is covered by the CLI
16959    //    integration tests, where a child process isolates it) ───────────────
16960
16961    #[test]
16962    fn hub_config_flag_beats_file_and_requires_some_source() {
16963        let dir = tempfile::tempdir().unwrap();
16964        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
16965        std::fs::write(
16966            dir.path().join(CONFIG_REL_PATH),
16967            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
16968        )
16969        .unwrap();
16970
16971        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
16972        assert_eq!(from_flag.hub, "https://flag.example.com");
16973
16974        let from_file = hub_config(None, dir.path()).unwrap();
16975        assert_eq!(from_file.hub, "https://file.example.com");
16976
16977        let none = hub_config(None, tempfile::tempdir().unwrap().path());
16978        assert!(matches!(none, Err(LinkError::NoHub)));
16979    }
16980
16981    #[test]
16982    fn https_guard_allows_loopback_only_for_plain_http() {
16983        assert!(assert_safe_hub("https://hub.example.com").is_ok());
16984        assert!(assert_safe_hub("http://localhost:3000").is_ok());
16985        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
16986        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
16987        assert!(matches!(
16988            assert_safe_hub("http://hub.example.com"),
16989            Err(LinkError::UnsafeHub { .. })
16990        ));
16991        assert!(matches!(
16992            assert_safe_hub("hub.example.com"),
16993            Err(LinkError::UnsafeHub { .. })
16994        ));
16995        assert!(matches!(
16996            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
16997            Err(LinkError::UnsafeHub { .. })
16998        ));
16999        assert!(matches!(
17000            assert_safe_hub("https://hub.example.com@attacker.example"),
17001            Err(LinkError::UnsafeHub { .. })
17002        ));
17003        assert!(matches!(
17004            assert_safe_hub("https://hub.example.com/base"),
17005            Err(LinkError::UnsafeHub { .. })
17006        ));
17007    }
17008
17009    #[test]
17010    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17011        for blocked in [
17012            "127.0.0.1",
17013            "10.0.0.1",
17014            "100.64.0.1",
17015            "169.254.169.254",
17016            "172.16.0.1",
17017            "192.168.0.1",
17018            "192.88.99.1",
17019            "198.18.0.1",
17020            "203.0.113.1",
17021            "::1",
17022            "fe80::1",
17023            "fd00::1",
17024            "2001:db8::1",
17025            "2001:1::1",
17026            "2002:7f00:1::",
17027            "3fff::1",
17028        ] {
17029            assert!(
17030                !is_public_registry_ip(blocked.parse().unwrap()),
17031                "must block {blocked}"
17032            );
17033        }
17034        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17035        assert!(is_public_registry_ip(
17036            "2606:4700:4700::1111".parse().unwrap()
17037        ));
17038        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17039    }
17040
17041    #[test]
17042    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17043        use ureq::Resolver as _;
17044
17045        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17046        let resolver = PinnedRegistryResolver {
17047            netloc: "home.example:443".to_string(),
17048            addresses: vec![pinned],
17049        };
17050        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17051        assert!(resolver.resolve("127.0.0.1:443").is_err());
17052        assert_eq!(
17053            resolver.resolve("home.example:443").unwrap(),
17054            vec![pinned],
17055            "subsequent connects reuse the validated answer instead of DNS"
17056        );
17057    }
17058
17059    #[test]
17060    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17061        let cfg = HubConfig {
17062            hub: "https://hub.example".to_string(),
17063            key: None,
17064            agent_key: None,
17065            brain_key: None,
17066            state_dir: tempfile::tempdir().unwrap().keep(),
17067            store_selected: false,
17068        };
17069        assert!(
17070            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17071            "a production hub must not turn its presigned URL into an SSRF primitive"
17072        );
17073
17074        let store_selected = HubConfig {
17075            hub: "https://127.0.0.1".to_string(),
17076            store_selected: true,
17077            ..cfg
17078        };
17079        assert!(
17080            hub_agent(&store_selected).is_err(),
17081            "bytes in a cloned store must not select a private-network hub"
17082        );
17083    }
17084
17085    #[test]
17086    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17087        assert_eq!(
17088            one_past_bounded_limit(MAX_PACK_BYTES),
17089            Some(MAX_PACK_BYTES + 1),
17090            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17091        );
17092        assert_eq!(
17093            presigned_download_read_limit(),
17094            MAX_PACK_BYTES + 1,
17095            "the presigned reader is capped by the client constant, not a hub response"
17096        );
17097        assert_eq!(
17098            one_past_bounded_limit(u64::MAX),
17099            None,
17100            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17101        );
17102    }
17103
17104    #[test]
17105    fn https_guard_matches_the_scheme_case_insensitively() {
17106        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
17107        // hub is still HTTPS, never a misleading non-HTTPS refusal.
17108        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17109        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17110        // And an uppercase plain-HTTP hub is still refused outside loopback.
17111        assert!(matches!(
17112            assert_safe_hub("HTTP://hub.example.com"),
17113            Err(LinkError::UnsafeHub { .. })
17114        ));
17115    }
17116
17117    #[test]
17118    fn clean_key_refuses_paste_artifacts_without_echoing() {
17119        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
17120        for bad in ["vc account", "vc\naccount", "ключ", ""] {
17121            let err = clean_key(bad).unwrap_err();
17122            assert!(matches!(err, LinkError::BadKey));
17123            assert!(
17124                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17125                "error must not echo the key"
17126            );
17127        }
17128    }
17129
17130    // ── Verb entry gates: refs must never reshape the request path ──────────
17131
17132    /// A config whose hub passes the loopback guard but is never listened on:
17133    /// every refusal below must come from the entry gate BEFORE a request
17134    /// exists — a dial on this dead port would surface `Transport` instead.
17135    fn dead_hub() -> HubConfig {
17136        HubConfig {
17137            hub: "http://127.0.0.1:9".to_string(),
17138            key: Some("k".to_string()),
17139            agent_key: None,
17140            brain_key: None,
17141            state_dir: PathBuf::from("."),
17142            store_selected: false,
17143        }
17144    }
17145
17146    #[test]
17147    fn request_retries_a_connection_failure_before_sending() {
17148        use std::io::{Read as _, Write as _};
17149        use std::net::TcpListener;
17150        use std::thread;
17151        use std::time::Duration;
17152
17153        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17154        let address = probe.local_addr().unwrap();
17155        drop(probe);
17156        let server = thread::spawn(move || {
17157            thread::sleep(Duration::from_millis(40));
17158            let listener = TcpListener::bind(address).unwrap();
17159            let (mut stream, _) = listener.accept().unwrap();
17160            let mut request_bytes = [0_u8; 1024];
17161            let _ = stream.read(&mut request_bytes).unwrap();
17162            stream
17163                .write_all(
17164                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17165                )
17166                .unwrap();
17167        });
17168        let cfg = HubConfig {
17169            hub: format!("http://{address}"),
17170            key: None,
17171            agent_key: None,
17172            brain_key: None,
17173            state_dir: tempfile::tempdir().unwrap().keep(),
17174            store_selected: false,
17175        };
17176
17177        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17178        assert_eq!(response.status, 200);
17179        assert_eq!(response.body, Some(json!({ "ok": true })));
17180        server.join().unwrap();
17181    }
17182
17183    #[test]
17184    fn a_commit_goes_back_for_a_receipt_it_lost() {
17185        use std::io::{Read as _, Write as _};
17186        use std::net::TcpListener;
17187        use std::thread;
17188
17189        // The mutation coordinate is derived from the observed base and the
17190        // exact operation set so a repeat is the SAME effect. A commit that
17191        // lands durably and then loses its connection must therefore be asked
17192        // after, not reported as a failure — which is what happened to a
17193        // 32,338-file store that had already committed.
17194        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17195        let address = listener.local_addr().unwrap();
17196        let server = thread::spawn(move || {
17197            // First attempt: promise a body, hang up mid-way.
17198            let (mut first, _) = listener.accept().unwrap();
17199            let mut bytes = [0_u8; 4096];
17200            let _ = first.read(&mut bytes).unwrap();
17201            first
17202                .write_all(
17203                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17204                )
17205                .unwrap();
17206            drop(first);
17207            // Second attempt: the receipt for the commit that already landed.
17208            let (mut second, _) = listener.accept().unwrap();
17209            let _ = second.read(&mut bytes).unwrap();
17210            second
17211                .write_all(
17212                    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\"}",
17213                )
17214                .unwrap();
17215        });
17216        let cfg = HubConfig {
17217            hub: format!("http://{address}"),
17218            key: Some("k".to_string()),
17219            agent_key: None,
17220            brain_key: None,
17221            state_dir: tempfile::tempdir().unwrap().keep(),
17222            store_selected: false,
17223        };
17224
17225        let response = request_patient(
17226            &cfg,
17227            "POST",
17228            "/api/hub/brains/b/v2/commits",
17229            Some(&json!({ "mutation_id": "dbmd-1" })),
17230            Auth::Required,
17231        )
17232        .expect("the receipt is collected on the second ask");
17233        assert_eq!(response.status, 200);
17234        assert_eq!(
17235            response
17236                .body
17237                .as_ref()
17238                .and_then(|value| value.get("outcome"))
17239                .and_then(Value::as_str),
17240            Some("converged"),
17241            "an already-applied mutation answers with its receipt"
17242        );
17243        server.join().unwrap();
17244    }
17245
17246    #[test]
17247    fn a_body_that_dies_mid_stream_is_a_transport_failure() {
17248        use std::io::{Read as _, Write as _};
17249        use std::net::TcpListener;
17250        use std::thread;
17251
17252        // A connection that fails while the body is arriving is the same class
17253        // of problem as one that fails before it arrives at all. Typed as a
17254        // bare io error it matched no retry path and named no peer, and one
17255        // stalled body ended a whole migration reporting only "timed out
17256        // reading response".
17257        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17258        let address = listener.local_addr().unwrap();
17259        let server = thread::spawn(move || {
17260            let (mut stream, _) = listener.accept().unwrap();
17261            let mut request_bytes = [0_u8; 1024];
17262            let _ = stream.read(&mut request_bytes).unwrap();
17263            // Promise a hundred bytes, send ten, hang up.
17264            stream
17265                .write_all(
17266                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17267                )
17268                .unwrap();
17269        });
17270        let cfg = HubConfig {
17271            hub: format!("http://{address}"),
17272            key: None,
17273            agent_key: None,
17274            brain_key: None,
17275            state_dir: tempfile::tempdir().unwrap().keep(),
17276            store_selected: false,
17277        };
17278
17279        let error = request(&cfg, "GET", "/truncated", None, Auth::None)
17280            .expect_err("a truncated body must not read as success");
17281        match error {
17282            LinkError::Transport { hub, .. } => {
17283                assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17284            }
17285            other => panic!("expected a transport failure, got {other:?}"),
17286        }
17287        server.join().unwrap();
17288    }
17289
17290    #[test]
17291    fn object_store_transport_errors_never_render_presigned_urls() {
17292        use std::net::TcpListener;
17293
17294        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17295        let address = listener.local_addr().unwrap();
17296        drop(listener);
17297        let signature = "do-not-render-this-presigned-signature";
17298        let raw =
17299            format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
17300        let error = ureq::get(&raw)
17301            .timeout(std::time::Duration::from_millis(250))
17302            .call()
17303            .expect_err("the closed local port must fail");
17304        let ureq::Error::Transport(transport) = error else {
17305            panic!("expected a transport failure");
17306        };
17307
17308        let rendered = object_store_transport_error(transport).to_string();
17309        assert!(rendered.contains("the object store"));
17310        assert!(rendered.contains("network error"));
17311        assert!(!rendered.contains(&raw));
17312        assert!(!rendered.contains(signature));
17313        assert!(!rendered.contains("X-Amz-"));
17314    }
17315
17316    #[test]
17317    fn endpoint_cap_refuses_a_body_before_json_parsing() {
17318        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
17319        let cfg = HubConfig {
17320            hub,
17321            key: None,
17322            agent_key: None,
17323            brain_key: None,
17324            state_dir: tempfile::tempdir().unwrap().keep(),
17325            store_selected: false,
17326        };
17327
17328        assert!(matches!(
17329            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
17330            Err(LinkError::ResponseTooLarge { .. })
17331        ));
17332        server.join().unwrap();
17333    }
17334
17335    #[test]
17336    fn overall_deadline_stops_a_dribbled_response_body() {
17337        use std::io::{Read as _, Write as _};
17338        use std::net::TcpListener;
17339        use std::time::{Duration, Instant};
17340
17341        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17342        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
17343        let server = std::thread::spawn(move || {
17344            let (mut stream, _) = listener.accept().unwrap();
17345            let mut request = [0_u8; 1024];
17346            let _ = stream.read(&mut request);
17347            stream
17348                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
17349                .unwrap();
17350            for byte in [b'x'; 32] {
17351                if stream.write_all(&[byte]).is_err() {
17352                    break;
17353                }
17354                std::thread::sleep(Duration::from_millis(40));
17355            }
17356        });
17357        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17358        let started = Instant::now();
17359        let response = http.get(&url).call().unwrap();
17360        let mut body = Vec::new();
17361        let error = response
17362            .into_reader()
17363            .read_to_end(&mut body)
17364            .expect_err("per-read progress must not reset the overall deadline");
17365        assert!(
17366            started.elapsed() < Duration::from_millis(700),
17367            "dribbled body exceeded the wall-clock budget: {error}"
17368        );
17369        server.join().unwrap();
17370    }
17371
17372    #[test]
17373    fn overall_deadline_stops_a_stalled_upload() {
17374        use std::net::TcpListener;
17375        use std::time::{Duration, Instant};
17376
17377        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17378        let url = format!("http://{}/upload", listener.local_addr().unwrap());
17379        let server = std::thread::spawn(move || {
17380            let (_stream, _) = listener.accept().unwrap();
17381            // Never consume the request body. Once the kernel send buffer fills,
17382            // the client must leave on its absolute write deadline.
17383            std::thread::sleep(Duration::from_millis(600));
17384        });
17385        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17386        let body = vec![0x5a; 32 * 1024 * 1024];
17387        let started = Instant::now();
17388        let error = http
17389            .put(&url)
17390            .send_bytes(&body)
17391            .expect_err("stalled request-body writes must time out");
17392        assert!(
17393            started.elapsed() < Duration::from_millis(700),
17394            "stalled upload exceeded the wall-clock budget: {error}"
17395        );
17396        server.join().unwrap();
17397    }
17398
17399    #[test]
17400    fn presigned_source_retries_share_one_upload_deadline() {
17401        use std::net::TcpListener;
17402        use std::time::{Duration, Instant};
17403
17404        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17405        let address = listener.local_addr().unwrap();
17406        let signature = "do-not-render-this-stalled-upload-signature";
17407        let url = format!(
17408            "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
17409        );
17410        let server = std::thread::spawn(move || {
17411            let (_stream, _) = listener.accept().unwrap();
17412            // Keep the socket open without consuming the request. The first
17413            // attempt must spend the complete shared budget; retries may not
17414            // each restart that budget.
17415            std::thread::sleep(Duration::from_millis(600));
17416        });
17417
17418        let directory = tempfile::tempdir().unwrap();
17419        std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17420        std::fs::create_dir(directory.path().join("records")).unwrap();
17421        let relative = "records/stalled.bin";
17422        let bytes = vec![0x5a; 32 * 1024 * 1024];
17423        std::fs::write(directory.path().join(relative), &bytes).unwrap();
17424        let store = Store::open_strict(directory.path()).unwrap();
17425        let cfg = HubConfig {
17426            hub: format!("http://{address}"),
17427            key: None,
17428            agent_key: None,
17429            brain_key: None,
17430            state_dir: tempfile::tempdir().unwrap().keep(),
17431            store_selected: false,
17432        };
17433        let source = V2UploadSource {
17434            path: relative.to_string(),
17435            bytes: bytes.len() as u64,
17436        };
17437
17438        let started = Instant::now();
17439        let error = put_presigned_source_with_budget(
17440            &cfg,
17441            &url,
17442            &json!({ "content-length": source.bytes.to_string() }),
17443            &store,
17444            &source,
17445            None,
17446            Duration::from_millis(150),
17447        )
17448        .expect_err("a black-holed upload must leave at its shared deadline");
17449        assert!(
17450            started.elapsed() < Duration::from_millis(700),
17451            "presigned retries exceeded their shared budget: {error}"
17452        );
17453        let rendered = error.to_string();
17454        assert!(rendered.contains("the object store"));
17455        assert!(!rendered.contains(&url));
17456        assert!(!rendered.contains(signature));
17457        server.join().unwrap();
17458    }
17459
17460    #[test]
17461    fn verb_entry_gates_accept_the_hub_ref_shapes() {
17462        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
17463            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
17464            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
17465            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
17466        }
17467    }
17468
17469    #[test]
17470    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
17471        let cfg = dead_hub();
17472        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
17473            assert!(
17474                matches!(
17475                    sync_pull(&cfg, bad, None),
17476                    Err(LinkError::BadAddress { .. })
17477                ),
17478                "sync_pull must refuse {bad:?}"
17479            );
17480            assert!(
17481                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
17482                "sync_push must refuse {bad:?}"
17483            );
17484            assert!(
17485                matches!(
17486                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
17487                    Err(LinkError::BadAddress { .. })
17488                ),
17489                "grant_issue must refuse {bad:?}"
17490            );
17491            assert!(
17492                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
17493                "grant_list must refuse {bad:?}"
17494            );
17495            assert!(
17496                matches!(
17497                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
17498                    Err(LinkError::BadAddress { .. })
17499                ),
17500                "grant_revoke must refuse brain {bad:?}"
17501            );
17502            assert!(
17503                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
17504                "head must refuse {bad:?}"
17505            );
17506        }
17507    }
17508
17509    #[test]
17510    fn grant_revoke_refuses_url_reshaping_grant_ids() {
17511        let cfg = dead_hub();
17512        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
17513            assert!(
17514                matches!(
17515                    grant_revoke(&cfg, "acme", bad),
17516                    Err(LinkError::BadGrantId { .. })
17517                ),
17518                "grant_revoke must refuse grant id {bad:?}"
17519            );
17520        }
17521    }
17522
17523    #[test]
17524    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
17525        let cfg = dead_hub();
17526        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
17527            assert!(
17528                matches!(
17529                    propose(&cfg, bad, "intake", "hi"),
17530                    Err(LinkError::BadAddress { .. })
17531                ),
17532                "propose must refuse handle {bad:?}"
17533            );
17534        }
17535        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
17536        assert!(matches!(
17537            propose(&cfg, "acme-site", "intake", &oversize),
17538            Err(LinkError::ProposeTooLarge { .. })
17539        ));
17540        // A clean handle + in-cap body passes both gates: the failure is now
17541        // the (dead) wire, proving the gates refuse shape, not the verb.
17542        assert!(matches!(
17543            propose(&cfg, "acme-site", "intake", "hi"),
17544            Err(LinkError::Transport { .. })
17545        ));
17546    }
17547
17548    #[test]
17549    fn resolve_refuses_a_hand_built_unsafe_address() {
17550        let cfg = dead_hub();
17551        for brain in ["../up", "a/b", "a?x", "a#f"] {
17552            let addr = Address {
17553                brain: brain.to_string(),
17554                target: None,
17555            };
17556            assert!(
17557                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
17558                "resolve must refuse brain {brain:?}"
17559            );
17560        }
17561        for target in [
17562            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
17563            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
17564            AddressTarget::Path("../up.md".to_string()),
17565            AddressTarget::Path("records/x.md#frag".to_string()),
17566        ] {
17567            let addr = Address {
17568                brain: "acme".to_string(),
17569                target: Some(target.clone()),
17570            };
17571            assert!(
17572                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
17573                "resolve must refuse target {target:?}"
17574            );
17575        }
17576    }
17577
17578    #[test]
17579    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
17580        let mut local = std::collections::BTreeMap::new();
17581        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
17582        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
17583        let mut remote = std::collections::BTreeMap::new();
17584        remote.insert(
17585            "records/a.md".to_string(),
17586            V2BaselineFile {
17587                sha256: "c".repeat(64),
17588                bytes: 1,
17589                proof: None,
17590            },
17591        );
17592        remote.insert(
17593            "records/b.md".to_string(),
17594            V2BaselineFile {
17595                sha256: "b".repeat(64),
17596                bytes: 1,
17597                proof: None,
17598            },
17599        );
17600        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
17601    }
17602
17603    #[test]
17604    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
17605        let local = std::collections::BTreeMap::new();
17606        let mut remote = std::collections::BTreeMap::new();
17607        remote.insert(
17608            "private/local.md".to_string(),
17609            V2BaselineFile {
17610                sha256: "d".repeat(64),
17611                bytes: 1,
17612                proof: None,
17613            },
17614        );
17615        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
17616        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
17617    }
17618
17619    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
17620        V2VerifiedHead {
17621            requested: TEST_BRAIN_ID.to_string(),
17622            brain_id: TEST_BRAIN_ID.to_string(),
17623            view_kind: "scoped".to_string(),
17624            view_revision: revision.to_string(),
17625            control_revision: revision.to_string(),
17626            identity: V2HeadIdentity {
17627                custody: "hub".to_string(),
17628                fingerprint: "test".to_string(),
17629                public_key_spki: "test".to_string(),
17630                previous: Vec::new(),
17631                rotations: Vec::new(),
17632            },
17633            pointer: None,
17634            trust: TrustState {
17635                v: 2,
17636                origin: "https://hub.example".to_string(),
17637                requested: TEST_BRAIN_ID.to_string(),
17638                brain: TEST_BRAIN_ID.to_string(),
17639                home: None,
17640                anchor: "ed25519:test".to_string(),
17641                current: "ed25519:test".to_string(),
17642                head_seq: 0,
17643                feed_hash: None,
17644                rotations: Vec::new(),
17645                hub_signer: None,
17646                protocol_profile: Some("link-v2".to_string()),
17647            },
17648            alias: None,
17649        }
17650    }
17651
17652    #[test]
17653    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
17654        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
17655        assert!(accepted_as_v2(&trust));
17656
17657        trust.protocol_profile = None;
17658        trust.hub_signer = Some("ed25519:hub".to_string());
17659        assert!(accepted_as_v2(&trust));
17660
17661        trust.hub_signer = None;
17662        assert!(!accepted_as_v2(&trust));
17663    }
17664
17665    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
17666        V2SyncBaseline {
17667            v: 2,
17668            origin: "https://hub.example".to_string(),
17669            brain: TEST_BRAIN_ID.to_string(),
17670            checkout_id: Some("c".repeat(64)),
17671            head_seq: Some(0),
17672            commit_hash: None,
17673            content_root: None,
17674            asset_root: None,
17675            assets: std::collections::BTreeMap::new(),
17676            view_kind: Some("scoped".to_string()),
17677            view_revision: Some(revision.to_string()),
17678            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
17679            files: std::collections::BTreeMap::new(),
17680            local_policy_digest: None,
17681            local_eligibility: std::collections::BTreeMap::new(),
17682            remote_copy_remains: std::collections::BTreeMap::new(),
17683        }
17684    }
17685
17686    #[test]
17687    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
17688        let directory = tempfile::tempdir().unwrap();
17689        std::fs::write(
17690            directory.path().join("DB.md"),
17691            scoped_projection_bytes(TEST_BRAIN_ID),
17692        )
17693        .unwrap();
17694        let store = Store::open_strict(directory.path()).unwrap();
17695        let head = scoped_test_head(&"a".repeat(64));
17696        let baseline = scoped_test_baseline(&"a".repeat(64));
17697        let mut view = v2_local_files(&store).unwrap();
17698        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
17699        assert!(!view.riding.contains_key("DB.md"));
17700        assert!(!view.eligibility.contains_key("DB.md"));
17701    }
17702
17703    #[test]
17704    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
17705        let directory = tempfile::tempdir().unwrap();
17706        std::fs::write(
17707            directory.path().join("DB.md"),
17708            scoped_projection_bytes(TEST_BRAIN_ID),
17709        )
17710        .unwrap();
17711        let store = Store::open_strict(directory.path()).unwrap();
17712        let head = scoped_test_head(&"a".repeat(64));
17713        let baseline = scoped_test_baseline(&"a".repeat(64));
17714
17715        let mut carried = v2_local_files(&store).unwrap();
17716        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
17717        let handed_off =
17718            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
17719        assert!(!handed_off.riding.contains_key("DB.md"));
17720
17721        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
17722        assert!(!freshly_scanned.riding.contains_key("DB.md"));
17723
17724        std::fs::write(
17725            directory.path().join("DB.md"),
17726            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
17727        )
17728        .unwrap();
17729        let tampered = Store::open_strict(directory.path()).unwrap();
17730        assert!(matches!(
17731            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
17732            Err(LinkError::ScopedProjectionModified)
17733        ));
17734    }
17735
17736    #[test]
17737    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
17738        let directory = tempfile::tempdir().unwrap();
17739        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
17740        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
17741        std::fs::write(
17742            directory.path().join("DB.md"),
17743            b"---\nname: Kept home test\n---\n",
17744        )
17745        .unwrap();
17746        std::fs::write(
17747            directory.path().join("records/notes/a.md"),
17748            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
17749        )
17750        .unwrap();
17751        std::fs::write(
17752            directory.path().join("sources/private/secret.md"),
17753            b"---\ntype: note\n---\nlocal only\n",
17754        )
17755        .unwrap();
17756        std::fs::write(
17757            directory.path().join("sources/private/unlinked.md"),
17758            b"---\ntype: note\n---\nnot disclosed\n",
17759        )
17760        .unwrap();
17761        std::fs::write(
17762            directory.path().join(".sevralocal"),
17763            b"sources/private/**\n",
17764        )
17765        .unwrap();
17766
17767        let store = Store::open_strict(directory.path()).unwrap();
17768        let view = v2_local_files(&store).unwrap();
17769        assert!(!view.riding.contains_key("sources/private/secret.md"));
17770        assert_eq!(
17771            view.withheld_links,
17772            vec![V2WithheldLink {
17773                source: "records/notes/a.md".to_string(),
17774                target: "sources/private/secret.md".to_string(),
17775            }]
17776        );
17777    }
17778
17779    #[test]
17780    fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
17781        // An export omits kept-home content by construction, so a store
17782        // restored from one links to names it does not carry. Without this the
17783        // owner's own export could never be pushed back — the links would read
17784        // as broken and the commit would be refused.
17785        let directory = tempfile::tempdir().unwrap();
17786        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
17787        std::fs::write(
17788            directory.path().join("DB.md"),
17789            b"---\nname: Restored export\n---\n",
17790        )
17791        .unwrap();
17792        std::fs::write(
17793            directory.path().join("records/notes/a.md"),
17794            b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
17795        )
17796        .unwrap();
17797        std::fs::write(
17798            directory.path().join(".sevralocal"),
17799            b"sources/private/**\n",
17800        )
17801        .unwrap();
17802
17803        let store = Store::open_strict(directory.path()).unwrap();
17804        let view = v2_local_files(&store).unwrap();
17805        assert_eq!(
17806            view.withheld_links,
17807            vec![V2WithheldLink {
17808                source: "records/notes/a.md".to_string(),
17809                target: "sources/private/absent.md".to_string(),
17810            }]
17811        );
17812        // A dangling link the owner never claimed is still dangling.
17813        std::fs::write(
17814            directory.path().join("records/notes/b.md"),
17815            b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
17816        )
17817        .unwrap();
17818        let store = Store::open_strict(directory.path()).unwrap();
17819        let view = v2_local_files(&store).unwrap();
17820        assert!(
17821            !view
17822                .withheld_links
17823                .iter()
17824                .any(|link| link.target == "records/notes/nowhere.md"),
17825            "an unclaimed dangling target must not be declared withheld"
17826        );
17827    }
17828
17829    #[test]
17830    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
17831        let directory = tempfile::tempdir().unwrap();
17832        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
17833        std::fs::write(
17834            directory.path().join("DB.md"),
17835            b"---\nname: Withdrawal test\n---\n",
17836        )
17837        .unwrap();
17838        let source = b"---\ntype: note\n---\nlocal evidence\n";
17839        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
17840        std::fs::write(
17841            directory.path().join(".sevralocal"),
17842            b"sources/private/**\n",
17843        )
17844        .unwrap();
17845        let store = Store::open_strict(directory.path()).unwrap();
17846        let view = v2_local_files(&store).unwrap();
17847        let mut remote = std::collections::BTreeMap::new();
17848        remote.insert(
17849            "sources/private/evidence.md".to_string(),
17850            V2BaselineFile {
17851                sha256: content_sha256(source),
17852                bytes: source.len() as u64,
17853                proof: None,
17854            },
17855        );
17856        assert_eq!(
17857            v2_content_withdrawal_operation(
17858                &store,
17859                &view,
17860                &remote,
17861                "sources/private/evidence.md",
17862                "approved retention change",
17863            )
17864            .unwrap(),
17865            json!({
17866                "op": "withdraw_from_hosting",
17867                "path": "sources/private/evidence.md",
17868                "expected": { "kind": "blob", "hash": content_sha256(source) },
17869                "reason": "approved retention change",
17870            })
17871        );
17872
17873        std::fs::write(
17874            directory.path().join("sources/private/evidence.md"),
17875            b"changed after review",
17876        )
17877        .unwrap();
17878        assert!(matches!(
17879            v2_content_withdrawal_operation(
17880                &store,
17881                &view,
17882                &remote,
17883                "sources/private/evidence.md",
17884                "approved retention change",
17885            ),
17886            Err(LinkError::InvalidPack { .. })
17887        ));
17888    }
17889
17890    #[test]
17891    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
17892        let directory = tempfile::tempdir().unwrap();
17893        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
17894        std::fs::write(
17895            directory.path().join("DB.md"),
17896            b"---\nname: Asset withdrawal test\n---\n",
17897        )
17898        .unwrap();
17899        let bytes = b"private binary";
17900        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
17901        std::fs::write(
17902            directory.path().join(".sevralocal"),
17903            b"sources/files/private.pdf\n",
17904        )
17905        .unwrap();
17906        let store = Store::open_strict(directory.path()).unwrap();
17907        let view = v2_local_files(&store).unwrap();
17908        let local = crate::AssetRecord {
17909            path: "sources/files/private.pdf".to_string(),
17910            sha256: content_sha256(bytes),
17911            bytes: bytes.len() as u64,
17912            media_type: "application/pdf".to_string(),
17913            wrappers: vec!["sources/files/private.md".to_string()],
17914            required: true,
17915        };
17916        let current = V2BaselineAsset {
17917            blob_sha256: local.sha256.clone(),
17918            bytes: local.bytes,
17919            media_type: local.media_type.clone(),
17920            wrappers: local.wrappers.clone(),
17921            required: local.required,
17922            disposition: "hosted".to_string(),
17923            leaf_hash: "d".repeat(64),
17924        };
17925        assert_eq!(
17926            v2_asset_withdrawal_operation(
17927                &store,
17928                &view,
17929                &local.path,
17930                &local,
17931                &current,
17932                "approved retention change",
17933            )
17934            .unwrap(),
17935            json!({
17936                "op": "asset_withdraw",
17937                "path": local.path,
17938                "expected": { "kind": "asset", "hash": "d".repeat(64) },
17939                "reason": "approved retention change",
17940            })
17941        );
17942
17943        let mut mismatched = current.clone();
17944        mismatched.required = false;
17945        assert!(matches!(
17946            v2_asset_withdrawal_operation(
17947                &store,
17948                &view,
17949                &local.path,
17950                &local,
17951                &mismatched,
17952                "approved retention change",
17953            ),
17954            Err(LinkError::InvalidPack { .. })
17955        ));
17956    }
17957
17958    #[test]
17959    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
17960        let first = v2_checkout_id(None).unwrap();
17961        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
17962        assert_ne!(first, v2_checkout_id(None).unwrap());
17963        assert!(is_sha256(&first));
17964    }
17965
17966    #[test]
17967    fn scoped_projection_edit_and_scope_transition_fail_closed() {
17968        let directory = tempfile::tempdir().unwrap();
17969        std::fs::write(
17970            directory.path().join("DB.md"),
17971            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
17972        )
17973        .unwrap();
17974        let store = Store::open_strict(directory.path()).unwrap();
17975        let head = scoped_test_head(&"a".repeat(64));
17976        let baseline = scoped_test_baseline(&"a".repeat(64));
17977        let mut view = v2_local_files(&store).unwrap();
17978        assert!(matches!(
17979            remove_scoped_projection(&head, Some(&baseline), &mut view),
17980            Err(LinkError::ScopedProjectionModified)
17981        ));
17982
17983        let changed = scoped_test_head(&"b".repeat(64));
17984        assert!(matches!(
17985            ensure_v2_view_compatible(&changed, Some(&baseline)),
17986            Err(LinkError::ScopedViewChanged)
17987        ));
17988
17989        let mut same_view_new_control = head.clone();
17990        same_view_new_control.control_revision = "c".repeat(64);
17991        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
17992        assert!(!same_v2_head(&head, &same_view_new_control));
17993    }
17994
17995    #[test]
17996    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
17997        let scoped = scoped_test_head(&"a".repeat(64));
17998        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
17999        assert!(matches!(
18000            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18001            Err(LinkError::ScopedProjectionModified)
18002        ));
18003
18004        let mut full = scoped.clone();
18005        full.view_kind = "full".to_string();
18006        let mut full_baseline = scoped_baseline.clone();
18007        full_baseline.view_kind = Some("full".to_string());
18008        full_baseline.projection_sha256 = None;
18009        assert!(matches!(
18010            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18011            Err(LinkError::InvalidPack { .. })
18012        ));
18013
18014        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18015        assert!(
18016            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18017        );
18018    }
18019
18020    #[test]
18021    fn scoped_view_metadata_is_explicitly_non_authoritative() {
18022        let head = scoped_test_head(&"a".repeat(64));
18023        let value: Value =
18024            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18025        assert_eq!(value["kind"], "link.md-scoped-view");
18026        assert_eq!(value["authoritative"], false);
18027        assert_eq!(value["visible_files"], 7);
18028        assert_eq!(value["brain"], TEST_BRAIN_ID);
18029    }
18030
18031    #[test]
18032    fn local_scoped_marker_requires_the_exact_generated_projection() {
18033        let directory = tempfile::tempdir().unwrap();
18034        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18035        std::fs::write(
18036            directory.path().join("DB.md"),
18037            scoped_projection_bytes(TEST_BRAIN_ID),
18038        )
18039        .unwrap();
18040        let head = scoped_test_head(&"a".repeat(64));
18041        std::fs::write(
18042            directory.path().join(".dbmd/view.json"),
18043            scoped_view_metadata(&head, 0).unwrap(),
18044        )
18045        .unwrap();
18046        let store = Store::open_strict(directory.path()).unwrap();
18047        assert!(has_verified_local_scoped_view(&store));
18048
18049        std::fs::write(
18050            directory.path().join("DB.md"),
18051            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18052        )
18053        .unwrap();
18054        let altered = Store::open_strict(directory.path()).unwrap();
18055        assert!(!has_verified_local_scoped_view(&altered));
18056    }
18057
18058    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18059        use ring::signature::KeyPair as _;
18060
18061        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18062        let rng = ring::rand::SystemRandom::new();
18063        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18064        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18065        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18066        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18067        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18068        let blob = b"new";
18069        let blob_hash = content_sha256(blob);
18070        let changes = json!({
18071            "mutation_id": "sync:proposal-fixture",
18072            "operations": [{
18073                "blob": blob_hash,
18074                "bytes": blob.len(),
18075                "expected": null,
18076                "op": "put",
18077                "path": "records/new.md",
18078            }],
18079            "reason": "fixture",
18080            "v": 2,
18081        });
18082        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18083        let changes_base64 = STANDARD.encode(&changes_bytes);
18084        let descriptor = json!({
18085            "base": null,
18086            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18087            "changes_base64": changes_base64,
18088            "rebase": "strict",
18089            "v": 2,
18090        });
18091        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18092        let payload_hash = "b".repeat(64);
18093        let submitted_at = "2026-08-19T12:00:00.000Z";
18094        let claim = json!({
18095            "actor_root": {
18096                "actor_class": "foreign_key",
18097                "credential": "ed25519:fixture",
18098                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18099                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18100                "principal": "key:fixture",
18101                "role": null,
18102            },
18103            "brain": TEST_BRAIN_ID,
18104            "clear_sha256": clear_hash,
18105            "control_revision": "c".repeat(64),
18106            "mutation_id": "sync:proposal-fixture",
18107            "payload_sha256": payload_hash,
18108            "proposal_id": proposal_id,
18109            "submitted_at": submitted_at,
18110            "v": 2,
18111        });
18112        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
18113        let envelope = json!({
18114            "claim": claim,
18115            "fingerprint": fingerprint,
18116            "public_key": public_key,
18117            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
18118        });
18119        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18120        let submission_hash =
18121            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
18122        let mut head = scoped_test_head(&"c".repeat(64));
18123        head.view_kind = "full".to_string();
18124        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
18125        let value = json!({
18126            "proposal": {
18127                "base": null,
18128                "blobs": [{
18129                    "bytes": blob.len(),
18130                    "endpoint": format!(
18131                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
18132                    ),
18133                    "sha256": blob_hash,
18134                }],
18135                "changes_base64": changes_base64,
18136                "clear_sha256": clear_hash,
18137                "expires_at": "2026-08-26T12:00:00.000Z",
18138                "id": proposal_id,
18139                "payload_sha256": payload_hash,
18140                "proposer": { "class": "foreign_key" },
18141                "rebase": "strict",
18142                "state": "pending",
18143                "submission_claim_base64": STANDARD.encode(envelope_bytes),
18144                "submission_claim_sha256": submission_hash,
18145                "submitted_at": submitted_at,
18146            },
18147            "v": 2,
18148        });
18149        (head, proposal_id, value)
18150    }
18151
18152    #[test]
18153    fn v2_proposal_verifier_accepts_exact_signed_payload() {
18154        let (head, proposal_id, value) = signed_proposal_fixture();
18155        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18156        assert_eq!(verified.blobs.len(), 1);
18157        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18158    }
18159
18160    #[test]
18161    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18162        let (head, proposal_id, value) = signed_proposal_fixture();
18163
18164        let mut changed = value.clone();
18165        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18166        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18167
18168        let mut redirected = value.clone();
18169        redirected["proposal"]["blobs"][0]["endpoint"] =
18170            Value::String("https://attacker.example/blob".to_string());
18171        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18172
18173        let mut forged = value;
18174        let encoded = forged["proposal"]["submission_claim_base64"]
18175            .as_str()
18176            .unwrap();
18177        let mut envelope: Value =
18178            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18179        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18180        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18181        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18182        forged["proposal"]["submission_claim_sha256"] = Value::String(
18183            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18184        );
18185        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18186    }
18187
18188    #[cfg(unix)]
18189    #[test]
18190    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18191        let sandbox = tempfile::tempdir().unwrap();
18192        let destination = sandbox.path().join("brain");
18193        let entries = vec![
18194            (
18195                "DB.md".to_string(),
18196                scoped_projection_bytes(TEST_BRAIN_ID),
18197            ),
18198            (
18199                "records/contacts/a.md".to_string(),
18200                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18201                    .to_vec(),
18202            ),
18203        ];
18204        install_pulled_delta(&destination, &entries, &[], true).unwrap();
18205        assert!(destination.join("index.md").is_file());
18206        assert!(destination.join("records/index.md").is_file());
18207        assert!(destination.join("records/contacts/index.md").is_file());
18208        assert!(destination.join("records/contacts/index.jsonl").is_file());
18209    }
18210
18211    #[cfg(unix)]
18212    #[test]
18213    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
18214        let sandbox = tempfile::tempdir().unwrap();
18215        let destination = sandbox.path().join("brain");
18216        let cache = sandbox.path().join("cache");
18217        std::fs::create_dir(&cache).unwrap();
18218        let db = scoped_projection_bytes(TEST_BRAIN_ID);
18219        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
18220        let db_source = cache.join("db");
18221        let shared_source = cache.join("shared");
18222        crate::fsx::write_atomic(&db_source, &db).unwrap();
18223        crate::fsx::write_atomic(&shared_source, shared).unwrap();
18224        let mut entries = vec![V2StagedFile {
18225            path: "DB.md".to_string(),
18226            source: db_source,
18227            sha256: content_sha256(&db),
18228            bytes: db.len() as u64,
18229        }];
18230        for index in 0..512 {
18231            entries.push(V2StagedFile {
18232                path: format!("records/items/{index:05}.md"),
18233                source: shared_source.clone(),
18234                sha256: content_sha256(shared),
18235                bytes: shared.len() as u64,
18236            });
18237        }
18238        install_pulled_delta_sources(
18239            &destination,
18240            &entries,
18241            &[],
18242            false,
18243            None,
18244            &scoped_test_head(&"c".repeat(64)),
18245        )
18246        .unwrap();
18247        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
18248        for index in 0..512 {
18249            assert_eq!(
18250                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
18251                shared
18252            );
18253        }
18254        assert!(
18255            std::fs::read_dir(sandbox.path())
18256                .unwrap()
18257                .all(|entry| !entry
18258                    .unwrap()
18259                    .file_name()
18260                    .to_string_lossy()
18261                    .contains("pull-stage")),
18262            "the private stage must be atomically installed or removed"
18263        );
18264    }
18265
18266    #[cfg(unix)]
18267    #[test]
18268    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
18269        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
18270
18271        let sandbox = tempfile::tempdir().unwrap();
18272        let root = sandbox.path().join("brain");
18273        std::fs::create_dir_all(root.join("records/items")).unwrap();
18274        let db = scoped_projection_bytes(TEST_BRAIN_ID);
18275        let old = b"---\ntype: note\n---\n\nold\n";
18276        let new = b"---\ntype: note\n---\n\nnew\n";
18277        let removed = b"---\ntype: note\n---\n\nremove me\n";
18278        std::fs::write(root.join("DB.md"), &db).unwrap();
18279        std::fs::write(root.join("records/items/change.md"), old).unwrap();
18280        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
18281        for index in 0..512 {
18282            std::fs::write(
18283                root.join(format!("records/items/untouched-{index:04}.md")),
18284                old,
18285            )
18286            .unwrap();
18287        }
18288        let untouched = root.join("records/items/untouched-0256.md");
18289        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
18290        let source = sandbox.path().join("changed-source");
18291        crate::fsx::write_atomic(&source, new).unwrap();
18292        let same_source = sandbox.path().join("unchanged-source");
18293        crate::fsx::write_atomic(&same_source, old).unwrap();
18294        let same_entry = V2StagedFile {
18295            path: "records/items/change.md".to_string(),
18296            source: same_source,
18297            sha256: content_sha256(old),
18298            bytes: old.len() as u64,
18299        };
18300        let entry = V2StagedFile {
18301            path: "records/items/change.md".to_string(),
18302            source,
18303            sha256: content_sha256(new),
18304            bytes: new.len() as u64,
18305        };
18306        let head = scoped_test_head(&"c".repeat(64));
18307
18308        // A no-op established pull neither clones the checkout nor arms a
18309        // journal. Inode identity is a direct regression guard against the
18310        // former brain-wide private-copy implementation.
18311        install_established_v2_delta(
18312            Store::open_strict(&root).unwrap(),
18313            &[same_entry],
18314            &["records/items/already-absent.md".to_string()],
18315            true,
18316            None,
18317            &head,
18318        )
18319        .unwrap();
18320        assert_eq!(
18321            std::fs::metadata(&untouched).unwrap().ino(),
18322            untouched_inode
18323        );
18324        assert!(!root.join(V2_PULL_JOURNAL).exists());
18325
18326        install_established_v2_delta(
18327            Store::open_strict(&root).unwrap(),
18328            &[entry],
18329            &["records/items/delete.md".to_string()],
18330            false,
18331            None,
18332            &head,
18333        )
18334        .unwrap();
18335        assert_eq!(
18336            std::fs::read(root.join("records/items/change.md")).unwrap(),
18337            new
18338        );
18339        assert!(!root.join("records/items/delete.md").exists());
18340        assert_eq!(
18341            std::fs::metadata(&untouched).unwrap().ino(),
18342            untouched_inode
18343        );
18344        assert!(root.join(V2_PULL_JOURNAL).is_file());
18345        assert_eq!(
18346            std::fs::metadata(root.join(V2_PULL_JOURNAL))
18347                .unwrap()
18348                .permissions()
18349                .mode()
18350                & 0o777,
18351            0o600
18352        );
18353        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
18354            .unwrap()
18355            .unwrap();
18356        assert_eq!(
18357            std::fs::metadata(root.join(&journal.backup_dir))
18358                .unwrap()
18359                .permissions()
18360                .mode()
18361                & 0o777,
18362            0o700
18363        );
18364        for entry in &journal.entries {
18365            if let Some(backup) = &entry.backup {
18366                assert_eq!(
18367                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
18368                        .unwrap()
18369                        .permissions()
18370                        .mode()
18371                        & 0o777,
18372                    0o600
18373                );
18374            }
18375        }
18376
18377        let cfg = test_hub_config(
18378            "https://example.test".to_string(),
18379            sandbox.path().join("state"),
18380        );
18381        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18382        assert_eq!(
18383            std::fs::read(root.join("records/items/change.md")).unwrap(),
18384            old
18385        );
18386        assert_eq!(
18387            std::fs::read(root.join("records/items/delete.md")).unwrap(),
18388            removed
18389        );
18390        assert_eq!(
18391            std::fs::metadata(&untouched).unwrap().ino(),
18392            untouched_inode
18393        );
18394        assert!(!root.join(V2_PULL_JOURNAL).exists());
18395    }
18396
18397    #[test]
18398    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
18399        let body = b"bounded bytes";
18400        let path = "records/example.md".to_string();
18401        let file = V2BaselineFile {
18402            sha256: content_sha256(body),
18403            bytes: body.len() as u64,
18404            proof: None,
18405        };
18406        let header = serde_json::to_vec(&json!({
18407            "bytes": body.len(),
18408            "path": path,
18409            "sha256": file.sha256,
18410            "v": 2,
18411        }))
18412        .unwrap();
18413        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
18414        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
18415        stream.extend_from_slice(&header);
18416        stream.extend_from_slice(body);
18417        stream.extend_from_slice(&0_u32.to_be_bytes());
18418        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
18419        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
18420
18421        let mut tampered = stream.clone();
18422        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
18423        tampered[body_offset] ^= 1;
18424        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
18425
18426        let mut trailing = stream;
18427        trailing.push(0);
18428        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
18429    }
18430
18431    #[test]
18432    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
18433        let sandbox = tempfile::TempDir::new().unwrap();
18434        let root = sandbox.path().join("brain");
18435        std::fs::create_dir_all(&root).unwrap();
18436        std::fs::write(
18437            root.join("DB.md"),
18438            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18439        )
18440        .unwrap();
18441        let store = Store::open_strict(&root).unwrap();
18442        let incomplete = crate::ulid::mint();
18443        store
18444            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
18445            .unwrap();
18446        let expired = crate::ulid::mint();
18447        store
18448            .create_dir_all(&v2_conflict_relative(&expired, "files"))
18449            .unwrap();
18450        let plan = V2ConflictPlan {
18451            v: 2,
18452            class: "content_resolution_required".to_string(),
18453            bundle: expired.clone(),
18454            brain: TEST_BRAIN_ID.to_string(),
18455            origin: "https://example.test".to_string(),
18456            created_unix: 0,
18457            expires_unix: 0,
18458            base_seq: None,
18459            base_commit: None,
18460            remote_seq: 0,
18461            remote_commit: None,
18462            remote_content_root: None,
18463            view_kind: "full".to_string(),
18464            view_revision: "a".repeat(64),
18465            files: vec![V2ConflictFile {
18466                path: "records/value.md".to_string(),
18467                base: V2ConflictCoordinate {
18468                    sha256: None,
18469                    bytes: None,
18470                    file: None,
18471                },
18472                local: V2ConflictCoordinate {
18473                    sha256: None,
18474                    bytes: None,
18475                    file: None,
18476                },
18477                remote: V2ConflictCoordinate {
18478                    sha256: None,
18479                    bytes: None,
18480                    file: None,
18481                },
18482            }],
18483        };
18484        let mut bytes = serde_json::to_vec(&plan).unwrap();
18485        bytes.push(b'\n');
18486        store
18487            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
18488            .unwrap();
18489
18490        let listed = sync_conflicts(&root, false, false).unwrap();
18491        assert_eq!(listed["bundles"], 2);
18492        assert_eq!(listed["pruned"], 0);
18493        let pruned = sync_conflicts(&root, true, false).unwrap();
18494        assert_eq!(pruned["bundles"], 0);
18495        assert_eq!(pruned["pruned"], 2);
18496        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
18497        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
18498    }
18499
18500    #[test]
18501    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
18502        let sandbox = tempfile::TempDir::new().unwrap();
18503        let root = sandbox.path().join("brain");
18504        std::fs::create_dir_all(&root).unwrap();
18505        std::fs::write(
18506            root.join("DB.md"),
18507            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18508        )
18509        .unwrap();
18510        let store = Store::open_strict(&root).unwrap();
18511        let bundle = crate::ulid::mint();
18512        store
18513            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
18514            .unwrap();
18515        store
18516            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
18517            .unwrap();
18518
18519        assert!(sync_conflicts(&root, true, false).is_err());
18520        assert!(sync_conflicts(&root, false, true).is_err());
18521        let pruned = sync_conflicts(&root, true, true).unwrap();
18522        assert_eq!(pruned["pruned"], 1);
18523        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
18524    }
18525
18526    #[test]
18527    fn ready_pull_journal_rolls_back_exact_preimages() {
18528        let sandbox = tempfile::TempDir::new().unwrap();
18529        let root = sandbox.path().join("brain");
18530        std::fs::create_dir_all(root.join("records")).unwrap();
18531        std::fs::write(
18532            root.join("DB.md"),
18533            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18534        )
18535        .unwrap();
18536        let path = "records/value.md";
18537        let old = b"---\ntype: note\n---\n\nold\n";
18538        let new = b"---\ntype: note\n---\n\nnew\n";
18539        std::fs::write(root.join(path), old).unwrap();
18540        let store = Store::open_strict(&root).unwrap();
18541        let bundle = crate::ulid::mint();
18542        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18543        store
18544            .create_private_dir_all(Path::new(&backup_dir))
18545            .unwrap();
18546        store
18547            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
18548            .unwrap();
18549        let journal = V2PullJournal {
18550            v: 1,
18551            phase: V2PullPhase::Ready,
18552            brain: TEST_BRAIN_ID.to_string(),
18553            previous: V2PullCoordinate {
18554                head_seq: None,
18555                commit_hash: None,
18556                view_kind: None,
18557                view_revision: None,
18558            },
18559            next: V2PullCoordinate {
18560                head_seq: Some(2),
18561                commit_hash: Some("c".repeat(64)),
18562                view_kind: Some("full".to_string()),
18563                view_revision: Some("d".repeat(64)),
18564            },
18565            backup_dir: backup_dir.clone(),
18566            entries: vec![V2PullJournalEntry {
18567                path: path.to_string(),
18568                old: Some(V2PullFileCoordinate {
18569                    sha256: content_sha256(old),
18570                    bytes: old.len() as u64,
18571                }),
18572                new: Some(V2PullFileCoordinate {
18573                    sha256: content_sha256(new),
18574                    bytes: new.len() as u64,
18575                }),
18576                backup: Some("00000000".to_string()),
18577            }],
18578        };
18579        validate_v2_pull_journal(&journal).unwrap();
18580        store
18581            .write_private_atomic_new(
18582                Path::new(V2_PULL_JOURNAL),
18583                &v2_pull_journal_bytes(&journal).unwrap(),
18584            )
18585            .unwrap();
18586        store.write_atomic(Path::new(path), new).unwrap();
18587
18588        let cfg = test_hub_config(
18589            "https://example.test".to_string(),
18590            sandbox.path().join("state"),
18591        );
18592        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18593        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
18594        assert!(!root.join(V2_PULL_JOURNAL).exists());
18595        assert!(!root.join(backup_dir).exists());
18596    }
18597
18598    #[test]
18599    fn preparing_pull_journal_discards_only_private_staging() {
18600        let sandbox = tempfile::TempDir::new().unwrap();
18601        let root = sandbox.path().join("brain");
18602        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
18603        std::fs::write(
18604            root.join("DB.md"),
18605            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18606        )
18607        .unwrap();
18608        let store = Store::open_strict(&root).unwrap();
18609        let bundle = crate::ulid::mint();
18610        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18611        store
18612            .create_private_dir_all(Path::new(&backup_dir))
18613            .unwrap();
18614        let journal = V2PullJournal {
18615            v: 1,
18616            phase: V2PullPhase::Preparing,
18617            brain: TEST_BRAIN_ID.to_string(),
18618            previous: V2PullCoordinate {
18619                head_seq: None,
18620                commit_hash: None,
18621                view_kind: None,
18622                view_revision: None,
18623            },
18624            next: V2PullCoordinate {
18625                head_seq: Some(1),
18626                commit_hash: Some("a".repeat(64)),
18627                view_kind: Some("full".to_string()),
18628                view_revision: Some("b".repeat(64)),
18629            },
18630            backup_dir: backup_dir.clone(),
18631            entries: vec![V2PullJournalEntry {
18632                path: "records/new.md".to_string(),
18633                old: None,
18634                new: Some(V2PullFileCoordinate {
18635                    sha256: "c".repeat(64),
18636                    bytes: 1,
18637                }),
18638                backup: None,
18639            }],
18640        };
18641        store
18642            .write_private_atomic_new(
18643                Path::new(V2_PULL_JOURNAL),
18644                &v2_pull_journal_bytes(&journal).unwrap(),
18645            )
18646            .unwrap();
18647        let cfg = test_hub_config(
18648            "https://example.test".to_string(),
18649            sandbox.path().join("state"),
18650        );
18651
18652        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18653
18654        assert!(root.join("DB.md").is_file());
18655        assert!(!root.join(V2_PULL_JOURNAL).exists());
18656        assert!(!root.join(backup_dir).exists());
18657    }
18658
18659    #[test]
18660    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
18661        let sandbox = tempfile::TempDir::new().unwrap();
18662        let root = sandbox.path().join("brain");
18663        std::fs::create_dir_all(root.join("records")).unwrap();
18664        std::fs::write(
18665            root.join("DB.md"),
18666            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18667        )
18668        .unwrap();
18669        let new = b"---\ntype: note\n---\n\nnew\n";
18670        std::fs::write(root.join("records/value.md"), new).unwrap();
18671        let store = Store::open_strict(&root).unwrap();
18672        let bundle = crate::ulid::mint();
18673        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18674        store
18675            .create_private_dir_all(Path::new(&backup_dir))
18676            .unwrap();
18677        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
18678        store.create_private_dir_all(Path::new(&orphan)).unwrap();
18679        let next = V2PullCoordinate {
18680            head_seq: Some(2),
18681            commit_hash: Some("c".repeat(64)),
18682            view_kind: Some("full".to_string()),
18683            view_revision: Some("d".repeat(64)),
18684        };
18685        let journal = V2PullJournal {
18686            v: 1,
18687            phase: V2PullPhase::Ready,
18688            brain: TEST_BRAIN_ID.to_string(),
18689            previous: V2PullCoordinate {
18690                head_seq: Some(1),
18691                commit_hash: Some("a".repeat(64)),
18692                view_kind: Some("full".to_string()),
18693                view_revision: Some("b".repeat(64)),
18694            },
18695            next: next.clone(),
18696            backup_dir: backup_dir.clone(),
18697            entries: vec![V2PullJournalEntry {
18698                path: "records/value.md".to_string(),
18699                old: Some(V2PullFileCoordinate {
18700                    sha256: "e".repeat(64),
18701                    bytes: new.len() as u64,
18702                }),
18703                new: Some(V2PullFileCoordinate {
18704                    sha256: content_sha256(new),
18705                    bytes: new.len() as u64,
18706                }),
18707                backup: Some("00000000".to_string()),
18708            }],
18709        };
18710        store
18711            .write_private_atomic_new(
18712                Path::new(V2_PULL_JOURNAL),
18713                &v2_pull_journal_bytes(&journal).unwrap(),
18714            )
18715            .unwrap();
18716        let cfg = test_hub_config(
18717            "https://example.test".to_string(),
18718            sandbox.path().join("state"),
18719        );
18720        save_v2_baseline(
18721            &cfg,
18722            TEST_BRAIN_ID,
18723            &root,
18724            &V2SyncBaseline {
18725                v: 2,
18726                origin: "https://example.test".to_string(),
18727                brain: TEST_BRAIN_ID.to_string(),
18728                checkout_id: Some("c".repeat(64)),
18729                head_seq: next.head_seq,
18730                commit_hash: next.commit_hash.clone(),
18731                content_root: Some("f".repeat(64)),
18732                asset_root: None,
18733                assets: Default::default(),
18734                view_kind: next.view_kind.clone(),
18735                view_revision: next.view_revision.clone(),
18736                projection_sha256: None,
18737                files: Default::default(),
18738                local_policy_digest: None,
18739                local_eligibility: Default::default(),
18740                remote_copy_remains: Default::default(),
18741            },
18742        )
18743        .unwrap();
18744
18745        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18746
18747        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
18748        assert!(!root.join(V2_PULL_JOURNAL).exists());
18749        assert!(!root.join(backup_dir).exists());
18750        assert!(!root.join(orphan).exists());
18751    }
18752}