Skip to main content

dbmd_core/
linkmd.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! The **link.md client** — the five interconnect verbs `dbmd` speaks against
4//! a hub: `resolve`, `sync`, `grant`, `propose`, `subscribe`.
5//!
6//! One binary, two specs (the git precedent: one binary carries both the
7//! object format and the wire protocol). The db.md FORMAT is untouched by this
8//! module: a store never needs link.md to be valid db.md, record files stay
9//! plain markdown, and SPEC.md reserves only the `@brain/id` address *shape*.
10//! Everything with a wire or a trust boundary — addressing across stores,
11//! pulling/pushing a hosted copy, capability grants, the propose door, feed
12//! polling and signed-entry verification — lives here, as a *client
13//! capability*, never a format requirement.
14//!
15//! # What this client speaks
16//!
17//! The v0 HTTP binding a hub serves under its base URL:
18//!
19//! | verb | binding |
20//! | --- | --- |
21//! | `resolve` | `GET /api/hub/brains/<brain>` (the brain card); records are read from the exact signed snapshot pack named by that card's verified feed head |
22//! | `sync` (pull) | `GET /api/hub/brains/<brain>/export?format=pack&atSeq=<n>&feedHash=<hash>` — the exact verified snapshot |
23//! | `sync` (push) | `POST /api/hub/brains/<brain>/push` for small snapshots; presign/upload/commit for large snapshots |
24//! | `grant` | `GET` / `POST /api/hub/brains/<brain>/grants`, `DELETE /api/hub/brains/<brain>/grants/<id>` |
25//! | `propose` | `POST /api/hub/sites/<handle>/inbox` — evidence in, without trust (unauthenticated by design) |
26//! | `subscribe` | `GET /api/hub/brains/<brain>` + `/feed` for a locally verified signed head |
27//!
28//! # Configuration — no default hub, credential never in the store
29//!
30//! There is **no built-in hub endpoint**: the toolkit is neutral and a hub is
31//! whatever the user points it at. Resolution order for the hub URL:
32//!
33//! 1. the `--hub <URL>` flag,
34//! 2. the `DBMD_HUB_URL` environment variable,
35//! 3. the `hub = <URL>` line in the store-local `.dbmd/config` file
36//!    (toolkit state, not store content — the walkers already skip hidden
37//!    directories, so `.dbmd/` never syncs, indexes, or validates).
38//!
39//! The credential is the `DBMD_HUB_KEY` environment variable, full stop. It is
40//! deliberately **not** read from `.dbmd/config`: a secret inside the store
41//! tree is one commit or one push away from leaking. A store-selected hub may
42//! receive an ambient bearer or agent key only when
43//! `DBMD_HUB_CREDENTIAL_ORIGIN` binds it to that exact origin. Identity pins
44//! and monotonic feed checkpoints live in the user's global state directory,
45//! never in store-controlled `.dbmd/`.
46//!
47//! Non-HTTPS hubs are refused (the bearer key must never travel in cleartext)
48//! with a loopback exemption for local development.
49//!
50//! # v0 honesty
51//!
52//! This client binds to what a hub enforces **today**: grantees are hub
53//! principals (an email), grant scopes are store-path prefixes, pushes are
54//! whole-store snapshots, and `subscribe` reports feed-head movement. The hub
55//! signs each committed snapshot in a hash-chained feed with a per-brain
56//! Ed25519 identity. This client verifies the rotation chain, signer epochs,
57//! monotonic feed checkpoint, snapshot token, and content-addressed pack before
58//! untrusted bytes touch their destination.
59
60use std::collections::{BTreeMap, BTreeSet};
61use std::io::{Cursor, Read, Write};
62use std::path::{Path, PathBuf};
63use std::time::{SystemTime, UNIX_EPOCH};
64
65use base64::{
66    engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
67    Engine as _,
68};
69use ring::signature::{UnparsedPublicKey, ED25519};
70use serde::{Deserialize, Serialize};
71use serde_json::{json, Value};
72use sha2::{Digest, Sha256};
73
74use crate::store::Store;
75
76/// Environment variable naming the hub base URL (e.g. `https://hub.example.com`).
77pub const HUB_URL_ENV: &str = "DBMD_HUB_URL";
78
79/// Environment variable carrying the hub bearer credential. The bearer
80/// credential source — see the module docs for why it is never store-based.
81pub const HUB_KEY_ENV: &str = "DBMD_HUB_KEY";
82
83/// Explicit origin binding required before an ambient bearer or agent key may
84/// be sent to a hub selected by untrusted store-local configuration. The value
85/// is an origin (`https://host[:port]`), not an arbitrary URL path.
86pub const HUB_CREDENTIAL_ORIGIN_ENV: &str = "DBMD_HUB_CREDENTIAL_ORIGIN";
87
88/// Override for dbmd's user-owned trust/checkpoint root. Must be absolute.
89/// Intended for managed installations and hermetic tests; untrusted stores
90/// cannot select it.
91pub const STATE_DIR_ENV: &str = "DBMD_STATE_DIR";
92
93/// Explicit test/development escape hatch for registry homes on loopback or
94/// private networks. Production federation rejects every non-public resolved
95/// address and pins the validated DNS answer into the HTTP agent.
96pub const ALLOW_PRIVATE_REGISTRY_HOME_ENV: &str = "DBMD_ALLOW_PRIVATE_REGISTRY_HOME";
97
98/// Explicit development escape hatch for object-store URLs on loopback/private
99/// networks. Production hubs must return public HTTPS URLs. A local loopback hub
100/// is allowed to use local object URLs without this switch.
101pub const ALLOW_PRIVATE_OBJECT_URL_ENV: &str = "DBMD_ALLOW_PRIVATE_OBJECT_URL";
102
103/// Environment variable naming the PATH of a self-custodied BRAIN key file
104/// (link.md §2.4). When set, `sync --push` signs each feed entry locally and
105/// ships it through the pack flow — the hub verifies and stores the exact
106/// client bytes and can never sign for the brain. Same file format as agent
107/// keys (`dbmd key generate`).
108pub const BRAIN_KEY_FILE_ENV: &str = "DBMD_BRAIN_KEY_FILE";
109
110/// Environment variable naming the PATH of an agent signing key file
111/// (link.md §8 `LinkMD-Sig` proof of possession). When set, authenticated
112/// requests are signed per-request with the agent's Ed25519 key instead of
113/// carrying a bearer: the signature binds method + path + body + a ±60s
114/// window, so nothing reusable ever crosses the wire or lands in a log or an
115/// agent transcript. The file holds the base64url PKCS#8 key minted by
116/// `dbmd key generate`; the path is not a secret, the file is (mode 0600).
117pub const AGENT_KEY_FILE_ENV: &str = "DBMD_AGENT_KEY_FILE";
118
119/// The store-local config file, relative to the store root. Holds non-secret
120/// toolkit state (`hub = <URL>`); hidden, so every store walk skips it.
121pub const CONFIG_REL_PATH: &str = ".dbmd/config";
122
123/// Control-plane JSON is metadata, never the store itself. Exact snapshot
124/// bytes travel through the separately bounded pack lane.
125const MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024;
126/// A feed page can legitimately carry a large snapshot manifest, but remains
127/// far below a full pack and is parsed with count-bounded sequence visitors.
128const MAX_FEED_RESPONSE_BYTES: u64 = 16 * 1024 * 1024;
129/// Foreign registry cards are identity metadata only.
130const MAX_REGISTRY_CARD_BYTES: u64 = 1024 * 1024;
131
132/// Direct JSON pushes stay below the serverless request-body cap. Larger
133/// snapshots switch to the bounded object-store pack lane.
134const MAX_PUSH_BYTES: usize = 4 * 1024 * 1024;
135/// Ceiling for a staged change manifest. Mirrors the hub's bound so an
136/// oversized push is refused before it stages anything.
137const MAX_STAGED_CHANGE_BYTES: usize = 64 * 1024 * 1024;
138
139/// Canonical raw ZIP32 uses a u16 entry count and stores each UTF-8 name twice.
140const MAX_PUSH_FILES: usize = u16::MAX as usize;
141const MAX_STORE_PATH_BYTES: usize = 1_024;
142const MAX_STORE_BYTES: u64 = 512 * 1024 * 1024;
143/// Assets travel outside the Markdown store/pack lane and have their own
144/// protocol ceiling. Keep this distinct from `MAX_STORE_BYTES`: applying the
145/// pack bound to a signed asset leaf makes an otherwise valid checkout unable
146/// to load its own saved sync baseline after the first commit.
147const MAX_ASSET_BYTES: u64 = 2 * 1024 * 1024 * 1024;
148/// Exact worst case for the canonical STORED profile:
149/// payload + (local 30 + central 46 + name twice) per entry + EOCD 22.
150const MAX_PACK_BYTES: u64 =
151    MAX_STORE_BYTES + MAX_PUSH_FILES as u64 * (76 + 2 * MAX_STORE_PATH_BYTES) as u64 + 22;
152/// An upload reservation body carries one entry per changed blob, so a real
153/// store's first push does not fit in a single request: the hub bounds the body
154/// and the hosting platform rejects an oversized payload before the route sees
155/// it. Reservations are therefore batched. The budget is deliberately well
156/// under the hub's own ceiling, and both bounds are enforced because a single
157/// hash can carry many coordinates and a path may be long.
158/// The count matches the reservation window a hub grants per request; batching
159/// above it only fails deeper in, after the body has already been accepted.
160const MAX_UPLOAD_RESERVATION_BYTES: usize = 1024 * 1024;
161const MAX_UPLOAD_RESERVATION_BLOBS: usize = 1_000;
162
163/// A legitimate brain should rotate rarely. Bound adversarial identity
164/// histories before allocating and repeatedly verifying an unbounded chain.
165const MAX_IDENTITY_ROTATIONS: usize = 1_024;
166
167/// Split blob declarations into request-sized batches, by serialized size and
168/// by count. Order is preserved so a caller can concatenate the responses.
169fn batch_upload_declarations(declarations: Vec<Value>) -> Vec<Vec<Value>> {
170    let mut batches: Vec<Vec<Value>> = Vec::new();
171    let mut current: Vec<Value> = Vec::new();
172    let mut current_bytes = 0usize;
173    for declaration in declarations {
174        let declared_bytes = serde_json::to_string(&declaration)
175            .map(|text| text.len())
176            .unwrap_or(MAX_UPLOAD_RESERVATION_BYTES)
177            + 1;
178        if !current.is_empty()
179            && (current.len() >= MAX_UPLOAD_RESERVATION_BLOBS
180                || current_bytes + declared_bytes > MAX_UPLOAD_RESERVATION_BYTES)
181        {
182            batches.push(std::mem::take(&mut current));
183            current_bytes = 0;
184        }
185        current_bytes += declared_bytes;
186        current.push(declaration);
187    }
188    if !current.is_empty() {
189        batches.push(current);
190    }
191    batches
192}
193/// A client never needs to replay an unbounded feed in one invocation. Large
194/// histories are mirrored incrementally; a first mirror beyond this cap needs a
195/// checkpoint/export rather than allocating attacker-controlled metadata.
196const MAX_FEED_REPLAY_ENTRIES: u64 = 100_000;
197const MAX_FEED_REPLAY_BYTES: u64 = 64 * 1024 * 1024;
198const FEED_PAGE_LIMIT: usize = 100;
199
200/// The hub's inbox cap on one `propose` submission body, mirrored client-side
201/// so an oversized body fails before the upload, not after (the same
202/// fail-before-upload contract as the push caps). Public so the CLI can
203/// pre-check a `--body-file` from file metadata without reading it.
204pub const MAX_PROPOSE_BYTES: u64 = 16 * 1024;
205
206/// Bounded connect so a dead hub fails fast; a generous read window so a
207/// large export on a slow link still completes.
208const CONNECT_TIMEOUT_SECS: u64 = 10;
209const READ_TIMEOUT_SECS: u64 = 120;
210/// Hard wall-clock budget for one HTTP attempt, spanning socket writes,
211/// response headers, and the complete response body. DNS performed for pinned
212/// agents has its own interruptible deadline below.
213const OVERALL_REQUEST_TIMEOUT_SECS: u64 = 120;
214/// A commit of a whole store is brain-sized work the hub is allowed to spend
215/// its full route budget on, so the client has to outwait the server rather
216/// than the other way round. At the default 120 seconds a first push of a real
217/// store abandoned a commit the hub was still completing and reported a
218/// transport failure for work that had not failed. This sits above the hub's
219/// own genesis-commit ceiling so the client always learns the outcome.
220const COMMIT_REQUEST_TIMEOUT_SECS: u64 = 900;
221/// A commit is idempotent by mutation coordinate, so a lost response is worth
222/// asking after — and the work it lost the receipt for may still be finishing,
223/// which is why the waits are long enough to let a brain-sized commit land.
224const COMMIT_ATTEMPTS: usize = 4;
225const COMMIT_RETRY_BACKOFF_MS: [u64; COMMIT_ATTEMPTS - 1] = [5_000, 20_000, 45_000];
226const CONNECT_ATTEMPTS: usize = 3;
227const CONNECT_RETRY_BACKOFF_MS: [u64; CONNECT_ATTEMPTS - 1] = [100, 300];
228
229/// Staging a real store means tens of thousands of immutable object PUTs in a
230/// row. A single refusal under sustained load must not fail the migration, so
231/// the upload lane retries further and waits longer than a connect blip does.
232const UPLOAD_ATTEMPTS: usize = 6;
233const UPLOAD_RETRY_BACKOFF_MS: [u64; UPLOAD_ATTEMPTS - 1] = [200, 600, 1_500, 3_000, 6_000];
234/// Retries share one wall-clock budget. Without this outer deadline, one
235/// black-holed object can consume the per-attempt timeout six times, outlive
236/// its presigned capability, and make a large migration appear hung.
237const UPLOAD_TOTAL_TIMEOUT_SECS: u64 = 300;
238
239fn upload_retry_backoff_ms(attempt: usize) -> u64 {
240    UPLOAD_RETRY_BACKOFF_MS[attempt.min(UPLOAD_RETRY_BACKOFF_MS.len() - 1)]
241}
242
243fn upload_deadline_error() -> LinkError {
244    LinkError::Transport {
245        hub: "the object store".to_string(),
246        message: "network error (upload deadline exceeded)".to_string(),
247    }
248}
249
250fn upload_attempt_timeout(deadline: std::time::Instant) -> LinkResult<std::time::Duration> {
251    let remaining = deadline.saturating_duration_since(std::time::Instant::now());
252    if remaining.is_zero() {
253        return Err(upload_deadline_error());
254    }
255    Ok(remaining.min(std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS)))
256}
257
258fn wait_for_upload_retry(deadline: std::time::Instant, attempt: usize) -> bool {
259    if attempt + 1 >= UPLOAD_ATTEMPTS {
260        return false;
261    }
262    let pause = std::time::Duration::from_millis(upload_retry_backoff_ms(attempt));
263    if deadline.saturating_duration_since(std::time::Instant::now()) <= pause {
264        return false;
265    }
266    std::thread::sleep(pause);
267    true
268}
269
270/// A migration opens dozens of staging windows back to back, so it is ordinary
271/// for a shared limiter or a briefly busy hub to answer one of them with 429 or
272/// 5xx. Failing the push there throws away every byte already staged, and the
273/// waits have to reach past a whole limiter window to be worth anything.
274const RESERVATION_ATTEMPTS: usize = 7;
275const RESERVATION_BACKOFF_MS: [u64; RESERVATION_ATTEMPTS - 1] =
276    [500, 2_000, 5_000, 15_000, 30_000, 60_000];
277
278/// Answers a busy or briefly unhealthy hub gives, as opposed to a refusal that
279/// would repeat identically. A 4xx other than these states something about the
280/// request itself and is never retried.
281fn is_retryable_hub_status(status: u16) -> bool {
282    matches!(status, 408 | 429 | 500 | 502 | 503 | 504)
283}
284
285/// Statuses worth replaying an immutable, content-addressed upload for: edge
286/// and object-store refusals that are load-shaped rather than a statement about
287/// these bytes. A checksum or precondition failure is never retried.
288fn is_retryable_upload_status(status: u16) -> bool {
289    matches!(status, 400 | 408 | 429 | 500 | 502 | 503 | 504)
290}
291/// Cold checkouts may need every visible blob, but short-lived direct object
292/// capabilities must not create an unbounded connection storm. Keep the
293/// verified downloads inside this fixed worker count.
294const V2_BLOB_DOWNLOAD_WORKERS: usize = 16;
295/// File durability barriers are independent inside the private pull stage.
296/// Keep enough in flight to hide storage flush latency without approaching
297/// ordinary process descriptor limits.
298#[cfg(unix)]
299const V2_PULL_INSTALL_WORKERS: usize = 16;
300/// One authenticated link.md v2 bulk frame remains small enough for bounded
301/// retries and serverless streaming while amortizing tens of thousands of
302/// immutable blobs over a few hundred requests instead of one request each.
303const V2_BULK_STREAM_FILES: usize = 256;
304const V2_BULK_STREAM_CONTENT_BYTES: u64 = 8 * 1024 * 1024;
305const V2_BULK_STREAM_RESPONSE_BYTES: u64 = 10 * 1024 * 1024;
306const V2_BULK_STREAM_MAGIC: &[u8; 8] = b"LMD2STRM";
307
308/// Everything that can go wrong on the wire or at its edges. Each variant maps
309/// onto one stable CLI error code; messages are single-line and never echo the
310/// credential.
311#[derive(Debug, thiserror::Error)]
312pub enum LinkError {
313    /// No hub URL was configured anywhere (flag, env, `.dbmd/config`).
314    #[error(
315        "no hub configured — pass --hub <URL>, set {HUB_URL_ENV}, or add `hub = <URL>` to {CONFIG_REL_PATH}"
316    )]
317    NoHub,
318
319    /// The verb needs a credential and none was present.
320    #[error("no hub credential — set {HUB_KEY_ENV} (credentials never live in {CONFIG_REL_PATH})")]
321    NoCredential,
322
323    /// The credential contains whitespace / non-ASCII (a paste artifact). The
324    /// key is deliberately not echoed.
325    #[error(
326        "the hub credential in {HUB_KEY_ENV} contains whitespace or non-ASCII characters — re-copy it (the key is not shown here on purpose)"
327    )]
328    BadKey,
329
330    /// A store selected the destination while an ambient credential was
331    /// present, but the operator did not bind that credential to the same
332    /// origin. This is a hard refusal, not an anonymous fallback: silently
333    /// dropping a credential can turn an intended private operation into a
334    /// confusing public one.
335    #[error(
336        "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}"
337    )]
338    UnboundCredential,
339
340    /// The agent signing key file named by [`AGENT_KEY_FILE_ENV`] is missing,
341    /// unreadable, or not a valid Ed25519 PKCS#8 — key material is never
342    /// echoed.
343    #[error("invalid agent signing key ({message}) — mint one with `dbmd key generate`")]
344    BadAgentKey {
345        /// What failed, without any key material.
346        message: String,
347    },
348
349    /// A non-HTTPS hub outside loopback: the bearer key would travel in cleartext.
350    #[error("refusing non-HTTPS hub {hub} — the credential would travel in cleartext (localhost is exempt)")]
351    UnsafeHub {
352        /// The offending hub URL.
353        hub: String,
354    },
355
356    /// TCP/TLS-level failure: the hub never answered.
357    #[error("hub unreachable at {hub}: {message}")]
358    Transport {
359        /// The hub base URL.
360        hub: String,
361        /// The transport-layer error text.
362        message: String,
363    },
364
365    /// The hub answered with an HTTP error status.
366    #[error("{what} failed (HTTP {status}): {message}")]
367    Http {
368        /// What the client was doing (e.g. `"resolve"`, `"sync pull"`).
369        what: &'static str,
370        /// The HTTP status code.
371        status: u16,
372        /// The hub's own `error` string when it sent one, else a placeholder.
373        message: String,
374        /// The hub's machine `code` field when it sent one.
375        code: Option<String>,
376        /// Permission-filtered structured refusal details, when supplied.
377        details: Option<Value>,
378    },
379
380    /// A 2xx whose body is not JSON — a captive portal, a proxy, or a wrong
381    /// URL — refused here rather than deserializing into nothing downstream.
382    #[error("{what}: the hub answered HTTP {status} with a non-JSON body — check the hub URL")]
383    NotJson {
384        /// What the client was doing.
385        what: &'static str,
386        /// The (2xx) status that carried the non-JSON body.
387        status: u16,
388    },
389
390    /// The hub response exceeded the selected endpoint's byte cap.
391    #[error("hub response exceeded the {limit_bytes}-byte endpoint cap — refusing to buffer it")]
392    ResponseTooLarge {
393        /// The endpoint-specific cap applied before JSON parsing.
394        limit_bytes: u64,
395    },
396
397    /// A malformed `@brain/id` address.
398    #[error("invalid address `{given}`: {reason}")]
399    BadAddress {
400        /// The raw address as typed.
401        given: String,
402        /// Why it did not parse.
403        reason: String,
404    },
405
406    /// A grant id whose shape cannot travel as a URL path segment.
407    #[error(
408        "invalid grant id `{given}` — grant ids come from `grant list` (lowercase letters, digits, hyphens)"
409    )]
410    BadGrantId {
411        /// The raw id as typed.
412        given: String,
413    },
414
415    /// An exported file path that would escape or pollute the destination
416    /// (absolute, `..`, a dot-leading segment, or an illegal character). The
417    /// hub is not trusted with local path layout.
418    #[error("refusing unsafe path from the hub: `{path}`")]
419    UnsafePath {
420        /// The offending path as received.
421        path: String,
422    },
423
424    /// The store exceeds the hub's bounded whole-snapshot caps.
425    #[error(
426        "push too large ({detail}) — one snapshot caps at {} MB uncompressed, {} MB as a pack, and {MAX_PUSH_FILES} files",
427        MAX_STORE_BYTES / (1024 * 1024),
428        MAX_PACK_BYTES / (1024 * 1024)
429    )]
430    PushTooLarge {
431        /// Which cap was hit, human-readable.
432        detail: String,
433    },
434
435    /// The propose body exceeds the hub's inbox cap.
436    #[error(
437        "propose body too large ({bytes} bytes) — the hub's inbox caps one submission at {} KB",
438        MAX_PROPOSE_BYTES / 1024
439    )]
440    ProposeTooLarge {
441        /// The offending body size in bytes.
442        bytes: u64,
443    },
444
445    /// A store file that is not valid UTF-8 cannot travel the JSON push path.
446    #[error("store file `{path}` is not valid UTF-8 — the JSON push path carries text only")]
447    NotUtf8 {
448        /// The store-relative path of the offending file.
449        path: String,
450    },
451
452    /// A downloaded pack failed validation before any local write.
453    #[error("invalid store pack: {message}")]
454    InvalidPack {
455        /// Hash, ZIP, path, count, or expansion failure.
456        message: String,
457    },
458
459    /// A signed feed entry, hash chain, or advertised feed head did not verify.
460    #[error("invalid signed feed: {message}")]
461    InvalidFeed {
462        /// The failed integrity condition, without untrusted secret material.
463        message: String,
464    },
465
466    /// A mutable alias moved to another canonical brain. Ordinary sync fails
467    /// closed; only the exact old/new ids can authorize replacing that one
468    /// alias binding while preserving both canonical trust histories.
469    #[error(
470        "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}`"
471    )]
472    AliasRebindRequired {
473        alias: String,
474        from: String,
475        to: String,
476    },
477
478    /// Local and remote both changed one or more coordinates since the last
479    /// verified sync baseline. No side was overwritten.
480    #[error("sync conflict on {paths:?} — resolve the named files and retry")]
481    Conflict {
482        /// Bounded, portable paths safe to show to the agent/operator.
483        paths: Vec<String>,
484    },
485
486    /// A readable v2 conflict was preserved as a private, exact-head bundle.
487    /// The bundle is local control state; none of its bytes ride with the
488    /// brain, and resolving it always creates a fresh explicit mutation.
489    #[error(
490        "sync conflict preserved in private bundle `{bundle}` for {paths:?} — run `dbmd sync resolve {bundle} --keep-local`, `--take-remote`, or `--from <safe-file>`"
491    )]
492    ConflictBundle {
493        /// Local ULID naming `.dbmd/conflicts/<bundle>/plan.json`.
494        bundle: String,
495        /// Bounded, readable conflict paths.
496        paths: Vec<String>,
497    },
498
499    /// `.sevralocal` newly made paths eligible to ride. Uploading them is an
500    /// explicit adoption boundary, never an accidental side effect of editing
501    /// or removing a local policy file.
502    #[error(
503        "local sync policy newly exposes {paths:?} — review and retry with --resume-local-policy"
504    )]
505    LocalPolicyTransition {
506        /// Bounded local-only paths. They are never sent in telemetry.
507        paths: Vec<String>,
508    },
509
510    /// The exact mutation crosses a permissioned bulk-impact boundary. The
511    /// hub has not committed it; `preview` is the bounded, permission-filtered
512    /// receipt an agent must inspect before explicitly confirming the same
513    /// request.
514    #[error(
515        "bulk change requires explicit confirmation — review the preview and retry the same sync with --confirm-bulk <id>:<digest>"
516    )]
517    BulkPreviewRequired {
518        /// Stable structured preview returned by the hub.
519        preview: Value,
520    },
521
522    /// A scoped checkout's generated store marker was edited or removed.
523    /// It is local projection metadata and is never accepted as brain data.
524    #[error(
525        "the generated DB.md for this scoped view was modified — clone a fresh scoped checkout"
526    )]
527    ScopedProjectionModified,
528
529    /// The effective permission slice changed after this checkout was pinned.
530    /// Reusing the directory could conceal removals or accidentally adopt files
531    /// revealed by a wider grant, so a new checkout is required.
532    #[error(
533        "the checkout's permission scope changed — clone into a new directory to accept the new view"
534    )]
535    ScopedViewChanged,
536
537    /// A v2 identity/head was previously accepted for this ref, but the hub
538    /// now hides it. Never reinterpret that as a v1 downgrade.
539    #[error("the previously verified brain is unavailable — access may have been revoked or the brain removed")]
540    BrainUnavailable,
541
542    /// The verified remote head advanced while a pull or post-commit barrier
543    /// was in flight. The old baseline is deliberately retained.
544    #[error(
545        "the remote brain advanced during sync — retry to converge from the new verified head"
546    )]
547    RemoteAdvancedDuringSync,
548
549    /// This build cannot provide the no-follow, directory-handle-relative
550    /// filesystem semantics required for trust/key/snapshot state.
551    #[error(
552        "{operation} is unavailable on this platform — use the official macOS/Linux build or WSL"
553    )]
554    UnsupportedPlatform {
555        /// The operation that requires hardened local filesystem primitives.
556        operation: &'static str,
557    },
558
559    /// Local filesystem failure while materializing a pull or reading a push.
560    #[error(transparent)]
561    Io(#[from] std::io::Error),
562
563    /// A store-level failure (walking the local store for a push).
564    #[error(transparent)]
565    Store(#[from] crate::StoreError),
566}
567
568/// Result alias for link.md client operations.
569pub type LinkResult<T> = std::result::Result<T, LinkError>;
570
571/// Exact approval token returned by a permissioned v2 bulk preview.
572#[derive(Debug, Clone, PartialEq, Eq)]
573pub struct V2BulkConfirmation {
574    /// Opaque preview receipt id.
575    pub id: String,
576    /// Digest binding the receipt to principal, head, control revision, and
577    /// the exact strict mutation.
578    pub digest: String,
579}
580
581impl V2BulkConfirmation {
582    /// Parse the CLI/wire handoff form `<id>:<digest>` and reject anything
583    /// outside the protocol's exact lowercase shapes before network I/O.
584    pub fn parse(value: &str) -> LinkResult<Self> {
585        let (id, digest) = value
586            .split_once(':')
587            .ok_or_else(|| LinkError::InvalidPack {
588                message: "bulk confirmation must be <id>:<digest>".to_string(),
589            })?;
590        if !crate::ulid::is_ulid(id) || !is_sha256(digest) {
591            return Err(LinkError::InvalidPack {
592                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
593                    .to_string(),
594            });
595        }
596        Ok(Self {
597            id: id.to_string(),
598            digest: digest.to_string(),
599        })
600    }
601}
602
603/// Security-sensitive link.md state and destination writes require a hardened
604/// descriptor/handle-relative filesystem implementation. Unix uses
605/// `openat`/`O_NOFOLLOW`; Windows uses held directory handles, reparse-point
606/// refusal, exclusive share modes, and write-through replacement.
607fn require_hardened_filesystem(operation: &'static str) -> LinkResult<()> {
608    #[cfg(any(target_os = "linux", target_os = "macos", windows))]
609    {
610        let _ = operation;
611        Ok(())
612    }
613    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
614    {
615        Err(LinkError::UnsupportedPlatform { operation })
616    }
617}
618
619// ─────────────────────────────────────────────────────────────────────────────
620// Addressing — `@brain[/id]`, the reserved shape (SPEC § Addressing)
621// ─────────────────────────────────────────────────────────────────────────────
622
623/// What the part after `@brain/` names.
624#[derive(Debug, Clone, PartialEq, Eq)]
625pub enum AddressTarget {
626    /// A record `id` — the db.md lowercase ULID (the reserved `@brain/id` shape).
627    Id(String),
628    /// A store-relative `.md` path — a client-side convenience the hub's
629    /// resolve endpoint also accepts (`?path=`). Not part of the reserved
630    /// shape; unambiguous because a ULID is never a path.
631    Path(String),
632}
633
634/// Why a brain reference failed [`is_safe_ref`] — shared by [`Address::parse`]
635/// and the per-verb entry gates so the two surfaces never drift.
636const BAD_BRAIN_REASON: &str =
637    "the brain reference must be a brain id (lowercase ULID) or a slug (lowercase letters, digits, hyphens)";
638
639/// Why an address target failed its shape check — shared by [`Address::parse`]
640/// and the [`resolve`] entry gate.
641const BAD_TARGET_REASON: &str =
642    "the part after `/` must be a record id (lowercase ULID) or a store-relative `.md` path";
643
644/// A parsed `@brain[/target]` address. `brain` is a hub brain reference — the
645/// brain's ULID id (works for any caller, including cross-party on a public
646/// brain) or a slug (which a hub resolves only against the caller's own
647/// brains; slugs are unique per owner, not globally).
648#[derive(Debug, Clone, PartialEq, Eq)]
649pub struct Address {
650    /// The brain reference (leading `@` stripped).
651    pub brain: String,
652    /// The record target, when the address names one.
653    pub target: Option<AddressTarget>,
654}
655
656impl Address {
657    /// Parse `@brain`, `@brain/<ulid>`, or `@brain/<store-path>.md`. The `@`
658    /// sigil is optional (an agent piping ids around should not have to quote
659    /// it back on). Whitespace and empty segments are malformed.
660    pub fn parse(raw: &str) -> LinkResult<Address> {
661        let bad = |reason: &str| LinkError::BadAddress {
662            given: raw.to_string(),
663            reason: reason.to_string(),
664        };
665
666        let trimmed = raw.trim();
667        let body = trimmed.strip_prefix('@').unwrap_or(trimmed);
668        if body.is_empty() {
669            return Err(bad("empty address"));
670        }
671
672        let (brain, rest) = match body.split_once('/') {
673            Some((b, r)) => (b, Some(r)),
674            None => (body, None),
675        };
676
677        if brain.is_empty() {
678            return Err(bad("missing brain reference before `/`"));
679        }
680        if !is_safe_ref(brain) {
681            return Err(bad(BAD_BRAIN_REASON));
682        }
683
684        let target = match rest {
685            None => None,
686            Some("") => return Err(bad("trailing `/` with no record id or path")),
687            Some(r) if crate::ulid::is_ulid(r) => Some(AddressTarget::Id(r.to_string())),
688            Some(r) => {
689                if !safe_store_rel_path(r) || !r.ends_with(".md") {
690                    return Err(bad(BAD_TARGET_REASON));
691                }
692                Some(AddressTarget::Path(r.to_string()))
693            }
694        };
695
696        Ok(Address {
697            brain: brain.to_string(),
698            target,
699        })
700    }
701}
702
703/// A brain reference safe to embed in a URL path segment: the shapes a hub
704/// accepts (ULID id or slug), which are also exactly URL-path-clean.
705fn is_safe_ref(s: &str) -> bool {
706    !s.is_empty()
707        && s.len() <= 64
708        && s.bytes()
709            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
710}
711
712/// A published-site handle (the `propose` target). Same lexical shape as a
713/// slug.
714pub fn is_valid_handle(s: &str) -> bool {
715    is_safe_ref(s)
716}
717
718/// True when `p` is a store-relative path this client will read from or write
719/// to disk: relative, no `..`, no empty or dot-leading segment (which shields
720/// `.dbmd/` and `.git/`), and only the hub-portable character set. Applied to
721/// every path an export hands us (the hub is not trusted with local layout)
722/// and to every path a push sends (mirroring the hub's own gate).
723pub fn safe_store_rel_path(p: &str) -> bool {
724    if p.is_empty() || p.len() > MAX_STORE_PATH_BYTES || p.starts_with('/') {
725        return false;
726    }
727    if !p
728        .bytes()
729        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-' | b'/'))
730    {
731        return false;
732    }
733    p.split('/')
734        .all(|seg| !seg.is_empty() && seg != "." && seg != ".." && !seg.starts_with('.'))
735}
736
737/// Entry gate for every verb that embeds a caller-supplied brain reference in
738/// a URL path segment. `resolve` reaches the same check through
739/// [`Address::parse`]; the raw-ref verbs (`sync`, `grant`, `subscribe`) call
740/// this directly, so a ref carrying `/`, `..`, `?`, `#`, or any other
741/// URL-reshaping byte is refused before a request exists (the `url` crate
742/// normalizes dot segments, so an unvalidated ref would redirect the
743/// authenticated request to a different hub path).
744fn require_safe_ref(brain: &str) -> LinkResult<()> {
745    if is_safe_ref(brain) {
746        Ok(())
747    } else {
748        Err(LinkError::BadAddress {
749            given: brain.to_string(),
750            reason: BAD_BRAIN_REASON.to_string(),
751        })
752    }
753}
754
755/// Entry gate for the published-site handle `propose` embeds in its URL path.
756fn require_valid_handle(handle: &str) -> LinkResult<()> {
757    if is_valid_handle(handle) {
758        Ok(())
759    } else {
760        Err(LinkError::BadAddress {
761            given: handle.to_string(),
762            reason: "the site handle must be lowercase letters, digits, hyphens".to_string(),
763        })
764    }
765}
766
767/// Entry gate for the grant id `grant revoke` embeds in its URL path. Hub
768/// grant ids are lowercase ULIDs; the gate accepts the same URL-path-clean
769/// shape as a brain ref rather than pinning one mint scheme.
770fn require_safe_grant_id(id: &str) -> LinkResult<()> {
771    if is_safe_ref(id) {
772        Ok(())
773    } else {
774        Err(LinkError::BadGrantId {
775            given: id.to_string(),
776        })
777    }
778}
779
780// ─────────────────────────────────────────────────────────────────────────────
781// Configuration — flag > env > .dbmd/config; credential from env only
782// ─────────────────────────────────────────────────────────────────────────────
783
784/// The resolved client configuration for one invocation.
785#[derive(Debug, Clone)]
786pub struct HubConfig {
787    /// The hub base URL, trailing slash stripped, HTTPS-or-loopback enforced.
788    pub hub: String,
789    /// The bearer credential, when the environment carries one.
790    pub key: Option<String>,
791    /// The agent signing key, when [`AGENT_KEY_FILE_ENV`] names one. Wins
792    /// over the bearer for authenticated requests (link.md §8).
793    pub agent_key: Option<AgentSigningKey>,
794    /// The self-custodied brain signing key, when [`BRAIN_KEY_FILE_ENV`]
795    /// names one — `sync --push` then signs feed entries locally (§2.4).
796    pub brain_key: Option<AgentSigningKey>,
797    /// User-owned global toolkit state root. Identity pins and monotonic feed
798    /// checkpoints live below `<state_dir>/trust/`, never under a store.
799    pub state_dir: PathBuf,
800    /// True only when the origin came from untrusted store-local configuration.
801    /// Such origins are resolved and public-IP-pinned for every request.
802    store_selected: bool,
803}
804
805/// A loaded agent signing key: the PKCS#8 secret plus its derived public
806/// multikey. Debug never prints key material.
807#[derive(Clone)]
808pub struct AgentSigningKey {
809    pkcs8: Vec<u8>,
810    /// The key's public identity, `ed25519:<base64url sha256(SPKI)>`.
811    pub multikey: String,
812    /// The full public key, `base64url(SPKI DER)` — what feed entries carry.
813    pub public_key_spki: String,
814}
815
816impl std::fmt::Debug for AgentSigningKey {
817    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
818        f.debug_struct("AgentSigningKey")
819            .field("multikey", &self.multikey)
820            .field("pkcs8", &"<redacted>")
821            .finish()
822    }
823}
824
825impl HubConfig {
826    /// The credential, or the canonical "not configured" error. Verbs that
827    /// authenticate call this; `propose` never does.
828    pub fn require_key(&self) -> LinkResult<&str> {
829        self.key.as_deref().ok_or(LinkError::NoCredential)
830    }
831}
832
833/// Resolve the client configuration: `flag_hub` beats [`HUB_URL_ENV`] beats
834/// the `hub =` line in `<dir>/.dbmd/config`; no fallback default exists. The
835/// credential comes from [`HUB_KEY_ENV`] alone and is validated as a clean
836/// header token (never echoed on failure).
837pub fn hub_config(flag_hub: Option<&str>, dir: &Path) -> LinkResult<HubConfig> {
838    let explicit_hub = flag_hub
839        .map(str::to_string)
840        .or_else(|| env_nonempty(HUB_URL_ENV));
841    let selected_by_store = explicit_hub.is_none();
842    let hub = explicit_hub
843        .or_else(|| config_file_hub(&dir.join(CONFIG_REL_PATH)))
844        .ok_or(LinkError::NoHub)?;
845    let hub = hub.trim().trim_end_matches('/').to_string();
846    assert_safe_hub(&hub)?;
847    if selected_by_store {
848        let parsed =
849            url::Url::parse(&hub).map_err(|_| LinkError::UnsafeHub { hub: hub.clone() })?;
850        // A cloned store is not an operator opt-in to local-network access.
851        // Local/private development hubs must be selected explicitly by flag or
852        // environment, never by bytes inside the store.
853        if !parsed.scheme().eq_ignore_ascii_case("https")
854            || (parsed.path() != "/" && !parsed.path().is_empty())
855        {
856            return Err(LinkError::UnsafeHub { hub });
857        }
858    }
859
860    let key = match env_nonempty(HUB_KEY_ENV) {
861        Some(raw) => Some(clean_key(&raw)?),
862        None => None,
863    };
864
865    let agent_key = match env_nonempty(AGENT_KEY_FILE_ENV) {
866        Some(path) => Some(load_agent_key(Path::new(&path))?),
867        None => None,
868    };
869
870    let brain_key = match env_nonempty(BRAIN_KEY_FILE_ENV) {
871        Some(path) => Some(load_agent_key(Path::new(&path))?),
872        None => None,
873    };
874
875    // A cloned store controls `.dbmd/config`. It must never be able to point
876    // the process at an attacker origin and harvest the user's ambient account
877    // bearer, signed agent identity, or self-custodied brain signatures/content.
878    // Explicit --hub/DBMD_HUB_URL selection already pairs target + credential
879    // in the invocation environment; a store-selected target additionally
880    // needs an exact origin binding.
881    if selected_by_store && (key.is_some() || agent_key.is_some() || brain_key.is_some()) {
882        let bound = env_nonempty(HUB_CREDENTIAL_ORIGIN_ENV)
883            .and_then(|value| normalized_origin(&value).ok());
884        let selected_origin = normalized_origin(&hub)?;
885        if bound.as_deref() != Some(selected_origin.as_str()) {
886            return Err(LinkError::UnboundCredential);
887        }
888    }
889
890    Ok(HubConfig {
891        hub,
892        key,
893        agent_key,
894        brain_key,
895        state_dir: toolkit_state_dir()?,
896        store_selected: selected_by_store,
897    })
898}
899
900fn toolkit_state_dir() -> LinkResult<PathBuf> {
901    if let Some(path) = env_nonempty(STATE_DIR_ENV) {
902        let path = PathBuf::from(path);
903        if !path.is_absolute() {
904            return Err(LinkError::UnsafePath {
905                path: path.display().to_string(),
906            });
907        }
908        return Ok(path);
909    }
910    #[cfg(windows)]
911    if let Some(base) = env_nonempty("LOCALAPPDATA") {
912        let base = PathBuf::from(base);
913        if base.is_absolute() {
914            return Ok(base.join("dbmd").join("state"));
915        }
916    }
917    #[cfg(windows)]
918    {
919        Err(LinkError::Io(std::io::Error::new(
920            std::io::ErrorKind::NotFound,
921            format!("cannot locate user state; set {STATE_DIR_ENV} or LOCALAPPDATA"),
922        )))
923    }
924    #[cfg(not(windows))]
925    if let Some(base) = env_nonempty("XDG_STATE_HOME") {
926        let base = PathBuf::from(base);
927        if base.is_absolute() {
928            return Ok(base.join("dbmd"));
929        }
930    }
931    #[cfg(not(windows))]
932    let home = PathBuf::from(env_nonempty("HOME").ok_or_else(|| {
933        LinkError::Io(std::io::Error::new(
934            std::io::ErrorKind::NotFound,
935            format!("cannot locate user state; set {STATE_DIR_ENV}"),
936        ))
937    })?);
938    #[cfg(not(windows))]
939    if !home.is_absolute() {
940        return Err(LinkError::UnsafePath {
941            path: home.display().to_string(),
942        });
943    }
944    #[cfg(target_os = "macos")]
945    {
946        Ok(home
947            .join("Library")
948            .join("Application Support")
949            .join("dbmd")
950            .join("state"))
951    }
952    #[cfg(all(not(target_os = "macos"), not(windows)))]
953    {
954        Ok(home.join(".local").join("state").join("dbmd"))
955    }
956}
957
958fn normalized_origin(value: &str) -> LinkResult<String> {
959    let parsed = url::Url::parse(value).map_err(|_| LinkError::UnsafeHub {
960        hub: value.to_string(),
961    })?;
962    if !(parsed.scheme().eq_ignore_ascii_case("https")
963        || parsed.scheme().eq_ignore_ascii_case("http"))
964        || !parsed.username().is_empty()
965        || parsed.password().is_some()
966        || (parsed.path() != "/" && !parsed.path().is_empty())
967        || parsed.query().is_some()
968        || parsed.fragment().is_some()
969    {
970        return Err(LinkError::UnsafeHub {
971            hub: value.to_string(),
972        });
973    }
974    let host = parsed.host_str().ok_or_else(|| LinkError::UnsafeHub {
975        hub: value.to_string(),
976    })?;
977    let host = if host.contains(':') {
978        format!("[{host}]")
979    } else {
980        host.to_ascii_lowercase()
981    };
982    let port = parsed
983        .port_or_known_default()
984        .ok_or_else(|| LinkError::UnsafeHub {
985            hub: value.to_string(),
986        })?;
987    let default = (parsed.scheme().eq_ignore_ascii_case("https") && port == 443)
988        || (parsed.scheme().eq_ignore_ascii_case("http") && port == 80);
989    Ok(format!(
990        "{}://{}{}",
991        parsed.scheme().to_ascii_lowercase(),
992        host,
993        if default {
994            String::new()
995        } else {
996            format!(":{port}")
997        }
998    ))
999}
1000
1001// ─────────────────────────────────────────────────────────────────────────────
1002// Agent signing keys — link.md §8 `LinkMD-Sig` proof of possession
1003// ─────────────────────────────────────────────────────────────────────────────
1004
1005/// The DER prefix that wraps a raw Ed25519 public key into a
1006/// SubjectPublicKeyInfo (RFC 8410).
1007const ED25519_SPKI_PREFIX: [u8; 12] = [
1008    0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
1009];
1010
1011fn bad_agent_key(message: &str) -> LinkError {
1012    LinkError::BadAgentKey {
1013        message: message.to_string(),
1014    }
1015}
1016
1017fn agent_keypair(pkcs8: &[u8]) -> LinkResult<ring::signature::Ed25519KeyPair> {
1018    // `from_pkcs8` wants ring's own v2 encoding (private + public); keys from
1019    // other tools are often PKCS#8 v1, which `maybe_unchecked` accepts by
1020    // deriving the public half itself.
1021    ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8)
1022        .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(pkcs8))
1023        .map_err(|_| bad_agent_key("not an Ed25519 PKCS#8 key"))
1024}
1025
1026/// Derive `(publicKeySpki b64u, multikey)` from a keypair.
1027fn public_identity_for(pair: &ring::signature::Ed25519KeyPair) -> (String, String) {
1028    use ring::signature::KeyPair as _;
1029    let mut spki = Vec::with_capacity(44);
1030    spki.extend_from_slice(&ED25519_SPKI_PREFIX);
1031    spki.extend_from_slice(pair.public_key().as_ref());
1032    (
1033        URL_SAFE_NO_PAD.encode(&spki),
1034        format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&spki))),
1035    )
1036}
1037
1038/// Load and validate a signing-key file (agent or brain — same format):
1039/// one base64url line of PKCS#8. Public so `dbmd key rotate` can load the
1040/// old key explicitly.
1041pub fn load_signing_key(path: &Path) -> LinkResult<AgentSigningKey> {
1042    load_agent_key(path)
1043}
1044
1045/// Load and validate the agent key file: one base64url line of PKCS#8.
1046fn load_agent_key(path: &Path) -> LinkResult<AgentSigningKey> {
1047    #[cfg(unix)]
1048    let file = {
1049        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1050        use std::os::unix::ffi::OsStrExt as _;
1051        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
1052            .map_err(|e| bad_agent_key(&format!("cannot open the key parent: {e}")))?;
1053        let leaf = path
1054            .file_name()
1055            .ok_or_else(|| bad_agent_key("the key path has no file name"))?;
1056        let leaf = c_name(leaf.as_bytes(), &path.display().to_string())?;
1057        let fd = unsafe {
1058            libc::openat(
1059                parent.as_raw_fd(),
1060                leaf.as_ptr(),
1061                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1062            )
1063        };
1064        if fd < 0 {
1065            return Err(bad_agent_key(
1066                "the key path must be an existing regular file without symlink ancestors",
1067            ));
1068        }
1069        unsafe { std::fs::File::from_raw_fd(fd) }
1070    };
1071    #[cfg(not(unix))]
1072    let file = std::fs::File::open(path)
1073        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1074    let metadata = file
1075        .metadata()
1076        .map_err(|e| bad_agent_key(&format!("cannot inspect the key file: {e}")))?;
1077    if !metadata.is_file() {
1078        return Err(bad_agent_key("the key path must be a regular file"));
1079    }
1080    #[cfg(unix)]
1081    {
1082        use std::os::unix::fs::PermissionsExt as _;
1083        if metadata.permissions().mode() & 0o077 != 0 {
1084            return Err(bad_agent_key(
1085                "the key file is accessible to group/other; set mode 0600",
1086            ));
1087        }
1088    }
1089    let mut text = String::new();
1090    file.take(1024 * 1024 + 1)
1091        .read_to_string(&mut text)
1092        .map_err(|e| bad_agent_key(&format!("cannot read the key file: {e}")))?;
1093    if text.len() > 1024 * 1024 {
1094        return Err(bad_agent_key("the key file exceeds the size limit"));
1095    }
1096    let pkcs8 = URL_SAFE_NO_PAD
1097        .decode(text.trim())
1098        .map_err(|_| bad_agent_key("the key file is not one base64url line"))?;
1099    let (public_key_spki, multikey) = public_identity_for(&agent_keypair(&pkcs8)?);
1100    Ok(AgentSigningKey {
1101        pkcs8,
1102        multikey,
1103        public_key_spki,
1104    })
1105}
1106
1107/// Durably create a new secret file without an exists→write race and without
1108/// ever exposing a default-mode (commonly 0644) key between write and chmod.
1109/// `create_new` also refuses a planted symlink. The file and its parent
1110/// directory are synced before the caller can publish the corresponding
1111/// public identity remotely.
1112fn write_secret_new(path: &Path, bytes: &[u8]) -> LinkResult<()> {
1113    #[cfg(unix)]
1114    let (mut file, parent, leaf) = {
1115        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1116        use std::os::unix::ffi::OsStrExt as _;
1117        let parent = open_or_create_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))?;
1118        let leaf_name = path
1119            .file_name()
1120            .ok_or_else(|| bad_agent_key("the output key path has no file name"))?;
1121        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
1122        let fd = unsafe {
1123            libc::openat(
1124                parent.as_raw_fd(),
1125                leaf.as_ptr(),
1126                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1127                0o600,
1128            )
1129        };
1130        if fd < 0 {
1131            let error = std::io::Error::last_os_error();
1132            if error.kind() == std::io::ErrorKind::AlreadyExists {
1133                return Err(bad_agent_key(
1134                    "the output file already exists — refusing to overwrite a key",
1135                ));
1136            }
1137            return Err(error.into());
1138        }
1139        (unsafe { std::fs::File::from_raw_fd(fd) }, parent, leaf)
1140    };
1141    #[cfg(not(unix))]
1142    let mut file = std::fs::OpenOptions::new()
1143        .write(true)
1144        .create_new(true)
1145        .open(path)
1146        .map_err(|error| {
1147            if error.kind() == std::io::ErrorKind::AlreadyExists {
1148                bad_agent_key("the output file already exists — refusing to overwrite a key")
1149            } else {
1150                LinkError::Io(error)
1151            }
1152        })?;
1153    if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
1154        drop(file);
1155        #[cfg(unix)]
1156        let _ =
1157            unsafe { libc::unlinkat(std::os::fd::AsRawFd::as_raw_fd(&parent), leaf.as_ptr(), 0) };
1158        #[cfg(not(unix))]
1159        let _ = std::fs::remove_file(path);
1160        return Err(LinkError::Io(error));
1161    }
1162    drop(file);
1163    #[cfg(unix)]
1164    parent.sync_all()?;
1165    Ok(())
1166}
1167
1168/// What `dbmd key generate` returns: the public identity to register plus
1169/// where the secret landed.
1170#[derive(Debug, Serialize)]
1171pub struct GeneratedAgentKey {
1172    /// `ed25519:<fingerprint>` — the grantable/registerable identity.
1173    pub multikey: String,
1174    /// base64url SPKI DER — what a hub's register endpoint takes.
1175    #[serde(rename = "publicKeySpki")]
1176    pub public_key_spki: String,
1177    /// Where the PKCS#8 secret was written (mode 0600).
1178    #[serde(rename = "keyFile")]
1179    pub key_file: String,
1180}
1181
1182/// Mint a fresh Ed25519 agent keypair. The secret is written to `out`
1183/// (base64url PKCS#8, one line, 0600, refusing to overwrite); only public
1184/// identity is returned. The private key never enters a store and never
1185/// travels — requests carry per-request signatures instead (link.md §8).
1186pub fn generate_agent_key(out: &Path) -> LinkResult<GeneratedAgentKey> {
1187    require_hardened_filesystem("key generation")?;
1188    let rng = ring::rand::SystemRandom::new();
1189    let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
1190        .map_err(|_| bad_agent_key("key generation failed"))?;
1191    let pair = agent_keypair(pkcs8.as_ref())?;
1192    let (spki_b64u, multikey) = public_identity_for(&pair);
1193
1194    write_secret_new(
1195        out,
1196        format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
1197    )?;
1198
1199    Ok(GeneratedAgentKey {
1200        multikey,
1201        public_key_spki: spki_b64u,
1202        key_file: out.display().to_string(),
1203    })
1204}
1205
1206/// Build the origin-bound `LinkMD-Sig` v2 header for one request:
1207/// `canonical = "v2" LF origin LF METHOD LF path+query LF ts LF
1208/// (sha256hex(body) | "-")`.
1209///
1210/// The origin is derived from the already-validated hub URL, never from
1211/// attacker-controlled request metadata. Binding it closes the v1 replay
1212/// class where a proof captured by one hub could be replayed to another hub
1213/// serving the same path inside the timestamp window.
1214fn linkmd_sig_header(
1215    key: &AgentSigningKey,
1216    origin: &str,
1217    method: &str,
1218    path: &str,
1219    body: Option<&str>,
1220) -> LinkResult<String> {
1221    let ts = std::time::SystemTime::now()
1222        .duration_since(std::time::UNIX_EPOCH)
1223        .map_err(|_| bad_agent_key("system clock is before the epoch"))?
1224        .as_secs();
1225    let body_hash = match body {
1226        Some(b) => format!("{:x}", Sha256::digest(b.as_bytes())),
1227        None => "-".to_string(),
1228    };
1229    let canonical = format!(
1230        "v2\n{}\n{}\n{}\n{}\n{}",
1231        origin,
1232        method.to_uppercase(),
1233        path,
1234        ts,
1235        body_hash
1236    );
1237    let pair = agent_keypair(&key.pkcs8)?;
1238    let sig = URL_SAFE_NO_PAD.encode(pair.sign(canonical.as_bytes()).as_ref());
1239    let fingerprint = key.multikey.trim_start_matches("ed25519:");
1240    Ok(format!(
1241        "LinkMD-Sig v2,key=ed25519:{fingerprint},ts={ts},sig={sig}"
1242    ))
1243}
1244
1245// ─────────────────────────────────────────────────────────────────────────────
1246// Self-custody feed entries — the client signs what the hub only verifies
1247// ─────────────────────────────────────────────────────────────────────────────
1248
1249/// One `files` element of a wire-profile-v1 feed entry (SPEC §5.1: fields in
1250/// exactly this order).
1251#[derive(Serialize)]
1252struct WireFeedFile {
1253    path: String,
1254    sha256: String,
1255    bytes: u64,
1256}
1257
1258/// The unsigned entry in the normative §5.1 field order — serde serializes
1259/// struct fields in declaration order, which IS the wire contract.
1260#[derive(Serialize)]
1261struct UnsignedWireEntry<'a> {
1262    v: u8,
1263    seq: u64,
1264    ts: String,
1265    brain: &'a str,
1266    public_key: &'a str,
1267    kind: &'a str,
1268    op: &'a str,
1269    pack_sha256: &'a str,
1270    files: &'a [WireFeedFile],
1271    removed: &'a [String],
1272    prev_entry_hash: Option<&'a str>,
1273}
1274
1275/// Build and sign a wire-profile-v1 `push` feed entry with a self-custodied
1276/// brain key: serialize the unsigned entry compactly in the normative order,
1277/// Ed25519-sign those exact bytes, splice `sig` on as the final field. The
1278/// returned string is the exact serialization the hub stores verbatim (plus
1279/// one trailing newline) and every independent reader re-derives.
1280fn self_custody_entry(
1281    key: &AgentSigningKey,
1282    seq: u64,
1283    ts: String,
1284    pack_sha256: &str,
1285    files: &[WireFeedFile],
1286    prev_entry_hash: Option<&str>,
1287) -> LinkResult<String> {
1288    let removed: [String; 0] = [];
1289    let unsigned = serde_json::to_string(&UnsignedWireEntry {
1290        v: 1,
1291        seq,
1292        ts,
1293        brain: &key.multikey,
1294        public_key: &key.public_key_spki,
1295        kind: "push",
1296        op: "snapshot",
1297        pack_sha256,
1298        files,
1299        removed: &removed,
1300        prev_entry_hash,
1301    })
1302    .expect("serialize feed entry");
1303    let pair = agent_keypair(&key.pkcs8)?;
1304    let sig = URL_SAFE_NO_PAD.encode(pair.sign(unsigned.as_bytes()).as_ref());
1305    Ok(format!(
1306        "{},\"sig\":\"{}\"}}",
1307        &unsigned[..unsigned.len() - 1],
1308        sig
1309    ))
1310}
1311
1312/// An env var, treated as absent when unset or empty (an empty
1313/// `DBMD_HUB_KEY=` falls through rather than becoming an empty credential).
1314fn env_nonempty(name: &str) -> Option<String> {
1315    std::env::var(name).ok().filter(|v| !v.trim().is_empty())
1316}
1317
1318/// Read the `hub = <URL>` line out of a `.dbmd/config` file. The format is
1319/// deliberately minimal: `key = value` lines, `#` comments, unknown keys
1320/// ignored (forward-compatible). A missing or unreadable file is simply "not
1321/// configured here".
1322fn config_file_hub(path: &Path) -> Option<String> {
1323    const MAX_CONFIG_BYTES: u64 = 64 * 1024;
1324    #[cfg(unix)]
1325    let file = {
1326        use std::os::fd::{AsRawFd as _, FromRawFd as _};
1327        use std::os::unix::ffi::OsStrExt as _;
1328        let parent =
1329            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new("."))).ok()?;
1330        let leaf = c_name(path.file_name()?.as_bytes(), &path.display().to_string()).ok()?;
1331        let fd = unsafe {
1332            libc::openat(
1333                parent.as_raw_fd(),
1334                leaf.as_ptr(),
1335                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
1336            )
1337        };
1338        if fd < 0 {
1339            return None;
1340        }
1341        unsafe { std::fs::File::from_raw_fd(fd) }
1342    };
1343    #[cfg(not(unix))]
1344    let file = std::fs::File::open(path).ok()?;
1345    let metadata = file.metadata().ok()?;
1346    if !metadata.is_file() || metadata.len() > MAX_CONFIG_BYTES {
1347        return None;
1348    }
1349    let mut bytes = Vec::with_capacity(metadata.len() as usize);
1350    file.take(MAX_CONFIG_BYTES + 1)
1351        .read_to_end(&mut bytes)
1352        .ok()?;
1353    if bytes.len() as u64 > MAX_CONFIG_BYTES {
1354        return None;
1355    }
1356    let text = String::from_utf8(bytes).ok()?;
1357    for line in text.lines() {
1358        let line = line.trim();
1359        if line.is_empty() || line.starts_with('#') {
1360            continue;
1361        }
1362        if let Some((k, v)) = line.split_once('=') {
1363            if k.trim() == "hub" {
1364                let v = v.trim();
1365                if !v.is_empty() {
1366                    return Some(v.to_string());
1367                }
1368            }
1369        }
1370    }
1371    None
1372}
1373
1374/// The bearer key must never travel in cleartext; only loopback hosts may
1375/// skip TLS (local development against a hub on localhost).
1376fn assert_safe_hub(hub: &str) -> LinkResult<()> {
1377    let parsed = url::Url::parse(hub).map_err(|_| LinkError::UnsafeHub {
1378        hub: hub.to_string(),
1379    })?;
1380    if !(parsed.scheme().eq_ignore_ascii_case("https")
1381        || parsed.scheme().eq_ignore_ascii_case("http"))
1382        || !parsed.username().is_empty()
1383        || parsed.password().is_some()
1384        || (parsed.path() != "/" && !parsed.path().is_empty())
1385        || parsed.query().is_some()
1386        || parsed.fragment().is_some()
1387    {
1388        return Err(LinkError::UnsafeHub {
1389            hub: hub.to_string(),
1390        });
1391    }
1392    let loopback = match parsed.host() {
1393        Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
1394        Some(url::Host::Ipv4(ip)) => ip.is_loopback(),
1395        Some(url::Host::Ipv6(ip)) => ip.is_loopback(),
1396        None => false,
1397    };
1398    if parsed.scheme().eq_ignore_ascii_case("https") || loopback {
1399        Ok(())
1400    } else {
1401        Err(LinkError::UnsafeHub {
1402            hub: hub.to_string(),
1403        })
1404    }
1405}
1406
1407/// Trim paste artifacts and refuse anything outside the printable-ASCII token
1408/// range WITHOUT echoing the key — an HTTP library rejecting a bad header
1409/// value tends to echo the whole header line, credential included, so the
1410/// gate sits here instead.
1411fn clean_key(raw: &str) -> LinkResult<String> {
1412    let k = raw.trim();
1413    if k.is_empty() || k.bytes().any(|b| !(0x21..=0x7e).contains(&b)) {
1414        return Err(LinkError::BadKey);
1415    }
1416    Ok(k.to_string())
1417}
1418
1419// ─────────────────────────────────────────────────────────────────────────────
1420// Transport — one blocking agent, capped reads, the JSON-or-refuse contract
1421// ─────────────────────────────────────────────────────────────────────────────
1422
1423/// One hub response: the status plus the parsed JSON body when there was one.
1424#[derive(Debug)]
1425pub struct HubResponse {
1426    /// The HTTP status code.
1427    pub status: u16,
1428    /// The parsed JSON body, `None` when the body was empty or not JSON.
1429    pub body: Option<Value>,
1430}
1431
1432struct RawHubResponse {
1433    status: u16,
1434    body: Vec<u8>,
1435}
1436
1437/// Whether a request carries the bearer credential.
1438#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1439enum Auth {
1440    /// Send `authorization: Bearer <key>`; error without a key.
1441    Required,
1442    /// Send no credential — the propose door is unauthenticated by design.
1443    None,
1444    /// Send the configured credential when one exists, otherwise nothing —
1445    /// brain-addressed propose works anonymously on public brains, and an
1446    /// authenticated caller earns a bigger actor-class budget.
1447    Optional,
1448}
1449
1450fn agent_builder_with_timeout(overall: std::time::Duration) -> ureq::AgentBuilder {
1451    ureq::AgentBuilder::new()
1452        .user_agent(concat!("dbmd/", env!("CARGO_PKG_VERSION")))
1453        // Never follow a redirect while a bearer, a store pack, or a signed
1454        // response is in flight. Callers see the 3xx as a non-success instead
1455        // of letting an origin steer sensitive material elsewhere.
1456        .redirects(0)
1457        .timeout_connect(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
1458        .timeout_read(std::time::Duration::from_secs(READ_TIMEOUT_SECS))
1459        .timeout_write(overall)
1460        .timeout(overall)
1461}
1462
1463fn hub_agent(cfg: &HubConfig) -> LinkResult<ureq::Agent> {
1464    hub_agent_with_timeout(
1465        cfg,
1466        std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
1467    )
1468}
1469
1470fn hub_agent_with_timeout(
1471    cfg: &HubConfig,
1472    overall: std::time::Duration,
1473) -> LinkResult<ureq::Agent> {
1474    if !cfg.store_selected {
1475        return Ok(agent_builder_with_timeout(overall).build());
1476    }
1477    let parsed = url::Url::parse(&cfg.hub).map_err(|_| LinkError::UnsafeHub {
1478        hub: cfg.hub.clone(),
1479    })?;
1480    pinned_public_agent_pooled(
1481        &parsed,
1482        false,
1483        "store-selected hub",
1484        AgentShape {
1485            overall,
1486            ..AgentShape::default()
1487        },
1488    )
1489}
1490
1491/// Perform one hub request. `path` is the binding path (starts with `/`);
1492/// `body` posts JSON. Transport failures, oversized bodies, and non-UTF-8 are
1493/// all surfaced as typed [`LinkError`]s; HTTP error statuses are returned in
1494/// the [`HubResponse`] for [`ensure_ok`] to shape.
1495fn request_raw(
1496    cfg: &HubConfig,
1497    method: &str,
1498    path: &str,
1499    body: Option<&Value>,
1500    auth: Auth,
1501    max_response_bytes: u64,
1502) -> LinkResult<RawHubResponse> {
1503    let http = hub_agent(cfg)?;
1504    request_raw_with_agent(
1505        cfg,
1506        &http,
1507        method,
1508        path,
1509        body,
1510        RawRequestOptions {
1511            auth,
1512            max_response_bytes,
1513            request_id: None,
1514        },
1515    )
1516}
1517
1518struct RawRequestOptions<'a> {
1519    auth: Auth,
1520    max_response_bytes: u64,
1521    request_id: Option<&'a str>,
1522}
1523
1524fn request_raw_with_agent(
1525    cfg: &HubConfig,
1526    http: &ureq::Agent,
1527    method: &str,
1528    path: &str,
1529    body: Option<&Value>,
1530    options: RawRequestOptions<'_>,
1531) -> LinkResult<RawHubResponse> {
1532    let url = format!("{}{}", cfg.hub, path);
1533    let encoded_body = body.map(Value::to_string);
1534    let origin = normalized_origin(&cfg.hub)?;
1535    // An agent signing key outranks the bearer: possession proofs put nothing
1536    // reusable on the wire, so when both are configured the stronger one wins.
1537    let credential = match options.auth {
1538        Auth::Required => Some(match &cfg.agent_key {
1539            Some(key) => linkmd_sig_header(key, &origin, method, path, encoded_body.as_deref())?,
1540            None => format!("Bearer {}", cfg.require_key()?),
1541        }),
1542        Auth::Optional => match &cfg.agent_key {
1543            Some(key) => Some(linkmd_sig_header(
1544                key,
1545                &origin,
1546                method,
1547                path,
1548                encoded_body.as_deref(),
1549            )?),
1550            None => cfg.key.as_deref().map(|k| format!("Bearer {k}")),
1551        },
1552        Auth::None => None,
1553    };
1554    let result = with_connect_retries(|| {
1555        let mut req = http.request(method, &url);
1556        if let Some(value) = &credential {
1557            req = req.set("authorization", value);
1558        }
1559        if let Some(value) = options.request_id {
1560            req = req.set("x-request-id", value);
1561        }
1562        match &encoded_body {
1563            Some(value) => req
1564                .set("content-type", "application/json")
1565                .send_string(value)
1566                .map_err(Box::new),
1567            None => req.call().map_err(Box::new),
1568        }
1569    });
1570    let resp = match result {
1571        Ok(resp) => resp,
1572        Err(error) => match *error {
1573            ureq::Error::Status(_, resp) => resp,
1574            ureq::Error::Transport(error) => {
1575                return Err(LinkError::Transport {
1576                    hub: cfg.hub.clone(),
1577                    message: error.to_string(),
1578                });
1579            }
1580        },
1581    };
1582
1583    let status = resp.status();
1584    let buf = read_response_body(resp, options.max_response_bytes + 1, &cfg.hub)?;
1585    if buf.len() as u64 > options.max_response_bytes {
1586        return Err(LinkError::ResponseTooLarge {
1587            limit_bytes: options.max_response_bytes,
1588        });
1589    }
1590    Ok(RawHubResponse { status, body: buf })
1591}
1592
1593fn request_capped(
1594    cfg: &HubConfig,
1595    method: &str,
1596    path: &str,
1597    body: Option<&Value>,
1598    auth: Auth,
1599    max_response_bytes: u64,
1600) -> LinkResult<HubResponse> {
1601    let raw = request_raw(cfg, method, path, body, auth, max_response_bytes)?;
1602    let parsed: Option<Value> = serde_json::from_slice(&raw.body).ok();
1603    Ok(HubResponse {
1604        status: raw.status,
1605        body: parsed,
1606    })
1607}
1608
1609/// One request the hub is allowed to take brain-sized time over. Used for the
1610/// commit lane, where abandoning the attempt early does not cancel the work —
1611/// it only loses the receipt for it.
1612///
1613/// Which is why a lost connection is asked again rather than reported. The
1614/// mutation coordinate is derived from the observed base and the exact
1615/// operation set precisely so a repeat is the SAME effect: the hub answers a
1616/// mutation it already applied with that commit's receipt instead of a second
1617/// commit. Measured on a 32,338-file store: the commit landed durably and the
1618/// push reported a transport failure, because nobody went back for the
1619/// receipt.
1620fn request_patient(
1621    cfg: &HubConfig,
1622    method: &str,
1623    path: &str,
1624    body: Option<&Value>,
1625    auth: Auth,
1626) -> LinkResult<HubResponse> {
1627    let http = hub_agent_with_timeout(
1628        cfg,
1629        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1630    )?;
1631    let mut attempt = 0;
1632    loop {
1633        let sent = request_raw_with_agent(
1634            cfg,
1635            &http,
1636            method,
1637            path,
1638            body,
1639            RawRequestOptions {
1640                auth,
1641                max_response_bytes: MAX_RESPONSE_BYTES,
1642                request_id: None,
1643            },
1644        );
1645        match sent {
1646            Err(LinkError::Transport { .. }) if attempt + 1 < COMMIT_ATTEMPTS => {
1647                std::thread::sleep(std::time::Duration::from_millis(
1648                    COMMIT_RETRY_BACKOFF_MS[attempt.min(COMMIT_RETRY_BACKOFF_MS.len() - 1)],
1649                ));
1650                attempt += 1;
1651            }
1652            Err(error) => return Err(error),
1653            Ok(raw) => {
1654                return Ok(HubResponse {
1655                    status: raw.status,
1656                    body: serde_json::from_slice(&raw.body).ok(),
1657                })
1658            }
1659        }
1660    }
1661}
1662
1663fn request(
1664    cfg: &HubConfig,
1665    method: &str,
1666    path: &str,
1667    body: Option<&Value>,
1668    auth: Auth,
1669) -> LinkResult<HubResponse> {
1670    request_capped(cfg, method, path, body, auth, MAX_RESPONSE_BYTES)
1671}
1672
1673/// Repeat one challenge-response mutation with the exact request identity the
1674/// hub bound into its actor claim. A new request id would create a new actor
1675/// hash and therefore a different canonical commit candidate, making the
1676/// otherwise correct self-custody signature unusable.
1677fn request_with_request_id(
1678    cfg: &HubConfig,
1679    method: &str,
1680    path: &str,
1681    body: Option<&Value>,
1682    auth: Auth,
1683    request_id: &str,
1684) -> LinkResult<HubResponse> {
1685    if request_id.is_empty()
1686        || request_id.len() > 128
1687        || !request_id
1688            .bytes()
1689            .all(|byte| byte.is_ascii_alphanumeric() || b"-_.:".contains(&byte))
1690    {
1691        return Err(invalid_feed("hub returned an unsafe request id"));
1692    }
1693    // Both callers are commit responses, so this shares the commit lane's
1694    // patience: the hub is finishing the same brain-sized work either way.
1695    let http = hub_agent_with_timeout(
1696        cfg,
1697        std::time::Duration::from_secs(COMMIT_REQUEST_TIMEOUT_SECS),
1698    )?;
1699    let raw = request_raw_with_agent(
1700        cfg,
1701        &http,
1702        method,
1703        path,
1704        body,
1705        RawRequestOptions {
1706            auth,
1707            max_response_bytes: MAX_RESPONSE_BYTES,
1708            request_id: Some(request_id),
1709        },
1710    )?;
1711    Ok(HubResponse {
1712        status: raw.status,
1713        body: serde_json::from_slice(&raw.body).ok(),
1714    })
1715}
1716
1717fn ensure_raw_ok(r: RawHubResponse, what: &'static str) -> LinkResult<Vec<u8>> {
1718    if (200..300).contains(&r.status) {
1719        return Ok(r.body);
1720    }
1721    ensure_ok(
1722        HubResponse {
1723            status: r.status,
1724            body: serde_json::from_slice(&r.body).ok(),
1725        },
1726        what,
1727    )
1728    .and_then(|_| Err(invalid_feed("a non-2xx response was accepted unexpectedly")))
1729}
1730
1731/// These failures happen before any HTTP request reaches the hub, so retrying
1732/// cannot duplicate a mutation. Mid-stream I/O is deliberately excluded: once
1733/// bytes may have crossed the wire, the caller must rely on the verb's own
1734/// idempotency contract instead of guessing.
1735fn is_pre_request_transport(kind: ureq::ErrorKind) -> bool {
1736    matches!(
1737        kind,
1738        ureq::ErrorKind::Dns | ureq::ErrorKind::ConnectionFailed | ureq::ErrorKind::ProxyConnect
1739    )
1740}
1741
1742fn with_connect_retries(
1743    mut send: impl FnMut() -> Result<ureq::Response, Box<ureq::Error>>,
1744) -> Result<ureq::Response, Box<ureq::Error>> {
1745    let mut attempt = 0;
1746    loop {
1747        match send() {
1748            Err(error)
1749                if matches!(
1750                    error.as_ref(),
1751                    ureq::Error::Transport(transport)
1752                        if is_pre_request_transport(transport.kind())
1753                ) && attempt + 1 < CONNECT_ATTEMPTS =>
1754            {
1755                std::thread::sleep(std::time::Duration::from_millis(
1756                    CONNECT_RETRY_BACKOFF_MS[attempt],
1757                ));
1758                attempt += 1;
1759            }
1760            result => return result,
1761        }
1762    }
1763}
1764
1765fn hub_is_loopback(hub: &str) -> bool {
1766    url::Url::parse(hub).ok().is_some_and(|parsed| {
1767        parsed.host().is_some_and(|host| match host {
1768            url::Host::Domain(host) => host.eq_ignore_ascii_case("localhost"),
1769            url::Host::Ipv4(ip) => ip.is_loopback(),
1770            url::Host::Ipv6(ip) => ip.is_loopback(),
1771        })
1772    })
1773}
1774
1775/// Every safety property a presigned object-store URL must carry, checked
1776/// before anything is sent to it. Split out from agent construction so a batch
1777/// can share one connection pool without ever skipping a per-URL check.
1778fn checked_presigned_url(cfg: &HubConfig, raw: &str) -> LinkResult<(url::Url, bool)> {
1779    let parsed = url::Url::parse(raw).map_err(|_| LinkError::InvalidPack {
1780        message: "the hub returned an invalid object-store URL".to_string(),
1781    })?;
1782    let allow_private = hub_is_loopback(&cfg.hub)
1783        || env_nonempty(ALLOW_PRIVATE_OBJECT_URL_ENV).as_deref() == Some("1");
1784    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
1785        || !parsed.username().is_empty()
1786        || parsed.password().is_some()
1787        || parsed.fragment().is_some()
1788    {
1789        return Err(LinkError::InvalidPack {
1790            message: "the hub returned an unsafe object-store URL".to_string(),
1791        });
1792    }
1793    Ok((parsed, allow_private))
1794}
1795
1796fn presigned_agent(cfg: &HubConfig, raw: &str) -> LinkResult<ureq::Agent> {
1797    let (parsed, allow_private) = checked_presigned_url(cfg, raw)?;
1798    pinned_public_agent(&parsed, allow_private, "object-store URL").map_err(|_| {
1799        LinkError::InvalidPack {
1800            message: "the hub returned an object-store URL with an unsafe network target"
1801                .to_string(),
1802        }
1803    })
1804}
1805
1806/// One pinned agent for a whole staging batch.
1807///
1808/// Every object in a batch is presigned against the same object-store
1809/// authority, so one agent can serve them all — and must: building one per
1810/// object costs a DNS resolution and a fresh TLS handshake per object, which is
1811/// the difference between a real store staging in minutes and in an hour. The
1812/// shared authority is verified rather than assumed, and a batch that ever
1813/// spans two of them falls back to a per-object agent.
1814fn shared_staging_agent(cfg: &HubConfig, urls: &[&str]) -> Option<ureq::Agent> {
1815    let (first, allow_private) = checked_presigned_url(cfg, urls.first()?).ok()?;
1816    let authority = (
1817        first.host_str()?.to_string(),
1818        first.port_or_known_default()?,
1819    );
1820    for raw in &urls[1..] {
1821        let (parsed, _) = checked_presigned_url(cfg, raw).ok()?;
1822        if (parsed.host_str()?, parsed.port_or_known_default()?)
1823            != (authority.0.as_str(), authority.1)
1824        {
1825            return None;
1826        }
1827    }
1828    pinned_public_agent_pooled(
1829        &first,
1830        allow_private,
1831        "object-store URL",
1832        AgentShape {
1833            idle_per_host: V2_UPLOAD_CONCURRENCY,
1834            ..AgentShape::default()
1835        },
1836    )
1837    .ok()
1838}
1839
1840/// Turn an object-store transport failure into a useful but credential-safe
1841/// diagnostic. `ureq::Transport`'s Display implementation includes the full
1842/// request URL; for a presigned URL that means the short-lived signature and
1843/// every signed query coordinate. The error kind is enough to distinguish a
1844/// network failure without copying bearer material into terminals or logs.
1845fn object_store_transport_error(error: ureq::Transport) -> LinkError {
1846    LinkError::Transport {
1847        hub: "the object store".to_string(),
1848        message: format!("network error ({:?})", error.kind()),
1849    }
1850}
1851
1852fn put_presigned(cfg: &HubConfig, raw: &str, headers: &Value, bytes: &[u8]) -> LinkResult<()> {
1853    let http = presigned_agent(cfg, raw)?;
1854    let deadline = std::time::Instant::now()
1855        .checked_add(std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS))
1856        .ok_or_else(upload_deadline_error)?;
1857    let mut attempt = 0;
1858    let result = loop {
1859        // The reservation states content-length itself. This client sends the
1860        // body from memory, so it never needs to add one, and adding one under
1861        // a different capitalization would leave both on the wire.
1862        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
1863        if let Some(map) = headers.as_object() {
1864            for (name, value) in map {
1865                if let Some(value) = value.as_str() {
1866                    req = req.set(name, value);
1867                }
1868            }
1869        }
1870        match req.send_bytes(bytes) {
1871            // An upload is idempotent here: fixed content address, exact
1872            // length and checksum, write-once precondition. A dropped
1873            // connection or a single overloaded refusal is worth replaying
1874            // rather than failing a whole migration on. 412 already means
1875            // "someone else stored these exact bytes".
1876            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
1877                attempt += 1;
1878            }
1879            Err(ureq::Error::Status(status, _))
1880                if status != 412
1881                    && is_retryable_upload_status(status)
1882                    && wait_for_upload_retry(deadline, attempt) =>
1883            {
1884                attempt += 1;
1885            }
1886            result => break result,
1887        }
1888    };
1889    match result {
1890        Ok(resp) if (200..300).contains(&resp.status()) => {
1891            drain_presigned_response(resp);
1892            Ok(())
1893        }
1894        Ok(resp) => Err(presigned_upload_refusal(resp)),
1895        Err(error) => match error {
1896            // Immutable uploads use If-None-Match. A concurrent writer may
1897            // win the same content address; the commit/confirm endpoint reads
1898            // and hashes that object before accepting it, so 412 safely means
1899            // "continue to verification", never "trust the upload".
1900            ureq::Error::Status(412, _) => Ok(()),
1901            ureq::Error::Status(_, resp) => Err(presigned_upload_refusal(resp)),
1902            ureq::Error::Transport(err) => Err(object_store_transport_error(err)),
1903        },
1904    }
1905}
1906
1907/// Read a response body, typed as the transport failure it is.
1908///
1909/// A connection that fails while the body is still arriving is the same class
1910/// of problem as one that fails before it arrives at all — but a bare `?` on
1911/// the read turned it into an anonymous io error that no retry path recognised
1912/// and no message attributed to a peer. On a migration making tens of
1913/// thousands of requests, one stalled body ended the whole push and reported
1914/// only "timed out reading response".
1915fn read_response_body(response: ureq::Response, limit: u64, peer: &str) -> LinkResult<Vec<u8>> {
1916    let mut buf = Vec::new();
1917    response
1918        .into_reader()
1919        .take(limit)
1920        .read_to_end(&mut buf)
1921        .map_err(|error| LinkError::Transport {
1922            hub: peer.to_string(),
1923            message: error.to_string(),
1924        })?;
1925    Ok(buf)
1926}
1927
1928/// Draining a response is what returns its connection to the pool: the client
1929/// cannot recycle a socket whose body may still have bytes pending, so a body
1930/// that is dropped instead of read costs the next object a fresh handshake.
1931/// An object-store PUT answers with almost nothing, so this is nearly free.
1932fn drain_presigned_response(response: ureq::Response) {
1933    let mut reader = response.into_reader().take(64 * 1024);
1934    let _ = std::io::copy(&mut reader, &mut std::io::sink());
1935}
1936
1937/// Carry the object store's own reason for a refusal. An unexplained status is
1938/// unactionable; the body states checksum mismatch, precondition, or expiry.
1939fn presigned_upload_refusal(response: ureq::Response) -> LinkError {
1940    let status = response.status();
1941    let detail = response
1942        .into_string()
1943        .ok()
1944        .map(|body| body.chars().take(400).collect::<String>())
1945        .filter(|body| !body.trim().is_empty());
1946    LinkError::Http {
1947        what: "pack upload",
1948        status,
1949        message: match detail {
1950            Some(body) => format!(
1951                "object store rejected the upload: {}",
1952                body.replace('\n', " ")
1953            ),
1954            None => "object store rejected the upload".to_string(),
1955        },
1956        code: None,
1957        details: None,
1958    }
1959}
1960
1961fn one_past_bounded_limit(max_bytes: u64) -> Option<u64> {
1962    max_bytes.checked_add(1)
1963}
1964
1965fn presigned_download_read_limit() -> u64 {
1966    one_past_bounded_limit(MAX_PACK_BYTES)
1967        .expect("the fixed presigned-download ceiling must leave room for the refusal byte")
1968}
1969
1970fn get_presigned(cfg: &HubConfig, raw: &str) -> LinkResult<Vec<u8>> {
1971    let http = presigned_agent(cfg, raw)?;
1972    let resp = match with_connect_retries(|| http.get(raw).call().map_err(Box::new)) {
1973        Ok(resp) => resp,
1974        Err(error) => match *error {
1975            ureq::Error::Status(_, resp) => {
1976                return Err(LinkError::Http {
1977                    what: "pack download",
1978                    status: resp.status(),
1979                    message: "object store rejected the download".to_string(),
1980                    code: None,
1981                    details: None,
1982                });
1983            }
1984            ureq::Error::Transport(err) => {
1985                return Err(LinkError::Transport {
1986                    hub: "the object store".to_string(),
1987                    message: err.to_string(),
1988                });
1989            }
1990        },
1991    };
1992    if !(200..300).contains(&resp.status()) {
1993        return Err(LinkError::Http {
1994            what: "pack download",
1995            status: resp.status(),
1996            message: "object store rejected the download".to_string(),
1997            code: None,
1998            details: None,
1999        });
2000    }
2001    let bytes = read_response_body(resp, presigned_download_read_limit(), "the object store")?;
2002    if bytes.len() as u64 > MAX_PACK_BYTES {
2003        return Err(LinkError::InvalidPack {
2004            message: "download exceeds the compressed-size limit".to_string(),
2005        });
2006    }
2007    Ok(bytes)
2008}
2009
2010/// Unwrap a successful JSON body, or shape the failure: a >=400 surfaces the
2011/// hub's own `error` + `code`; a 2xx without JSON is refused as not a hub
2012/// answer.
2013fn ensure_ok(r: HubResponse, what: &'static str) -> LinkResult<Value> {
2014    if !(200..300).contains(&r.status) {
2015        let message = r
2016            .body
2017            .as_ref()
2018            .and_then(|b| b.get("error"))
2019            .and_then(Value::as_str)
2020            .unwrap_or("unknown error")
2021            .to_string();
2022        let code = r
2023            .body
2024            .as_ref()
2025            .and_then(|b| b.get("code"))
2026            .and_then(Value::as_str)
2027            .map(str::to_string);
2028        let details = r.body.as_ref().and_then(|b| b.get("details")).cloned();
2029        return Err(LinkError::Http {
2030            what,
2031            status: r.status,
2032            message,
2033            code,
2034            details,
2035        });
2036    }
2037    r.body.ok_or(LinkError::NotJson {
2038        what,
2039        status: r.status,
2040    })
2041}
2042
2043// ─────────────────────────────────────────────────────────────────────────────
2044// resolve — handle → brain card; @brain/id → the record
2045// ─────────────────────────────────────────────────────────────────────────────
2046
2047/// Resolve an address. A bare `@brain` returns the brain card (metadata +
2048/// index stats — the v0 form of the card; keys arrive with the protocol's
2049/// signing layer). `@brain/<id>` and `@brain/<path>.md` return the full
2050/// record, frontmatter + body.
2051fn is_public_registry_ip(ip: std::net::IpAddr) -> bool {
2052    match ip {
2053        std::net::IpAddr::V4(ip) => {
2054            let [a, b, c, _] = ip.octets();
2055            !(a == 0
2056                || a == 10
2057                || a == 127
2058                || (a == 100 && (64..=127).contains(&b))
2059                || (a == 169 && b == 254)
2060                || (a == 172 && (16..=31).contains(&b))
2061                || (a == 192 && b == 0 && c == 0)
2062                || (a == 192 && b == 0 && c == 2)
2063                || (a == 192 && b == 88 && c == 99)
2064                || (a == 192 && b == 168)
2065                || (a == 198 && (b == 18 || b == 19))
2066                || (a == 198 && b == 51 && c == 100)
2067                || (a == 203 && b == 0 && c == 113)
2068                || a >= 224)
2069        }
2070        std::net::IpAddr::V6(ip) => {
2071            let segments = ip.segments();
2072            // Conservatively accept only global unicast 2000::/3, excluding
2073            // special-purpose/transition blocks. In particular, 6to4 embeds
2074            // an IPv4 destination and must not tunnel a validated public
2075            // connect to 127/8 or RFC1918.
2076            (segments[0] & 0xe000) == 0x2000
2077                && !(segments[0] == 0x2001 && (segments[1] & 0xfe00) == 0)
2078                && !(segments[0] == 0x2001 && segments[1] == 0x0db8)
2079                && segments[0] != 0x2002
2080                && !(segments[0] == 0x3fff && (segments[1] & 0xf000) == 0)
2081        }
2082    }
2083}
2084
2085#[derive(Clone)]
2086struct PinnedRegistryResolver {
2087    netloc: String,
2088    addresses: Vec<std::net::SocketAddr>,
2089}
2090
2091impl ureq::Resolver for PinnedRegistryResolver {
2092    fn resolve(&self, requested: &str) -> std::io::Result<Vec<std::net::SocketAddr>> {
2093        if requested == self.netloc {
2094            Ok(self.addresses.clone())
2095        } else {
2096            Err(std::io::Error::new(
2097                std::io::ErrorKind::PermissionDenied,
2098                "registry request attempted to resolve an unvalidated authority",
2099            ))
2100        }
2101    }
2102}
2103
2104fn pinned_public_agent(
2105    url: &url::Url,
2106    allow_private: bool,
2107    label: &str,
2108) -> LinkResult<ureq::Agent> {
2109    pinned_public_agent_pooled(url, allow_private, label, AgentShape::default())
2110}
2111
2112/// How one agent is built: `idle_per_host` sizes the connection pool (the
2113/// default of one is right for a lone request but throttles a concurrent batch
2114/// back to a single warm connection), and `overall` is the wall-clock budget
2115/// for one attempt.
2116struct AgentShape {
2117    idle_per_host: usize,
2118    overall: std::time::Duration,
2119}
2120
2121impl Default for AgentShape {
2122    fn default() -> Self {
2123        Self {
2124            idle_per_host: 1,
2125            overall: std::time::Duration::from_secs(OVERALL_REQUEST_TIMEOUT_SECS),
2126        }
2127    }
2128}
2129
2130fn pinned_public_agent_pooled(
2131    url: &url::Url,
2132    allow_private: bool,
2133    label: &str,
2134    shape: AgentShape,
2135) -> LinkResult<ureq::Agent> {
2136    let host = url
2137        .host_str()
2138        .ok_or_else(|| invalid_feed(format!("{label} has no host")))?;
2139    let port = url
2140        .port_or_known_default()
2141        .ok_or_else(|| invalid_feed(format!("{label} has no port")))?;
2142    let addresses = resolve_addresses_with_deadline(
2143        host,
2144        port,
2145        std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS),
2146    )
2147    .map_err(|error| invalid_feed(format!("{label} DNS resolution failed: {error}")))?;
2148    if addresses.is_empty() {
2149        return Err(invalid_feed(format!("{label} DNS returned no addresses")));
2150    }
2151    if !allow_private
2152        && addresses
2153            .iter()
2154            .any(|address| !is_public_registry_ip(address.ip()))
2155    {
2156        return Err(invalid_feed(format!(
2157            "{label} resolves to a non-public address"
2158        )));
2159    }
2160    let netloc = if host.contains(':') {
2161        format!("[{host}]:{port}")
2162    } else {
2163        format!("{host}:{port}")
2164    };
2165    Ok(agent_builder_with_timeout(shape.overall)
2166        .max_idle_connections_per_host(shape.idle_per_host.max(1))
2167        .resolver(PinnedRegistryResolver { netloc, addresses })
2168        .build())
2169}
2170
2171/// Resolve one authority without allowing libc DNS to hold the caller forever.
2172/// `ToSocketAddrs` itself has no timeout API, so resolution runs in a detached
2173/// worker and only its result channel is awaited. A late resolver result has no
2174/// side effects and is discarded after the deadline.
2175fn resolve_addresses_with_deadline(
2176    host: &str,
2177    port: u16,
2178    timeout: std::time::Duration,
2179) -> std::io::Result<Vec<std::net::SocketAddr>> {
2180    use std::net::ToSocketAddrs as _;
2181
2182    let host = host.to_string();
2183    let (send, receive) = std::sync::mpsc::sync_channel(1);
2184    std::thread::Builder::new()
2185        .name("dbmd-dns".to_string())
2186        .spawn(move || {
2187            let result = (host.as_str(), port)
2188                .to_socket_addrs()
2189                .map(|addresses| addresses.collect());
2190            let _ = send.send(result);
2191        })
2192        .map_err(|error| std::io::Error::other(format!("cannot start resolver: {error}")))?;
2193    match receive.recv_timeout(timeout) {
2194        Ok(result) => result,
2195        Err(std::sync::mpsc::RecvTimeoutError::Timeout) => Err(std::io::Error::new(
2196            std::io::ErrorKind::TimedOut,
2197            "resolution exceeded its deadline",
2198        )),
2199        Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => Err(std::io::Error::other(
2200            "resolver stopped without returning a result",
2201        )),
2202    }
2203}
2204
2205fn registry_agent(url: &url::Url) -> LinkResult<ureq::Agent> {
2206    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2207    pinned_public_agent(url, allow_private, "registry home")
2208}
2209
2210/// GET an absolute URL as JSON with NO credential — used to fetch a brain card
2211/// from a FOREIGN home during registry resolution. DNS is resolved once,
2212/// every answer is required to be public, and that exact answer set is pinned
2213/// into a no-redirect HTTP agent to close private-network SSRF and rebinding.
2214fn get_json_absolute(url: &str) -> LinkResult<Value> {
2215    let parsed = url::Url::parse(url).map_err(|_| invalid_feed("invalid registry home URL"))?;
2216    let allow_private = env_nonempty(ALLOW_PRIVATE_REGISTRY_HOME_ENV).as_deref() == Some("1");
2217    if (!parsed.scheme().eq_ignore_ascii_case("https") && !allow_private)
2218        || !parsed.username().is_empty()
2219        || parsed.password().is_some()
2220        || parsed.query().is_some()
2221        || parsed.fragment().is_some()
2222    {
2223        return Err(invalid_feed("unsafe registry home URL"));
2224    }
2225    let http = registry_agent(&parsed)?;
2226    let resp = match with_connect_retries(|| http.get(url).call().map_err(Box::new)) {
2227        Ok(resp) => resp,
2228        Err(error) => match *error {
2229            ureq::Error::Status(status, resp) => {
2230                let _ = resp;
2231                return Err(LinkError::Http {
2232                    what: "registry home fetch",
2233                    status,
2234                    message: "the home node rejected the card request".to_string(),
2235                    code: None,
2236                    details: None,
2237                });
2238            }
2239            ureq::Error::Transport(err) => {
2240                return Err(LinkError::Transport {
2241                    hub: url.to_string(),
2242                    message: err.to_string(),
2243                });
2244            }
2245        },
2246    };
2247    if !(200..300).contains(&resp.status()) {
2248        return Err(LinkError::Http {
2249            what: "registry home fetch",
2250            status: resp.status(),
2251            message: "the home node returned a redirect or error".to_string(),
2252            code: None,
2253            details: None,
2254        });
2255    }
2256    let buf = read_response_body(resp, MAX_REGISTRY_CARD_BYTES + 1, url)?;
2257    if buf.len() as u64 > MAX_REGISTRY_CARD_BYTES {
2258        return Err(LinkError::ResponseTooLarge {
2259            limit_bytes: MAX_REGISTRY_CARD_BYTES,
2260        });
2261    }
2262    serde_json::from_slice(&buf).map_err(|_| LinkError::InvalidFeed {
2263        message: "the home node returned invalid JSON".to_string(),
2264    })
2265}
2266
2267/// Resolve a bare `@handle` through the federation registry (link.md §7.1,
2268/// E5): look the handle up in the hub's registry, fetch the brain card from
2269/// the returned HOME node, and PIN — the card's identity fingerprint must
2270/// equal the registry's, or resolution fails. Returns the card enriched with
2271/// the resolved `home`, or `Ok(None)` when the registry has no such handle
2272/// (so the caller can fall back to a direct lookup).
2273pub fn resolve_registry(cfg: &HubConfig, handle: &str) -> LinkResult<Option<Value>> {
2274    require_safe_ref(handle)?;
2275    // Hold the validated trust-directory capability before any network I/O.
2276    // Every lock/load/save below remains relative to this exact inode even if
2277    // an attacker swaps an ancestor while the registry or home is answering.
2278    let trust_directory = open_trust_dir(cfg)?;
2279    let reg = request_capped(
2280        cfg,
2281        "GET",
2282        &format!("/api/hub/registry/{handle}"),
2283        None,
2284        Auth::None,
2285        MAX_REGISTRY_CARD_BYTES,
2286    )?;
2287    if reg.status == 404 {
2288        return Ok(None);
2289    }
2290    let body = ensure_ok(reg, "registry resolve")?;
2291    let home = body
2292        .get("home")
2293        .and_then(Value::as_str)
2294        .ok_or_else(|| invalid_feed("registry entry has no home"))?;
2295    let brain = body
2296        .get("brain")
2297        .and_then(Value::as_str)
2298        .ok_or_else(|| invalid_feed("registry entry has no brain"))?;
2299    if !crate::ulid::is_ulid(brain) {
2300        return Err(invalid_feed(
2301            "registry entry brain is not a canonical lowercase ULID",
2302        ));
2303    }
2304    let want_fp = body
2305        .get("identity")
2306        .and_then(|i| i.get("fingerprint"))
2307        .and_then(Value::as_str)
2308        .ok_or_else(|| invalid_feed("registry entry has no identity fingerprint"))?;
2309
2310    let home = home.trim_end_matches('/');
2311    let origin = normalized_origin(home)?;
2312    if origin != home {
2313        return Err(invalid_feed(
2314            "registry home must be an origin without a path, query, or fragment",
2315        ));
2316    }
2317    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[handle, brain])?;
2318    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, handle, brain)?;
2319    if let Some(binding) = &alias_binding {
2320        if binding
2321            .home
2322            .as_deref()
2323            .is_some_and(|pinned_home| pinned_home != home)
2324        {
2325            return Err(invalid_feed(
2326                "registry relocated a pinned handle to a different home",
2327            ));
2328        }
2329    }
2330    let card = get_json_absolute(&format!("{home}/api/hub/brains/{brain}"))?;
2331    if card.get("id").and_then(Value::as_str) != Some(brain) {
2332        return Err(invalid_feed(
2333            "the home node served a card for a different brain",
2334        ));
2335    }
2336    let identity: FeedIdentity = serde_json::from_value(
2337        card.get("identity")
2338            .cloned()
2339            .ok_or_else(|| invalid_feed("the home node served no identity"))?,
2340    )
2341    .map_err(|_| invalid_feed("the home node served an invalid identity"))?;
2342    let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
2343    let got_fp = card
2344        .get("identity")
2345        .and_then(|i| i.get("fingerprint"))
2346        .and_then(Value::as_str)
2347        .unwrap_or_default();
2348    if got_fp != want_fp {
2349        return Err(invalid_feed(
2350            "the home node served an identity that does not match the registry — refusing",
2351        ));
2352    }
2353    let current = format!("ed25519:{}", identity.fingerprint);
2354    let advertised_seq = card
2355        .get("headSeq")
2356        .and_then(Value::as_u64)
2357        .ok_or_else(|| invalid_feed("the home node served an invalid head sequence"))?;
2358    let advertised_hash = card.get("feedHash").and_then(Value::as_str);
2359    if (advertised_seq == 0 && !card.get("feedHash").is_some_and(Value::is_null))
2360        || (advertised_seq > 0 && advertised_hash.is_none_or(|hash| !is_sha256(hash)))
2361    {
2362        return Err(invalid_feed(
2363            "the home node served an invalid feed head boundary",
2364        ));
2365    }
2366    // This is required even on first contact. Otherwise a syntactically valid
2367    // identity may claim that a rotation happened after a head the home has
2368    // never reached, then become the permanent TOFU anchor.
2369    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], advertised_seq)?;
2370    let registry_alias = AliasBinding {
2371        v: 1,
2372        origin: normalized_origin(&cfg.hub)?,
2373        requested: handle.to_string(),
2374        brain: brain.to_string(),
2375        home: Some(home.to_string()),
2376    };
2377    save_canonical_pin_and_alias(
2378        cfg,
2379        &trust_directory,
2380        handle,
2381        brain,
2382        TrustState {
2383            v: 2,
2384            origin: normalized_origin(&cfg.hub)?,
2385            requested: brain.to_string(),
2386            brain: brain.to_string(),
2387            home: None,
2388            anchor,
2389            current,
2390            head_seq: pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq),
2391            feed_hash: pinned
2392                .as_ref()
2393                .and_then(|checkpoint| checkpoint.feed_hash.clone()),
2394            rotations: identity.rotations.clone(),
2395            hub_signer: None,
2396            protocol_profile: None,
2397        },
2398        Some(&registry_alias),
2399    )?;
2400    let mut out = card;
2401    if let Value::Object(map) = &mut out {
2402        map.insert("home".to_string(), Value::String(home.to_string()));
2403        map.insert(
2404            "resolvedVia".to_string(),
2405            Value::String("registry".to_string()),
2406        );
2407    }
2408    Ok(Some(out))
2409}
2410
2411pub fn resolve(cfg: &HubConfig, addr: &Address) -> LinkResult<Value> {
2412    // `Address::parse` refuses these shapes already, but `Address` has public
2413    // fields — re-assert at the wire so a hand-built address can never
2414    // reshape the request path.
2415    require_safe_ref(&addr.brain)?;
2416    if let Some(target) = &addr.target {
2417        let (given, ok) = match target {
2418            AddressTarget::Id(id) => (id, crate::ulid::is_ulid(id)),
2419            AddressTarget::Path(p) => (p, safe_store_rel_path(p) && p.ends_with(".md")),
2420        };
2421        if !ok {
2422            return Err(LinkError::BadAddress {
2423                given: given.clone(),
2424                reason: BAD_TARGET_REASON.to_string(),
2425            });
2426        }
2427    }
2428
2429    // A record is never accepted from the hub's mutable query/index response.
2430    // Under v2, an id lookup may suggest a path, but the client accepts only
2431    // bytes whose exact path proof reaches the verified content root and whose
2432    // parsed id still matches. Under v1, the signed snapshot pack remains the
2433    // binding. Both profiles therefore reject a forged query response.
2434    if let Some(target) = &addr.target {
2435        if let Some(head) = v2_verified_head(cfg, &addr.brain)? {
2436            let pointer = head.pointer.as_ref().ok_or_else(|| LinkError::Http {
2437                what: "resolve",
2438                status: 404,
2439                message: "record not found".to_string(),
2440                code: Some("NOT_FOUND".to_string()),
2441                details: None,
2442            })?;
2443            let (path, file) = match target {
2444                AddressTarget::Path(path) => {
2445                    let file =
2446                        v2_manifest_file(cfg, &head.brain_id, pointer, path)?.ok_or_else(|| {
2447                            LinkError::Http {
2448                                what: "resolve",
2449                                status: 404,
2450                                message: "record not found".to_string(),
2451                                code: Some("NOT_FOUND".to_string()),
2452                                details: None,
2453                            }
2454                        })?;
2455                    (path.clone(), file)
2456                }
2457                AddressTarget::Id(id) => v2_manifest_file_by_id(cfg, &head.brain_id, pointer, id)?,
2458            };
2459            let mut downloaded =
2460                download_v2_blobs(cfg, &head.brain_id, pointer, vec![(&path, &file)])?;
2461            let (_, bytes) = downloaded
2462                .pop()
2463                .ok_or_else(|| invalid_feed("v2 record download returned no bytes"))?;
2464            let resolved = resolve_from_verified_record_bytes(&head.brain_id, target, path, bytes)?;
2465            accept_v2_head(cfg, &head)?;
2466            return Ok(resolved);
2467        }
2468        let remote = verified_remote_head(cfg, &addr.brain, false)?;
2469        if !remote.head.verified {
2470            return Err(invalid_feed(
2471                "a path-scoped feed cannot prove a record against the full signed snapshot",
2472            ));
2473        }
2474        if remote.head.seq == 0 {
2475            return Err(LinkError::Http {
2476                what: "resolve",
2477                status: 404,
2478                message: "record not found".to_string(),
2479                code: Some("NOT_FOUND".to_string()),
2480                details: None,
2481            });
2482        }
2483        let brain = remote.head.brain.clone();
2484        let pack = download_verified_snapshot_pack(cfg, &brain, &remote)?;
2485        return resolve_from_verified_pack(&brain, target, pack);
2486    }
2487
2488    let path = format!("/api/hub/brains/{}", addr.brain);
2489    // Direct first: the caller's own slug and hub-hosted public handles resolve
2490    // here unchanged. Only a bare `@handle` the hub can't resolve directly
2491    // (404) falls through to the federation registry — how a handle reaches a
2492    // brain on ANOTHER node (link.md §7.1, E5).
2493    let direct = request(cfg, "GET", &path, None, Auth::Required)?;
2494    if direct.status == 404 && addr.target.is_none() && !crate::ulid::is_ulid(&addr.brain) {
2495        if let Some(card) = resolve_registry(cfg, &addr.brain)? {
2496            return Ok(card);
2497        }
2498    }
2499    let mut resolved = ensure_ok(direct, "resolve")?;
2500    if resolved.get("storageProfile").and_then(Value::as_str) == Some("v2") {
2501        let v2 = v2_verified_head(cfg, &addr.brain)?
2502            .ok_or_else(|| invalid_feed("v2 resolve card has no verified v2 head"))?;
2503        if resolved.get("id").and_then(Value::as_str) != Some(v2.brain_id.as_str()) {
2504            return Err(invalid_feed(
2505                "resolve card is not bound to the verified v2 brain",
2506            ));
2507        }
2508        let card_identity: FeedIdentity = serde_json::from_value(
2509            resolved
2510                .get("identity")
2511                .cloned()
2512                .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2513        )
2514        .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2515        if card_identity != v2_identity(&v2.identity) {
2516            return Err(invalid_feed(
2517                "resolve card identity differs from the verified v2 identity",
2518            ));
2519        }
2520        accept_v2_head(cfg, &v2)?;
2521        if let Value::Object(card) = &mut resolved {
2522            card.insert(
2523                "headSeq".to_string(),
2524                json!(v2.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
2525            );
2526            card.insert(
2527                "feedHash".to_string(),
2528                v2.pointer
2529                    .as_ref()
2530                    .map(|pointer| Value::String(pointer.feed_hash.clone()))
2531                    .unwrap_or(Value::Null),
2532            );
2533            card.insert(
2534                "storageProfile".to_string(),
2535                Value::String("v2".to_string()),
2536            );
2537            if let Some(pointer) = &v2.pointer {
2538                card.insert(
2539                    "updatedAt".to_string(),
2540                    Value::String(pointer.signed_at.clone()),
2541                );
2542            }
2543        }
2544        return Ok(resolved);
2545    }
2546    // A successful direct response is accepted only after the same centralized
2547    // identity/rotation/feed checkpoint verification used by sync and
2548    // subscribe. No verb gets a weaker ad-hoc pinning path.
2549    let remote = verified_remote_head(cfg, &addr.brain, false)?;
2550    if resolved.get("id").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2551        || resolved.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2552        || resolved.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
2553    {
2554        return Err(invalid_feed(
2555            "resolve card is not bound to the exact verified feed checkpoint",
2556        ));
2557    }
2558    let card_identity: FeedIdentity = serde_json::from_value(
2559        resolved
2560            .get("identity")
2561            .cloned()
2562            .ok_or_else(|| invalid_feed("resolve card has no signed identity"))?,
2563    )
2564    .map_err(|_| invalid_feed("resolve card has an invalid identity"))?;
2565    if remote.identity.as_ref() != Some(&card_identity) {
2566        return Err(invalid_feed(
2567            "resolve card identity differs from the verified feed identity",
2568        ));
2569    }
2570    Ok(resolved)
2571}
2572
2573/// Resolve one record strictly from the exact signed snapshot pack. The hub's
2574/// mutable query/index result is intentionally not consulted: a signed pack
2575/// digest is the only cryptographic binding between a feed checkpoint and
2576/// record bytes in wire profile v1.
2577fn resolve_from_verified_pack(
2578    brain: &str,
2579    target: &AddressTarget,
2580    pack: Vec<u8>,
2581) -> LinkResult<Value> {
2582    let entries = parse_store_pack(pack)?;
2583    let mut matched: Option<(String, Vec<u8>)> = None;
2584
2585    for (path, bytes) in entries {
2586        let is_candidate = match target {
2587            AddressTarget::Path(want) => &path == want,
2588            AddressTarget::Id(_) => {
2589                path.ends_with(".md")
2590                    && (path.starts_with("records/") || path.starts_with("sources/"))
2591            }
2592        };
2593        if !is_candidate {
2594            continue;
2595        }
2596        let text = std::str::from_utf8(&bytes)
2597            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2598        let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2599            .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2600        if let AddressTarget::Id(want) = target {
2601            let frontmatter =
2602                crate::parser::Frontmatter::parse(&parsed.frontmatter_yaml, Path::new(&path))
2603                    .map_err(|_| {
2604                        invalid_feed(format!("signed snapshot record `{path}` is malformed"))
2605                    })?;
2606            if frontmatter.id.as_deref() != Some(want) {
2607                continue;
2608            }
2609        }
2610        if matched.is_some() {
2611            return Err(invalid_feed(
2612                "signed snapshot contains more than one record for the requested target",
2613            ));
2614        }
2615        matched = Some((path, bytes));
2616    }
2617
2618    let (path, bytes) = matched.ok_or_else(|| LinkError::Http {
2619        what: "resolve",
2620        status: 404,
2621        message: "record not found".to_string(),
2622        code: Some("NOT_FOUND".to_string()),
2623        details: None,
2624    })?;
2625    resolve_from_verified_record_bytes(brain, target, path, bytes)
2626}
2627
2628fn resolve_from_verified_record_bytes(
2629    brain: &str,
2630    target: &AddressTarget,
2631    path: String,
2632    bytes: Vec<u8>,
2633) -> LinkResult<Value> {
2634    match target {
2635        AddressTarget::Path(expected) if expected != &path => {
2636            return Err(invalid_feed(
2637                "verified record path differs from the requested path",
2638            ));
2639        }
2640        AddressTarget::Id(_) if !(path.starts_with("records/") || path.starts_with("sources/")) => {
2641            return Err(invalid_feed(
2642                "verified id resolved outside records or sources",
2643            ));
2644        }
2645        _ => {}
2646    }
2647    let text = std::str::from_utf8(&bytes)
2648        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is not UTF-8")))?;
2649    let parsed = crate::parser::split_frontmatter(text, Path::new(&path))
2650        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2651    let frontmatter: Value = serde_norway::from_str(&parsed.frontmatter_yaml)
2652        .map_err(|_| invalid_feed(format!("signed snapshot record `{path}` is malformed")))?;
2653    let Value::Object(fields) = frontmatter else {
2654        return Err(invalid_feed(format!(
2655            "signed snapshot record `{path}` frontmatter is not a mapping"
2656        )));
2657    };
2658    if let AddressTarget::Id(expected) = target {
2659        if fields.get("id").and_then(Value::as_str) != Some(expected.as_str()) {
2660            return Err(invalid_feed(
2661                "verified record id differs from the requested id",
2662            ));
2663        }
2664    }
2665    let mut document = serde_json::Map::new();
2666    document.insert("path".to_string(), Value::String(path));
2667    for (key, value) in fields {
2668        document.insert(key, value);
2669    }
2670    document.insert("body".to_string(), Value::String(parsed.body));
2671    document.insert(
2672        "contentSha".to_string(),
2673        Value::String(content_sha256(&bytes)),
2674    );
2675    Ok(json!({
2676        "brain": brain,
2677        "document": Value::Object(document),
2678    }))
2679}
2680
2681// ─────────────────────────────────────────────────────────────────────────────
2682// sync — pull the granted slice as files; push the local store as a snapshot
2683// ─────────────────────────────────────────────────────────────────────────────
2684
2685/// What a pull materialized.
2686#[derive(Debug, Clone, serde::Serialize)]
2687pub struct PullReport {
2688    /// The brain id the hub reported.
2689    pub brain: String,
2690    /// The brain's slug.
2691    pub slug: String,
2692    /// The hub's feed head at export time.
2693    #[serde(rename = "headSeq")]
2694    pub head_seq: u64,
2695    /// How many files were written.
2696    pub files: usize,
2697    /// Where they were written (as given or derived from the slug).
2698    pub dest: String,
2699    /// Local content files that the export did not carry — present so a
2700    /// caller sees divergence; nothing is ever deleted locally.
2701    #[serde(rename = "extraLocal")]
2702    pub extra_local: Vec<String>,
2703    /// Exact convergence result from the post-install barrier.
2704    #[serde(rename = "syncStatus")]
2705    pub sync_status: String,
2706}
2707
2708struct V2PulledSnapshot {
2709    report: PullReport,
2710    head: V2VerifiedHead,
2711    files: std::collections::BTreeMap<String, V2BaselineFile>,
2712    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
2713    local: V2LocalView,
2714    local_assets: std::collections::BTreeMap<String, crate::AssetRecord>,
2715}
2716
2717fn download_verified_snapshot_pack(
2718    cfg: &HubConfig,
2719    brain: &str,
2720    remote: &VerifiedRemote,
2721) -> LinkResult<Vec<u8>> {
2722    let feed_hash = remote
2723        .head
2724        .feed_hash
2725        .as_deref()
2726        .ok_or_else(|| invalid_feed("non-empty snapshot has no verified feed hash"))?;
2727    let signed_head = remote
2728        .head_entry
2729        .as_ref()
2730        .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
2731    let expected = &signed_head.entry.pack_sha256;
2732    if !is_sha256(expected) {
2733        return Err(invalid_feed(
2734            "signed head carries an invalid snapshot pack digest",
2735        ));
2736    }
2737    let path = format!(
2738        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={feed_hash}",
2739        remote.head.seq
2740    );
2741    let body = ensure_ok(
2742        request(cfg, "GET", &path, None, Auth::Required)?,
2743        "sync pull",
2744    )?;
2745    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
2746        || body.get("feedHash").and_then(Value::as_str) != Some(feed_hash)
2747        || body.get("brain").and_then(Value::as_str) != Some(remote.head.brain.as_str())
2748        || body.get("sha256").and_then(Value::as_str) != Some(expected.as_str())
2749    {
2750        return Err(invalid_feed(
2751            "export response is not bound to the exact verified snapshot",
2752        ));
2753    }
2754    let url = body
2755        .get("url")
2756        .and_then(Value::as_str)
2757        .ok_or_else(|| invalid_feed("verified snapshot export carried no exact pack URL"))?;
2758    let bytes = get_presigned(cfg, url)?;
2759    if content_sha256(&bytes) != *expected {
2760        return Err(LinkError::InvalidPack {
2761            message: "downloaded pack does not match the signed snapshot digest".to_string(),
2762        });
2763    }
2764    let entries = parse_store_pack(bytes.clone())?;
2765    if signed_head.entry.kind == "push" {
2766        verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
2767    }
2768    Ok(bytes)
2769}
2770
2771#[derive(Debug, Clone, Deserialize, Serialize)]
2772struct V2PointerBody {
2773    v: u8,
2774    brain: String,
2775    seq: u64,
2776    commit_hash: String,
2777    feed_hash: String,
2778    content_root: Option<String>,
2779    asset_root: Option<String>,
2780    materializer: String,
2781    signer_epoch: u64,
2782    control_revision: String,
2783    backup_preparation: String,
2784    prior_pointer_hash: Option<String>,
2785    signed_at: String,
2786}
2787
2788#[derive(Debug, Clone, Deserialize)]
2789struct V2SignedPointer {
2790    pointer: V2PointerBody,
2791    hub_public_key: String,
2792    hub_fingerprint: String,
2793    sig: String,
2794}
2795
2796#[derive(Debug, Clone, Deserialize)]
2797struct V2HeadIdentity {
2798    #[serde(default)]
2799    custody: String,
2800    fingerprint: String,
2801    public_key_spki: String,
2802    #[serde(default)]
2803    previous: Vec<V2PreviousIdentity>,
2804    #[serde(default)]
2805    rotations: Vec<String>,
2806}
2807
2808#[derive(Debug, Clone, Deserialize)]
2809struct V2PreviousIdentity {
2810    fingerprint: String,
2811    public_key_spki: String,
2812}
2813
2814#[derive(Debug, Deserialize)]
2815struct V2HeadResponse {
2816    v: u8,
2817    brain_id: String,
2818    profile: String,
2819    view: Option<V2HeadView>,
2820    pointer: Option<V2SignedPointer>,
2821    identity: Option<V2HeadIdentity>,
2822}
2823
2824#[derive(Debug, Clone, Deserialize)]
2825struct V2HeadView {
2826    kind: String,
2827    #[serde(default)]
2828    id: Option<String>,
2829    control_revision: String,
2830}
2831
2832#[derive(Debug, Clone)]
2833struct V2VerifiedHead {
2834    requested: String,
2835    brain_id: String,
2836    view_kind: String,
2837    /// Stable identity of the exact current-content path projection.
2838    view_revision: String,
2839    /// Use-time authority revision independently bound into writes/signatures.
2840    control_revision: String,
2841    identity: V2HeadIdentity,
2842    pointer: Option<V2PointerBody>,
2843    trust: TrustState,
2844    alias: Option<AliasBinding>,
2845}
2846
2847fn verify_v2_spki_signature(
2848    public_key: &str,
2849    message: &[u8],
2850    signature: &str,
2851) -> LinkResult<Vec<u8>> {
2852    let der = URL_SAFE_NO_PAD
2853        .decode(public_key)
2854        .map_err(|_| invalid_feed("v2 signer public key is not base64url"))?;
2855    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
2856        return Err(invalid_feed("v2 signer public key is not Ed25519 SPKI"));
2857    }
2858    let sig = URL_SAFE_NO_PAD
2859        .decode(signature)
2860        .map_err(|_| invalid_feed("v2 signature is not base64url"))?;
2861    UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
2862        .verify(message, &sig)
2863        .map_err(|_| invalid_feed("v2 Ed25519 signature failed"))?;
2864    Ok(der)
2865}
2866
2867fn verify_v2_pointer(pointer: &V2SignedPointer, expected_brain: &str) -> LinkResult<String> {
2868    if pointer.pointer.v != 2
2869        || pointer.pointer.brain != expected_brain
2870        || pointer.pointer.seq == 0
2871        || !is_sha256(&pointer.pointer.commit_hash)
2872        || !is_sha256(&pointer.pointer.feed_hash)
2873        || pointer
2874            .pointer
2875            .content_root
2876            .as_deref()
2877            .is_some_and(|hash| !is_sha256(hash))
2878        || !is_sha256(&pointer.pointer.backup_preparation)
2879    {
2880        return Err(invalid_feed("v2 pointer fields are invalid"));
2881    }
2882    let value = serde_json::to_value(&pointer.pointer)
2883        .map_err(|_| invalid_feed("v2 pointer could not be canonicalized"))?;
2884    let message = crate::linkmd_v2::canonical_bytes(&value)
2885        .map_err(|error| invalid_feed(error.to_string()))?;
2886    let der = verify_v2_spki_signature(&pointer.hub_public_key, &message, &pointer.sig)?;
2887    let fingerprint = format!("{:x}", Sha256::digest(&der));
2888    if fingerprint != pointer.hub_fingerprint {
2889        return Err(invalid_feed("v2 hub signer fingerprint mismatch"));
2890    }
2891    Ok(format!(
2892        "{}:{}",
2893        pointer.hub_fingerprint, pointer.hub_public_key
2894    ))
2895}
2896
2897fn v2_identity(identity: &V2HeadIdentity) -> FeedIdentity {
2898    FeedIdentity {
2899        fingerprint: identity.fingerprint.clone(),
2900        public_key_spki: identity.public_key_spki.clone(),
2901        previous: identity
2902            .previous
2903            .iter()
2904            .map(|previous| PreviousIdentity {
2905                fingerprint: previous.fingerprint.clone(),
2906                public_key_spki: previous.public_key_spki.clone(),
2907            })
2908            .collect(),
2909        rotations: identity.rotations.clone(),
2910    }
2911}
2912
2913fn verified_v2_commit_object(
2914    raw: &[u8],
2915    identity: &V2HeadIdentity,
2916) -> LinkResult<serde_json::Map<String, Value>> {
2917    let mut value: Value =
2918        serde_json::from_slice(raw).map_err(|_| invalid_feed("v2 commit is not JSON"))?;
2919    let canonical = crate::linkmd_v2::canonical_bytes(&value)
2920        .map_err(|error| invalid_feed(error.to_string()))?;
2921    if canonical != raw {
2922        return Err(invalid_feed("v2 commit is not canonical JSON"));
2923    }
2924    let object = value
2925        .as_object_mut()
2926        .ok_or_else(|| invalid_feed("v2 commit is not an object"))?;
2927    let sig = object
2928        .remove("sig")
2929        .and_then(|value| value.as_str().map(str::to_string))
2930        .ok_or_else(|| invalid_feed("v2 commit has no signature"))?;
2931    const FIELDS: [&str; 18] = [
2932        "actor_ref",
2933        "asset_root",
2934        "brain",
2935        "changes_sha256",
2936        "control_revision",
2937        "materializer",
2938        "op",
2939        "parent_asset_root",
2940        "parent_commit",
2941        "parent_root",
2942        "prev_entry_hash",
2943        "public_key",
2944        "seq",
2945        "signer_epoch",
2946        "state_root",
2947        "ts",
2948        "v",
2949        "v1_bridge",
2950    ];
2951    if object.len() != FIELDS.len() || FIELDS.iter().any(|field| !object.contains_key(*field)) {
2952        return Err(invalid_feed("v2 commit has a non-normative field set"));
2953    }
2954    let seq = object
2955        .get("seq")
2956        .and_then(Value::as_u64)
2957        .filter(|seq| *seq > 0)
2958        .ok_or_else(|| invalid_feed("v2 commit has an invalid sequence"))?;
2959    let signer_epoch = object
2960        .get("signer_epoch")
2961        .and_then(Value::as_u64)
2962        .filter(|epoch| *epoch > 0)
2963        .ok_or_else(|| invalid_feed("v2 commit has an invalid signer epoch"))?;
2964    let hash_or_null = |field: &str| {
2965        object
2966            .get(field)
2967            .is_some_and(|value| value.is_null() || value.as_str().is_some_and(is_sha256))
2968    };
2969    if object.get("v").and_then(Value::as_u64) != Some(2)
2970        || object.get("op").and_then(Value::as_str) != Some("changeset")
2971        || !object
2972            .get("changes_sha256")
2973            .and_then(Value::as_str)
2974            .is_some_and(is_sha256)
2975        || !object
2976            .get("actor_ref")
2977            .and_then(Value::as_str)
2978            .is_some_and(is_sha256)
2979        || !object
2980            .get("control_revision")
2981            .and_then(Value::as_str)
2982            .is_some_and(is_sha256)
2983        || !object
2984            .get("state_root")
2985            .and_then(Value::as_str)
2986            .is_some_and(is_sha256)
2987        || !hash_or_null("parent_commit")
2988        || !hash_or_null("parent_root")
2989        || !hash_or_null("parent_asset_root")
2990        || !hash_or_null("asset_root")
2991        || !hash_or_null("prev_entry_hash")
2992        || !object
2993            .get("materializer")
2994            .and_then(Value::as_str)
2995            .is_some_and(|value| !value.is_empty() && value.len() <= 128)
2996        || !object
2997            .get("ts")
2998            .and_then(Value::as_str)
2999            .is_some_and(|value| value.len() == 24 && value.ends_with('Z'))
3000    {
3001        return Err(invalid_feed("v2 commit fields are invalid"));
3002    }
3003    if (seq == 1
3004        && [
3005            "parent_commit",
3006            "parent_root",
3007            "parent_asset_root",
3008            "prev_entry_hash",
3009        ]
3010        .iter()
3011        .any(|field| !object.get(*field).is_some_and(Value::is_null)))
3012        || (seq > 1
3013            && ["parent_commit", "parent_root", "prev_entry_hash"]
3014                .iter()
3015                .any(|field| object.get(*field).and_then(Value::as_str).is_none()))
3016    {
3017        return Err(invalid_feed("v2 commit parent shape is invalid"));
3018    }
3019    match object.get("v1_bridge") {
3020        Some(Value::Null) => {}
3021        Some(Value::Object(bridge))
3022            if seq == 1
3023                && bridge.len() == 3
3024                && bridge
3025                    .get("head_seq")
3026                    .and_then(Value::as_u64)
3027                    .is_some_and(|v| v > 0)
3028                && bridge
3029                    .get("feed_hash")
3030                    .and_then(Value::as_str)
3031                    .is_some_and(is_sha256)
3032                && bridge
3033                    .get("pack_sha256")
3034                    .and_then(Value::as_str)
3035                    .is_some_and(is_sha256) => {}
3036        _ => return Err(invalid_feed("v2 commit has an invalid v1 bridge")),
3037    }
3038    let public_key = object
3039        .get("public_key")
3040        .and_then(Value::as_str)
3041        .ok_or_else(|| invalid_feed("v2 commit has no public key"))?;
3042    let der = URL_SAFE_NO_PAD
3043        .decode(public_key)
3044        .map_err(|_| invalid_feed("v2 brain public key is not base64url"))?;
3045    let expected_multikey = format!("ed25519:{}", URL_SAFE_NO_PAD.encode(Sha256::digest(&der)));
3046    if object.get("brain").and_then(Value::as_str) != Some(expected_multikey.as_str()) {
3047        return Err(invalid_feed("v2 commit brain identity mismatch"));
3048    }
3049    // Verify the old-key-signed chain before trusting any prior public key.
3050    verify_identity_chain(&v2_identity(identity), None)?;
3051    // Old commits remain verifiable after brain-key rotation. `previous` is
3052    // newest-first, while signer epochs and rotation statements are oldest-first.
3053    let mut chain: Vec<(&str, &str)> = identity
3054        .previous
3055        .iter()
3056        .rev()
3057        .map(|previous| {
3058            (
3059                previous.fingerprint.as_str(),
3060                previous.public_key_spki.as_str(),
3061            )
3062        })
3063        .collect();
3064    chain.push((&identity.fingerprint, &identity.public_key_spki));
3065    let signer_index = chain.iter().position(|(fingerprint, spki)| {
3066        *fingerprint == expected_multikey.trim_start_matches("ed25519:") && *spki == public_key
3067    });
3068    let Some(signer_index) = signer_index else {
3069        return Err(invalid_feed("v2 commit uses an unrecognized brain key"));
3070    };
3071    if signer_epoch != signer_index as u64 + 1 {
3072        return Err(invalid_feed("v2 commit signer epoch differs from its key"));
3073    }
3074    let lower_boundary = if signer_index == 0 {
3075        None
3076    } else {
3077        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
3078            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3079        Some(prior.prior_head_seq)
3080    };
3081    let upper_boundary = if signer_index == identity.rotations.len() {
3082        None
3083    } else {
3084        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
3085            .map_err(|_| invalid_feed("v2 rotation statement did not parse"))?;
3086        Some(next.prior_head_seq)
3087    };
3088    if lower_boundary.is_some_and(|boundary| seq <= boundary)
3089        || upper_boundary.is_some_and(|boundary| seq > boundary)
3090    {
3091        return Err(invalid_feed(
3092            "v2 commit signer is outside its authenticated rotation epoch",
3093        ));
3094    }
3095    let unsigned = crate::linkmd_v2::canonical_bytes(&Value::Object(object.clone()))
3096        .map_err(|error| invalid_feed(error.to_string()))?;
3097    verify_v2_spki_signature(public_key, &unsigned, &sig)?;
3098    Ok(object.clone())
3099}
3100
3101#[derive(Debug, Deserialize)]
3102struct V2FeedWireEntry {
3103    seq: u64,
3104    commit_hash: String,
3105    feed_hash: String,
3106    bytes_base64: String,
3107}
3108
3109#[derive(Debug, Deserialize)]
3110struct V2FeedPage {
3111    v: u8,
3112    head_seq: u64,
3113    head_commit_hash: String,
3114    head_feed_hash: String,
3115    entries: Vec<V2FeedWireEntry>,
3116    next_after: u64,
3117    complete: bool,
3118}
3119
3120fn replay_v2_feed(
3121    cfg: &HubConfig,
3122    brain: &str,
3123    pointer: &V2PointerBody,
3124    identity: &V2HeadIdentity,
3125    start_after: u64,
3126    start_feed: Option<String>,
3127) -> LinkResult<()> {
3128    let mut after = start_after;
3129    let mut prior_feed = start_feed;
3130    let mut final_object = None;
3131    let mut replayed_entries = 0_u64;
3132    let mut replayed_bytes = 0_u64;
3133    while after < pointer.seq {
3134        let path = format!("/api/hub/brains/{brain}/v2/feed?after={after}&limit=100");
3135        let value = ensure_ok(
3136            request_capped(
3137                cfg,
3138                "GET",
3139                &path,
3140                None,
3141                Auth::Required,
3142                MAX_FEED_REPLAY_BYTES,
3143            )?,
3144            "v2 feed replay",
3145        )?;
3146        let page: V2FeedPage = serde_json::from_value(value)
3147            .map_err(|_| invalid_feed("v2 feed page has an invalid shape"))?;
3148        if page.v != 2
3149            || page.head_seq != pointer.seq
3150            || page.head_commit_hash != pointer.commit_hash
3151            || page.head_feed_hash != pointer.feed_hash
3152            || page.entries.is_empty()
3153            || page.entries.len() > FEED_PAGE_LIMIT
3154        {
3155            return Err(invalid_feed("v2 feed page differs from the signed head"));
3156        }
3157        for entry in page.entries {
3158            if entry.seq != after + 1
3159                || !is_sha256(&entry.commit_hash)
3160                || !is_sha256(&entry.feed_hash)
3161            {
3162                return Err(invalid_feed("v2 feed sequence is not contiguous"));
3163            }
3164            let raw = base64::engine::general_purpose::STANDARD
3165                .decode(&entry.bytes_base64)
3166                .map_err(|_| invalid_feed("v2 feed bytes are not canonical base64"))?;
3167            replayed_entries = replayed_entries
3168                .checked_add(1)
3169                .ok_or_else(|| invalid_feed("v2 feed replay count overflow"))?;
3170            replayed_bytes = replayed_bytes
3171                .checked_add(raw.len() as u64)
3172                .ok_or_else(|| invalid_feed("v2 feed replay byte count overflow"))?;
3173            if replayed_entries > MAX_FEED_REPLAY_ENTRIES || replayed_bytes > MAX_FEED_REPLAY_BYTES
3174            {
3175                return Err(invalid_feed("v2 feed replay exceeds its safety bound"));
3176            }
3177            if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3178                .map_err(|error| invalid_feed(error.to_string()))?
3179                != entry.commit_hash
3180                || content_sha256(&raw) != entry.feed_hash
3181            {
3182                return Err(invalid_feed("v2 feed entry address mismatch"));
3183            }
3184            let object = verified_v2_commit_object(&raw, identity)?;
3185            if object.get("seq").and_then(Value::as_u64) != Some(entry.seq)
3186                || object.get("prev_entry_hash").and_then(Value::as_str) != prior_feed.as_deref()
3187            {
3188                return Err(invalid_feed(
3189                    "v2 feed entry does not extend its predecessor",
3190                ));
3191            }
3192            after = entry.seq;
3193            prior_feed = Some(entry.feed_hash);
3194            final_object = Some((entry.commit_hash, object));
3195        }
3196        if page.next_after != after || (page.complete != (after == pointer.seq)) {
3197            return Err(invalid_feed("v2 feed page cursor is inconsistent"));
3198        }
3199    }
3200    let (final_hash, object) =
3201        final_object.ok_or_else(|| invalid_feed("v2 feed replay made no progress"))?;
3202    if final_hash != pointer.commit_hash
3203        || prior_feed.as_deref() != Some(pointer.feed_hash.as_str())
3204        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3205        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3206        || object.get("control_revision").and_then(Value::as_str)
3207            != Some(pointer.control_revision.as_str())
3208        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3209    {
3210        return Err(invalid_feed(
3211            "v2 replay did not converge on the signed pointer",
3212        ));
3213    }
3214    Ok(())
3215}
3216
3217fn verify_v1_to_v2_bridge(
3218    cfg: &HubConfig,
3219    brain: &str,
3220    pointer: &V2PointerBody,
3221    identity: &V2HeadIdentity,
3222    checkpoint: &TrustState,
3223) -> LinkResult<()> {
3224    let value = ensure_ok(
3225        request_capped(
3226            cfg,
3227            "GET",
3228            &format!("/api/hub/brains/{brain}/v2/feed?after=0&limit=1"),
3229            None,
3230            Auth::Required,
3231            MAX_FEED_RESPONSE_BYTES,
3232        )?,
3233        "v2 genesis bridge",
3234    )?;
3235    let page: V2FeedPage = serde_json::from_value(value)
3236        .map_err(|_| invalid_feed("v2 genesis bridge page has an invalid shape"))?;
3237    if page.v != 2
3238        || page.head_seq != pointer.seq
3239        || page.head_commit_hash != pointer.commit_hash
3240        || page.head_feed_hash != pointer.feed_hash
3241        || page.entries.len() != 1
3242        || page.entries[0].seq != 1
3243        || !is_sha256(&page.entries[0].commit_hash)
3244        || !is_sha256(&page.entries[0].feed_hash)
3245    {
3246        return Err(invalid_feed(
3247            "v2 genesis bridge page differs from the signed head",
3248        ));
3249    }
3250    let first = &page.entries[0];
3251    let raw = STANDARD
3252        .decode(&first.bytes_base64)
3253        .map_err(|_| invalid_feed("v2 genesis bridge bytes are not canonical base64"))?;
3254    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3255        .map_err(|error| invalid_feed(error.to_string()))?
3256        != first.commit_hash
3257        || content_sha256(&raw) != first.feed_hash
3258    {
3259        return Err(invalid_feed("v2 genesis bridge address mismatch"));
3260    }
3261    let object = verified_v2_commit_object(&raw, identity)?;
3262    if checkpoint.head_seq == 0 {
3263        if checkpoint.feed_hash.is_some() || object.get("v1_bridge") != Some(&Value::Null) {
3264            return Err(invalid_feed(
3265                "empty v1 checkpoint did not transition through an empty v2 genesis",
3266            ));
3267        }
3268        return Ok(());
3269    }
3270    let bridge = object
3271        .get("v1_bridge")
3272        .and_then(Value::as_object)
3273        .ok_or_else(|| invalid_feed("v2 genesis omitted the pinned v1 boundary"))?;
3274    let checkpoint_feed = checkpoint
3275        .feed_hash
3276        .as_deref()
3277        .ok_or_else(|| invalid_feed("non-empty v1 checkpoint has no feed hash"))?;
3278    if bridge.get("head_seq").and_then(Value::as_u64) != Some(checkpoint.head_seq)
3279        || bridge.get("feed_hash").and_then(Value::as_str) != Some(checkpoint_feed)
3280    {
3281        return Err(invalid_feed(
3282            "v2 genesis bridge differs from the pinned v1 checkpoint",
3283        ));
3284    }
3285    let legacy_raw = ensure_raw_ok(
3286        request_raw(
3287            cfg,
3288            "GET",
3289            &format!(
3290                "/api/hub/brains/{brain}/feed?after={}&limit=1",
3291                checkpoint.head_seq - 1
3292            ),
3293            None,
3294            Auth::Required,
3295            MAX_FEED_RESPONSE_BYTES,
3296        )?,
3297        "v1 bridge boundary",
3298    )?;
3299    let legacy: FeedResponse = serde_json::from_slice(&legacy_raw)
3300        .map_err(|_| invalid_feed("v1 bridge boundary has an invalid feed shape"))?;
3301    let legacy_identity = legacy
3302        .identity
3303        .ok_or_else(|| invalid_feed("v1 bridge boundary has no identity"))?;
3304    let item = legacy
3305        .entries
3306        .first()
3307        .filter(|_| legacy.entries.len() == 1)
3308        .ok_or_else(|| invalid_feed("v1 bridge boundary did not return one exact entry"))?;
3309    if legacy.scope_limited
3310        || legacy.head_seq != checkpoint.head_seq
3311        || legacy.feed_hash.as_deref() != Some(checkpoint_feed)
3312        || item.entry.seq != checkpoint.head_seq
3313        || item.hash != checkpoint_feed
3314        || legacy_identity != v2_identity(identity)
3315        || bridge.get("pack_sha256").and_then(Value::as_str)
3316            != Some(item.entry.pack_sha256.as_str())
3317    {
3318        return Err(invalid_feed(
3319            "v1 bridge boundary differs from its signed legacy head",
3320        ));
3321    }
3322    let anchor = verify_identity_chain(&legacy_identity, Some(checkpoint))?;
3323    if anchor != checkpoint.anchor {
3324        return Err(invalid_feed("v1 bridge changed the pinned identity anchor"));
3325    }
3326    verify_feed_item(item, &legacy_identity)?;
3327    verify_rotation_feed_boundaries(
3328        &legacy_identity,
3329        Some(checkpoint),
3330        std::slice::from_ref(item),
3331        checkpoint.head_seq,
3332    )?;
3333    Ok(())
3334}
3335
3336fn verify_v2_commit(
3337    cfg: &HubConfig,
3338    brain: &str,
3339    pointer: &V2PointerBody,
3340    identity: &V2HeadIdentity,
3341    pinned: Option<&TrustState>,
3342) -> LinkResult<()> {
3343    let path = format!(
3344        "/api/hub/brains/{brain}/v2/commit?commit={}",
3345        pointer.commit_hash
3346    );
3347    let raw = ensure_raw_ok(
3348        request_raw(cfg, "GET", &path, None, Auth::Required, MAX_RESPONSE_BYTES)?,
3349        "v2 commit",
3350    )?;
3351    if crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw)
3352        .map_err(|error| invalid_feed(error.to_string()))?
3353        != pointer.commit_hash
3354        || content_sha256(&raw) != pointer.feed_hash
3355    {
3356        return Err(invalid_feed("v2 commit address differs from the pointer"));
3357    }
3358    let object = verified_v2_commit_object(&raw, identity)?;
3359    if object.get("seq").and_then(Value::as_u64) != Some(pointer.seq)
3360        || object.get("state_root").and_then(Value::as_str) != pointer.content_root.as_deref()
3361        || object.get("asset_root").and_then(Value::as_str) != pointer.asset_root.as_deref()
3362        || object.get("control_revision").and_then(Value::as_str)
3363            != Some(pointer.control_revision.as_str())
3364        || object.get("materializer").and_then(Value::as_str) != Some(pointer.materializer.as_str())
3365    {
3366        return Err(invalid_feed("v2 commit fields differ from the pointer"));
3367    }
3368    if let Some(checkpoint) = pinned.filter(|checkpoint| accepted_as_v2(checkpoint)) {
3369        if pointer.seq == checkpoint.head_seq + 1
3370            && object.get("prev_entry_hash").and_then(Value::as_str)
3371                != checkpoint.feed_hash.as_deref()
3372        {
3373            return Err(invalid_feed(
3374                "v2 commit does not extend the pinned feed hash",
3375            ));
3376        }
3377        if pointer.seq > checkpoint.head_seq + 1 {
3378            return replay_v2_feed(
3379                cfg,
3380                brain,
3381                pointer,
3382                identity,
3383                checkpoint.head_seq,
3384                checkpoint.feed_hash.clone(),
3385            );
3386        }
3387    } else {
3388        if let Some(checkpoint) = pinned {
3389            verify_v1_to_v2_bridge(cfg, brain, pointer, identity, checkpoint)?;
3390        }
3391        if pointer.seq > 1 {
3392            return replay_v2_feed(cfg, brain, pointer, identity, 0, None);
3393        }
3394    }
3395    Ok(())
3396}
3397
3398fn v2_verified_head(cfg: &HubConfig, brain: &str) -> LinkResult<Option<V2VerifiedHead>> {
3399    require_hardened_filesystem("verified link.md v2 state")?;
3400    require_safe_ref(brain)?;
3401    // Open and pin the private trust directory before a credential can leave
3402    // the process. A hostile checkout must not turn an unsafe state root into
3403    // a network oracle or receive a bearer before checkpoint safety is known.
3404    let trust_directory = open_trust_dir(cfg)?;
3405    let path = format!("/api/hub/brains/{brain}/v2/head");
3406    let response = request(cfg, "GET", &path, None, Auth::Required)?;
3407    if response.status == 404 {
3408        if has_accepted_v2_ref(cfg, brain)? {
3409            return Err(LinkError::BrainUnavailable);
3410        }
3411        return Ok(None);
3412    }
3413    let body = ensure_ok(response, "v2 head")?;
3414    let head: V2HeadResponse = serde_json::from_value(body)
3415        .map_err(|_| invalid_feed("v2 head response has an invalid shape"))?;
3416    if head.v != 2 || !crate::ulid::is_ulid(&head.brain_id) {
3417        return Err(invalid_feed("v2 head has no canonical brain id"));
3418    }
3419    if crate::ulid::is_ulid(brain) && head.brain_id != brain {
3420        return Err(invalid_feed("v2 head resolved a different brain id"));
3421    }
3422    if head.profile == "v1" {
3423        return Ok(None);
3424    }
3425    if head.profile != "v2" && head.profile != "v2-empty" {
3426        return Err(invalid_feed("v2 head advertised an unknown profile"));
3427    }
3428    let view = head
3429        .view
3430        .as_ref()
3431        .ok_or_else(|| invalid_feed("v2 head has no permission view"))?;
3432    if !matches!(view.kind.as_str(), "full" | "scoped")
3433        || !is_sha256(&view.control_revision)
3434        || view.id.as_deref().is_some_and(|id| !is_sha256(id))
3435    {
3436        return Err(invalid_feed("v2 head has an invalid permission view"));
3437    }
3438    let view_kind = view.kind.clone();
3439    // `id` is the stable materialized namespace. Older v2 hubs did not expose
3440    // it, so retain the fail-closed control-revision fallback for compatibility.
3441    let view_revision = view
3442        .id
3443        .clone()
3444        .unwrap_or_else(|| view.control_revision.clone());
3445    let control_revision = view.control_revision.clone();
3446    let identity = head
3447        .identity
3448        .as_ref()
3449        .ok_or_else(|| invalid_feed("v2 head has no brain identity"))?;
3450    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &head.brain_id])?;
3451    let (pinned, alias_binding) = load_canonical_pin(cfg, &trust_directory, brain, &head.brain_id)?;
3452    let feed_identity = v2_identity(identity);
3453    let anchor = verify_identity_chain(&feed_identity, pinned.as_ref())?;
3454    let (seq, feed_hash, hub_signer) = match &head.pointer {
3455        None => {
3456            if head.profile != "v2-empty" {
3457                return Err(invalid_feed("initialized v2 head has no pointer"));
3458            }
3459            (
3460                0,
3461                None,
3462                pinned.as_ref().and_then(|state| state.hub_signer.clone()),
3463            )
3464        }
3465        Some(signed) => {
3466            let signer = verify_v2_pointer(signed, &head.brain_id)?;
3467            if pinned
3468                .as_ref()
3469                .and_then(|state| state.hub_signer.as_ref())
3470                .is_some_and(|known| known != &signer)
3471            {
3472                return Err(invalid_feed(
3473                    "v2 hub pointer signer changed without a trust transition",
3474                ));
3475            }
3476            if let Some(checkpoint) = pinned.as_ref().filter(|state| accepted_as_v2(state)) {
3477                if signed.pointer.seq < checkpoint.head_seq
3478                    || (signed.pointer.seq == checkpoint.head_seq
3479                        && checkpoint.feed_hash.as_deref()
3480                            != Some(signed.pointer.feed_hash.as_str()))
3481                {
3482                    return Err(invalid_feed("v2 pointer rolled back or equivocated"));
3483                }
3484            }
3485            verify_v2_commit(
3486                cfg,
3487                &head.brain_id,
3488                &signed.pointer,
3489                identity,
3490                pinned.as_ref(),
3491            )?;
3492            (
3493                signed.pointer.seq,
3494                Some(signed.pointer.feed_hash.clone()),
3495                Some(signer),
3496            )
3497        }
3498    };
3499    let trust = TrustState {
3500        v: 2,
3501        origin: normalized_origin(&cfg.hub)?,
3502        requested: head.brain_id.clone(),
3503        brain: head.brain_id.clone(),
3504        home: None,
3505        anchor,
3506        current: format!("ed25519:{}", identity.fingerprint),
3507        head_seq: seq,
3508        feed_hash,
3509        rotations: identity.rotations.clone(),
3510        hub_signer,
3511        protocol_profile: Some("link-v2".to_string()),
3512    };
3513    Ok(Some(V2VerifiedHead {
3514        requested: brain.to_string(),
3515        brain_id: head.brain_id,
3516        view_kind,
3517        view_revision,
3518        control_revision,
3519        identity: identity.clone(),
3520        pointer: head.pointer.map(|signed| signed.pointer),
3521        trust,
3522        alias: alias_binding,
3523    }))
3524}
3525
3526fn accept_v2_head(cfg: &HubConfig, head: &V2VerifiedHead) -> LinkResult<()> {
3527    let directory = open_trust_dir(cfg)?;
3528    let _locks = lock_trust_many(cfg, &directory, &[&head.requested, &head.brain_id])?;
3529    let (current, alias) = load_canonical_pin(cfg, &directory, &head.requested, &head.brain_id)?;
3530    if let Some(current) = current {
3531        let common_invalid = head.trust.anchor != current.anchor
3532            || !head.trust.rotations.starts_with(&current.rotations);
3533        let profile_invalid = if accepted_as_v2(&current) {
3534            head.trust.head_seq < current.head_seq
3535                || (head.trust.head_seq == current.head_seq
3536                    && head.trust.feed_hash != current.feed_hash)
3537                || current
3538                    .hub_signer
3539                    .as_ref()
3540                    .is_some_and(|known| head.trust.hub_signer.as_ref() != Some(known))
3541        } else {
3542            head.trust.protocol_profile.as_deref() != Some("link-v2")
3543                || head.trust.hub_signer.is_none()
3544        };
3545        if common_invalid || profile_invalid {
3546            return Err(invalid_feed(
3547                "v2 head cannot advance the currently accepted trust checkpoint",
3548            ));
3549        }
3550    }
3551    save_canonical_pin_and_alias(
3552        cfg,
3553        &directory,
3554        &head.requested,
3555        &head.brain_id,
3556        head.trust.clone(),
3557        alias.as_ref().or(head.alias.as_ref()),
3558    )
3559}
3560
3561#[derive(Debug, Clone, Deserialize, Serialize)]
3562struct V2BaselineFile {
3563    sha256: String,
3564    bytes: u64,
3565    #[serde(skip)]
3566    proof: Option<Vec<V2ProofStep>>,
3567}
3568
3569#[derive(Debug, Clone, Deserialize, Serialize)]
3570struct V2SyncBaseline {
3571    v: u8,
3572    origin: String,
3573    brain: String,
3574    #[serde(default)]
3575    checkout_id: Option<String>,
3576    #[serde(default)]
3577    head_seq: Option<u64>,
3578    commit_hash: Option<String>,
3579    content_root: Option<String>,
3580    #[serde(default)]
3581    asset_root: Option<String>,
3582    #[serde(default)]
3583    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
3584    #[serde(default)]
3585    view_kind: Option<String>,
3586    #[serde(default)]
3587    view_revision: Option<String>,
3588    #[serde(default)]
3589    projection_sha256: Option<String>,
3590    files: std::collections::BTreeMap<String, V2BaselineFile>,
3591    #[serde(default)]
3592    local_policy_digest: Option<String>,
3593    #[serde(default)]
3594    local_eligibility: std::collections::BTreeMap<String, bool>,
3595    #[serde(default)]
3596    remote_copy_remains: std::collections::BTreeMap<String, String>,
3597}
3598
3599struct V2LocalView {
3600    riding: std::collections::BTreeMap<String, (String, u64)>,
3601    eligibility: std::collections::BTreeMap<String, bool>,
3602    policy: crate::linkmd_sync_policy::SyncPolicy,
3603    withheld_links: Vec<V2WithheldLink>,
3604}
3605
3606#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
3607struct V2WithheldLink {
3608    source: String,
3609    target: String,
3610}
3611
3612#[derive(Debug, Clone, Deserialize, Serialize)]
3613struct V2ProofStep {
3614    directory_root: String,
3615    component: String,
3616    proof: crate::linkmd_v2::HamtProof,
3617}
3618
3619#[derive(Debug, Deserialize)]
3620struct V2ManifestFile {
3621    path: String,
3622    sha256: String,
3623    bytes: u64,
3624    proof: Vec<V2ProofStep>,
3625}
3626
3627#[derive(Debug, Deserialize)]
3628struct V2ManifestPage {
3629    v: u8,
3630    commit: String,
3631    content_root: Option<String>,
3632    files: Vec<V2ManifestFile>,
3633    next_cursor: Option<String>,
3634}
3635
3636#[derive(Debug, Clone, Deserialize, Serialize)]
3637struct V2BaselineAsset {
3638    blob_sha256: String,
3639    bytes: u64,
3640    media_type: String,
3641    wrappers: Vec<String>,
3642    required: bool,
3643    disposition: String,
3644    leaf_hash: String,
3645}
3646
3647#[derive(Debug, Deserialize)]
3648struct V2AssetManifestItem {
3649    path: String,
3650    blob_sha256: String,
3651    bytes: u64,
3652    media_type: String,
3653    wrappers: Vec<String>,
3654    required: bool,
3655    disposition: String,
3656    leaf_hash: String,
3657    proof: crate::linkmd_v2::HamtProof,
3658}
3659
3660#[derive(Debug, Deserialize)]
3661struct V2AssetManifestPage {
3662    v: u8,
3663    commit: String,
3664    asset_root: Option<String>,
3665    assets: Vec<V2AssetManifestItem>,
3666    next_cursor: Option<String>,
3667}
3668
3669#[derive(Debug, Deserialize)]
3670struct V2SigningCandidate {
3671    seq: u64,
3672    content_root: Option<String>,
3673    asset_root: Option<String>,
3674    signing_bytes_base64: String,
3675    changes_base64: String,
3676    actor_claim_base64: String,
3677}
3678
3679#[derive(Debug, Deserialize)]
3680struct V2SigningCandidatePage {
3681    v: u8,
3682    challenge_id: String,
3683    mutation_id: String,
3684    request_hash: String,
3685    parent: V2SigningParent,
3686    candidate: V2SigningCandidate,
3687    files: Vec<V2ManifestFile>,
3688    #[serde(default)]
3689    assets: Vec<V2AssetManifestItem>,
3690    next_cursor: Option<String>,
3691    expires_at: String,
3692}
3693
3694#[derive(Debug, Deserialize)]
3695struct V2SigningParent {
3696    seq: u64,
3697    commit_hash: Option<String>,
3698}
3699
3700fn verify_v2_file_proof(root: &str, file: &V2ManifestFile) -> LinkResult<()> {
3701    let normalized = crate::linkmd_v2::normalize_path(&file.path)
3702        .map_err(|error| invalid_feed(error.to_string()))?;
3703    let components = normalized.split('/').collect::<Vec<_>>();
3704    if components.len() != file.proof.len() || !is_sha256(&file.sha256) {
3705        return Err(invalid_feed("v2 file proof has the wrong shape"));
3706    }
3707    let mut directory_root = root.to_string();
3708    for (index, step) in file.proof.iter().enumerate() {
3709        if step.directory_root != directory_root || step.component != components[index] {
3710            return Err(invalid_feed(
3711                "v2 file proof path chain differs from its manifest",
3712            ));
3713        }
3714        if !crate::linkmd_v2::verify_proof(&directory_root, &step.component, &step.proof)
3715            .map_err(|error| invalid_feed(error.to_string()))?
3716        {
3717            return Err(invalid_feed("v2 file proof failed verification"));
3718        }
3719        let entry = match &step.proof {
3720            crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry,
3721            crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
3722                return Err(invalid_feed("v2 manifest carried a non-inclusion proof"));
3723            }
3724        };
3725        if index + 1 == components.len() {
3726            if entry.kind != crate::linkmd_v2::EntryKind::Blob
3727                || entry.child_hash != file.sha256
3728                || entry.bytes != Some(file.bytes)
3729            {
3730                return Err(invalid_feed("v2 file proof leaf differs from its manifest"));
3731            }
3732        } else if entry.kind != crate::linkmd_v2::EntryKind::Tree {
3733            return Err(invalid_feed("v2 file proof traversed a non-directory"));
3734        } else {
3735            directory_root = entry.child_hash.clone();
3736        }
3737    }
3738    Ok(())
3739}
3740
3741fn v2_manifest(
3742    cfg: &HubConfig,
3743    brain: &str,
3744    pointer: Option<&V2PointerBody>,
3745) -> LinkResult<std::collections::BTreeMap<String, V2BaselineFile>> {
3746    let Some(pointer) = pointer else {
3747        return Ok(std::collections::BTreeMap::new());
3748    };
3749    let Some(root) = pointer.content_root.as_deref() else {
3750        return Ok(std::collections::BTreeMap::new());
3751    };
3752    let mut files = std::collections::BTreeMap::new();
3753    let mut after = String::new();
3754    loop {
3755        let encoded_after: String =
3756            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
3757        let path = format!(
3758            "/api/hub/brains/{brain}/v2/files?commit={}&limit=500&after={encoded_after}",
3759            pointer.commit_hash
3760        );
3761        let value = ensure_ok(
3762            request_capped(
3763                cfg,
3764                "GET",
3765                &path,
3766                None,
3767                Auth::Required,
3768                MAX_FEED_RESPONSE_BYTES,
3769            )?,
3770            "v2 file manifest",
3771        )?;
3772        let page: V2ManifestPage = serde_json::from_value(value)
3773            .map_err(|_| invalid_feed("v2 file manifest has an invalid shape"))?;
3774        if page.v != 2
3775            || page.commit != pointer.commit_hash
3776            || page.content_root.as_deref() != Some(root)
3777            || page.files.len() > 500
3778        {
3779            return Err(invalid_feed(
3780                "v2 file manifest is not bound to the verified head",
3781            ));
3782        }
3783        for file in page.files {
3784            verify_v2_file_proof(root, &file)?;
3785            if files
3786                .insert(
3787                    file.path.clone(),
3788                    V2BaselineFile {
3789                        sha256: file.sha256,
3790                        bytes: file.bytes,
3791                        proof: Some(file.proof),
3792                    },
3793                )
3794                .is_some()
3795            {
3796                return Err(invalid_feed("v2 file manifest repeats a path"));
3797            }
3798            if files.len() > MAX_PUSH_FILES {
3799                return Err(invalid_feed(
3800                    "v2 file manifest exceeds the file-count bound",
3801                ));
3802            }
3803        }
3804        match page.next_cursor {
3805            None => break,
3806            Some(next) if next > after => after = next,
3807            Some(_) => return Err(invalid_feed("v2 file manifest cursor did not advance")),
3808        }
3809    }
3810    Ok(files)
3811}
3812
3813/// Fetch one exact path proof instead of enumerating the whole permission
3814/// view. This keeps `resolve @brain/path` proportional to path depth even for
3815/// a very large brain. The returned leaf is accepted only after its complete
3816/// proof chain reaches the content root signed by `pointer`.
3817fn v2_manifest_file(
3818    cfg: &HubConfig,
3819    brain: &str,
3820    pointer: &V2PointerBody,
3821    path: &str,
3822) -> LinkResult<Option<V2BaselineFile>> {
3823    let Some(root) = pointer.content_root.as_deref() else {
3824        return Ok(None);
3825    };
3826    crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
3827        path: error.to_string(),
3828    })?;
3829    let encoded: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
3830    let response = request_capped(
3831        cfg,
3832        "GET",
3833        &format!(
3834            "/api/hub/brains/{brain}/v2/files?commit={}&path={encoded}",
3835            pointer.commit_hash
3836        ),
3837        None,
3838        Auth::Required,
3839        MAX_FEED_RESPONSE_BYTES,
3840    )?;
3841    // Exact v2 file reads deliberately collapse absent and unreadable paths
3842    // into the same 404. Preserve that non-oracle as a normal `None`; callers
3843    // such as `resolve` then produce the stable, local `NOT_FOUND` contract.
3844    if response.status == 404 {
3845        return Ok(None);
3846    }
3847    let value = ensure_ok(response, "v2 exact file proof")?;
3848    let mut page: V2ManifestPage = serde_json::from_value(value)
3849        .map_err(|_| invalid_feed("v2 exact file proof has an invalid shape"))?;
3850    if page.v != 2
3851        || page.commit != pointer.commit_hash
3852        || page.content_root.as_deref() != Some(root)
3853        || page.next_cursor.is_some()
3854        || page.files.len() != 1
3855        || page.files[0].path != path
3856    {
3857        return Err(invalid_feed(
3858            "v2 exact file proof is not bound to the requested signed path",
3859        ));
3860    }
3861    let file = page.files.pop().expect("exactly one file was checked");
3862    verify_v2_file_proof(root, &file)?;
3863    Ok(Some(V2BaselineFile {
3864        sha256: file.sha256,
3865        bytes: file.bytes,
3866        proof: Some(file.proof),
3867    }))
3868}
3869
3870/// Resolve an id through the hub's synchronously maintained, content-light
3871/// validation certificate, then require the same exact signed path proof as a
3872/// path lookup. The certificate is only an efficient locator: the downloaded
3873/// bytes are parsed again and their id is checked by the caller.
3874fn v2_manifest_file_by_id(
3875    cfg: &HubConfig,
3876    brain: &str,
3877    pointer: &V2PointerBody,
3878    id: &str,
3879) -> LinkResult<(String, V2BaselineFile)> {
3880    let root = pointer
3881        .content_root
3882        .as_deref()
3883        .ok_or_else(|| LinkError::Http {
3884            what: "resolve",
3885            status: 404,
3886            message: "record not found".to_string(),
3887            code: Some("NOT_FOUND".to_string()),
3888            details: None,
3889        })?;
3890    if !crate::ulid::is_ulid(id) {
3891        return Err(LinkError::BadAddress {
3892            given: id.to_string(),
3893            reason: BAD_TARGET_REASON.to_string(),
3894        });
3895    }
3896    let encoded: String = url::form_urlencoded::byte_serialize(id.as_bytes()).collect();
3897    let value = ensure_ok(
3898        request_capped(
3899            cfg,
3900            "GET",
3901            &format!(
3902                "/api/hub/brains/{brain}/v2/files?commit={}&id={encoded}",
3903                pointer.commit_hash
3904            ),
3905            None,
3906            Auth::Required,
3907            MAX_FEED_RESPONSE_BYTES,
3908        )?,
3909        "v2 exact id proof",
3910    )?;
3911    let mut page: V2ManifestPage = serde_json::from_value(value)
3912        .map_err(|_| invalid_feed("v2 exact id proof has an invalid shape"))?;
3913    if page.v != 2
3914        || page.commit != pointer.commit_hash
3915        || page.content_root.as_deref() != Some(root)
3916        || page.next_cursor.is_some()
3917        || page.files.len() != 1
3918    {
3919        return Err(invalid_feed(
3920            "v2 exact id proof is not bound to one signed path",
3921        ));
3922    }
3923    let file = page.files.pop().expect("exactly one file was checked");
3924    if !safe_store_rel_path(&file.path)
3925        || !file.path.ends_with(".md")
3926        || !(file.path.starts_with("records/") || file.path.starts_with("sources/"))
3927    {
3928        return Err(invalid_feed("v2 exact id proof has an invalid record path"));
3929    }
3930    verify_v2_file_proof(root, &file)?;
3931    Ok((
3932        file.path,
3933        V2BaselineFile {
3934            sha256: file.sha256,
3935            bytes: file.bytes,
3936            proof: Some(file.proof),
3937        },
3938    ))
3939}
3940
3941fn verify_v2_asset_proof(root: &str, item: &V2AssetManifestItem) -> LinkResult<()> {
3942    crate::linkmd_v2::normalize_path(&item.path)
3943        .map_err(|error| invalid_feed(error.to_string()))?;
3944    if !is_sha256(&item.blob_sha256)
3945        || !is_sha256(&item.leaf_hash)
3946        || item.bytes > MAX_ASSET_BYTES
3947        || item.wrappers.is_empty()
3948        || !matches!(item.disposition.as_str(), "hosted" | "withheld")
3949        || item
3950            .wrappers
3951            .iter()
3952            .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
3953    {
3954        return Err(invalid_feed("v2 asset manifest item is invalid"));
3955    }
3956    let leaf = json!({
3957        "blob_sha256": item.blob_sha256,
3958        "bytes": item.bytes,
3959        "disposition": item.disposition,
3960        "media_type": item.media_type,
3961        "path": item.path,
3962        "required": item.required,
3963        "v": 2,
3964        "wrappers": item.wrappers,
3965    });
3966    if crate::linkmd_v2::domain_hash("v2/asset-leaf", &leaf)
3967        .map_err(|error| invalid_feed(error.to_string()))?
3968        != item.leaf_hash
3969        || !crate::linkmd_v2::verify_proof_with_domain(
3970            root,
3971            &item.path,
3972            &item.proof,
3973            crate::linkmd_v2::ASSET_TREE_HASH_DOMAIN,
3974        )
3975        .map_err(|error| invalid_feed(error.to_string()))?
3976    {
3977        return Err(invalid_feed("v2 asset inclusion proof failed"));
3978    }
3979    match &item.proof {
3980        crate::linkmd_v2::HamtProof::Inclusion { entry, .. }
3981            if entry.name == item.path
3982                && entry.kind == crate::linkmd_v2::EntryKind::Blob
3983                && entry.child_hash == item.leaf_hash
3984                && entry.bytes == Some(item.bytes) =>
3985        {
3986            Ok(())
3987        }
3988        _ => Err(invalid_feed(
3989            "v2 asset proof leaf differs from its manifest",
3990        )),
3991    }
3992}
3993
3994fn v2_asset_manifest(
3995    cfg: &HubConfig,
3996    brain: &str,
3997    pointer: Option<&V2PointerBody>,
3998) -> LinkResult<std::collections::BTreeMap<String, V2BaselineAsset>> {
3999    let Some(pointer) = pointer else {
4000        return Ok(std::collections::BTreeMap::new());
4001    };
4002    let Some(root) = pointer.asset_root.as_deref() else {
4003        return Ok(std::collections::BTreeMap::new());
4004    };
4005    let mut assets = std::collections::BTreeMap::new();
4006    let mut after = String::new();
4007    loop {
4008        let encoded_after: String =
4009            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4010        let path = format!(
4011            "/api/hub/brains/{brain}/v2/assets?commit={}&limit=500&after={encoded_after}",
4012            pointer.commit_hash
4013        );
4014        let value = ensure_ok(
4015            request_capped(
4016                cfg,
4017                "GET",
4018                &path,
4019                None,
4020                Auth::Required,
4021                MAX_FEED_RESPONSE_BYTES,
4022            )?,
4023            "v2 asset manifest",
4024        )?;
4025        let page: V2AssetManifestPage = serde_json::from_value(value)
4026            .map_err(|_| invalid_feed("v2 asset manifest has an invalid shape"))?;
4027        if page.v != 2
4028            || page.commit != pointer.commit_hash
4029            || page.asset_root.as_deref() != Some(root)
4030            || page.assets.len() > 500
4031        {
4032            return Err(invalid_feed(
4033                "v2 asset manifest is not bound to the verified head",
4034            ));
4035        }
4036        for item in page.assets {
4037            verify_v2_asset_proof(root, &item)?;
4038            let path = item.path.clone();
4039            if assets
4040                .insert(
4041                    path,
4042                    V2BaselineAsset {
4043                        blob_sha256: item.blob_sha256,
4044                        bytes: item.bytes,
4045                        media_type: item.media_type,
4046                        wrappers: item.wrappers,
4047                        required: item.required,
4048                        disposition: item.disposition,
4049                        leaf_hash: item.leaf_hash,
4050                    },
4051                )
4052                .is_some()
4053            {
4054                return Err(invalid_feed("v2 asset manifest repeats a path"));
4055            }
4056            if assets.len() > MAX_PUSH_FILES {
4057                return Err(invalid_feed(
4058                    "v2 asset manifest exceeds the item-count bound",
4059                ));
4060            }
4061        }
4062        match page.next_cursor {
4063            None => break,
4064            Some(next) if next > after => after = next,
4065            Some(_) => return Err(invalid_feed("v2 asset manifest cursor did not advance")),
4066        }
4067    }
4068    Ok(assets)
4069}
4070
4071fn v2_asset_record(asset: &V2BaselineAsset, path: &str) -> crate::AssetRecord {
4072    crate::AssetRecord {
4073        path: path.to_string(),
4074        sha256: asset.blob_sha256.clone(),
4075        bytes: asset.bytes,
4076        media_type: asset.media_type.clone(),
4077        wrappers: asset.wrappers.clone(),
4078        required: asset.required,
4079    }
4080}
4081
4082fn v2_asset_resumes_hosting(
4083    remote: Option<&V2BaselineAsset>,
4084    path: &str,
4085    record: &crate::AssetRecord,
4086    disposition: &str,
4087) -> bool {
4088    remote.is_some_and(|asset| {
4089        asset.disposition == "withheld"
4090            && disposition == "hosted"
4091            && v2_asset_record(asset, path) == *record
4092    })
4093}
4094
4095fn v2_asset_record_manifest_bytes(
4096    assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
4097) -> LinkResult<Vec<u8>> {
4098    let mut bytes = Vec::new();
4099    for (path, asset) in assets {
4100        if asset.path != *path {
4101            return Err(invalid_feed(
4102                "local asset manifest key differs from its record path",
4103            ));
4104        }
4105        serde_json::to_writer(&mut bytes, asset)
4106            .map_err(|_| invalid_feed("could not materialize merged v2 assets.jsonl"))?;
4107        bytes.push(b'\n');
4108    }
4109    Ok(bytes)
4110}
4111
4112fn v2_local_asset_records(
4113    store: &Store,
4114) -> LinkResult<std::collections::BTreeMap<String, crate::AssetRecord>> {
4115    let assets = crate::assets::read_manifest(store)
4116        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?;
4117    if assets.iter().any(|asset| asset.bytes > MAX_ASSET_BYTES) {
4118        return Err(LinkError::InvalidPack {
4119            message: "local asset exceeds the link.md v2 per-blob limit".to_string(),
4120        });
4121    }
4122    Ok(assets
4123        .into_iter()
4124        .map(|asset| (asset.path.clone(), asset))
4125        .collect())
4126}
4127
4128fn v2_asset_records_match_remote(
4129    local: &std::collections::BTreeMap<String, crate::AssetRecord>,
4130    remote: &std::collections::BTreeMap<String, V2BaselineAsset>,
4131) -> bool {
4132    local.len() == remote.len()
4133        && remote
4134            .iter()
4135            .all(|(path, asset)| local.get(path) == Some(&v2_asset_record(asset, path)))
4136}
4137
4138#[derive(Debug, Clone, PartialEq, Eq)]
4139struct V2PulledMerge<T> {
4140    records: std::collections::BTreeMap<String, T>,
4141    accept_remote: std::collections::BTreeSet<String>,
4142    conflicts: Vec<String>,
4143}
4144
4145/// Compute the local checkout that a pull may install without discarding a
4146/// local-only edit. Remote-only changes are accepted, identical concurrent
4147/// changes converge, and divergent concurrent changes are reported. The
4148/// caller can exempt kept-home coordinates from both remote installation and
4149/// conflict disclosure.
4150fn merge_v2_pulled_records<Base, Remote, Record, BaseRecord, RemoteRecord, KeepLocal>(
4151    base: &std::collections::BTreeMap<String, Base>,
4152    remote: &std::collections::BTreeMap<String, Remote>,
4153    local: &std::collections::BTreeMap<String, Record>,
4154    base_record: BaseRecord,
4155    remote_record: RemoteRecord,
4156    keep_local: KeepLocal,
4157) -> V2PulledMerge<Record>
4158where
4159    Record: Clone + Eq,
4160    BaseRecord: Fn(&Base, &str) -> Record,
4161    RemoteRecord: Fn(&Remote, &str) -> Record,
4162    KeepLocal: Fn(&str) -> bool,
4163{
4164    let paths = base
4165        .keys()
4166        .chain(remote.keys())
4167        .chain(local.keys())
4168        .cloned()
4169        .collect::<std::collections::BTreeSet<_>>();
4170    let mut records = local.clone();
4171    let mut accept_remote = std::collections::BTreeSet::new();
4172    let mut conflicts = Vec::new();
4173    for path in paths {
4174        if keep_local(&path) {
4175            continue;
4176        }
4177        let base_value = base.get(&path).map(|value| base_record(value, &path));
4178        let remote_value = remote.get(&path).map(|value| remote_record(value, &path));
4179        let local_value = local.get(&path).cloned();
4180        if local_value != base_value && remote_value != base_value && local_value != remote_value {
4181            conflicts.push(path);
4182            continue;
4183        }
4184        if local_value == base_value || local_value == remote_value {
4185            accept_remote.insert(path.clone());
4186            match remote_value {
4187                Some(value) => {
4188                    records.insert(path, value);
4189                }
4190                None => {
4191                    records.remove(&path);
4192                }
4193            }
4194        }
4195    }
4196    V2PulledMerge {
4197        records,
4198        accept_remote,
4199        conflicts,
4200    }
4201}
4202
4203fn sign_verified_v2_candidate(
4204    cfg: &HubConfig,
4205    head: &V2VerifiedHead,
4206    expected: &std::collections::BTreeMap<String, V2BaselineFile>,
4207    expected_assets: &std::collections::BTreeMap<String, V2BaselineAsset>,
4208    mutation_id: &str,
4209    request_body: &Value,
4210    challenge_value: &Value,
4211) -> LinkResult<(String, String, String)> {
4212    if head.view_kind != "full" {
4213        return Err(invalid_feed(
4214            "a scoped self-custody writer must use the proposal workflow",
4215        ));
4216    }
4217    if head.identity.custody != "self" {
4218        return Err(invalid_feed(
4219            "a hub-custodied brain unexpectedly requested an external signature",
4220        ));
4221    }
4222    let key = cfg
4223        .brain_key
4224        .as_ref()
4225        .ok_or_else(|| bad_agent_key("this self-custodied brain requires DBMD_BRAIN_KEY_FILE"))?;
4226    if key.multikey != format!("ed25519:{}", head.identity.fingerprint)
4227        || key.public_key_spki != head.identity.public_key_spki
4228    {
4229        return Err(bad_agent_key(
4230            "DBMD_BRAIN_KEY_FILE does not match the verified brain identity",
4231        ));
4232    }
4233    let challenge_id = challenge_value
4234        .get("id")
4235        .and_then(Value::as_str)
4236        .filter(|id| crate::ulid::is_ulid(id))
4237        .ok_or_else(|| invalid_feed("self-custody challenge has no canonical id"))?;
4238    let expected_endpoint = format!(
4239        "/api/hub/brains/{}/v2/signing-challenges/{challenge_id}",
4240        head.brain_id
4241    );
4242    if challenge_value
4243        .get("candidate_endpoint")
4244        .and_then(Value::as_str)
4245        != Some(expected_endpoint.as_str())
4246    {
4247        return Err(invalid_feed(
4248            "self-custody challenge candidate endpoint is not origin-bound",
4249        ));
4250    }
4251
4252    let mut files = std::collections::BTreeMap::new();
4253    let mut after = String::new();
4254    type CandidateCoordinate = (
4255        String,
4256        String,
4257        String,
4258        String,
4259        Option<String>,
4260        Option<String>,
4261        u64,
4262        Option<String>,
4263    );
4264    let mut pinned: Option<CandidateCoordinate> = None;
4265    loop {
4266        let encoded_after: String =
4267            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4268        let path = format!("{expected_endpoint}?limit=500&after={encoded_after}");
4269        let value = ensure_ok(
4270            request_capped(
4271                cfg,
4272                "GET",
4273                &path,
4274                None,
4275                Auth::Required,
4276                MAX_FEED_RESPONSE_BYTES,
4277            )?,
4278            "v2 self-custody candidate",
4279        )?;
4280        let page: V2SigningCandidatePage = serde_json::from_value(value)
4281            .map_err(|_| invalid_feed("self-custody candidate has an invalid shape"))?;
4282        if page.v != 2
4283            || page.challenge_id != challenge_id
4284            || page.mutation_id != mutation_id
4285            || page.candidate.seq != page.parent.seq + 1
4286            || page.files.len() > 500
4287            || page.expires_at.is_empty()
4288        {
4289            return Err(invalid_feed(
4290                "self-custody candidate is not bound to this mutation",
4291            ));
4292        }
4293        let coordinate = (
4294            page.request_hash.clone(),
4295            page.candidate.signing_bytes_base64.clone(),
4296            page.candidate.changes_base64.clone(),
4297            page.candidate.actor_claim_base64.clone(),
4298            page.candidate.content_root.clone(),
4299            page.candidate.asset_root.clone(),
4300            page.parent.seq,
4301            page.parent.commit_hash.clone(),
4302        );
4303        if pinned.as_ref().is_some_and(|prior| prior != &coordinate) {
4304            return Err(invalid_feed(
4305                "self-custody candidate changed between manifest pages",
4306            ));
4307        }
4308        pinned = Some(coordinate);
4309        let root = page
4310            .candidate
4311            .content_root
4312            .as_deref()
4313            .ok_or_else(|| invalid_feed("self-custody candidate has no content root"))?;
4314        for file in page.files {
4315            verify_v2_file_proof(root, &file)?;
4316            if files
4317                .insert(
4318                    file.path.clone(),
4319                    V2BaselineFile {
4320                        sha256: file.sha256,
4321                        bytes: file.bytes,
4322                        proof: Some(file.proof),
4323                    },
4324                )
4325                .is_some()
4326            {
4327                return Err(invalid_feed(
4328                    "self-custody candidate repeats a manifest path",
4329                ));
4330            }
4331            if files.len() > MAX_PUSH_FILES {
4332                return Err(invalid_feed(
4333                    "self-custody candidate exceeds the file-count bound",
4334                ));
4335            }
4336        }
4337        match page.next_cursor {
4338            None => break,
4339            Some(next) if next > after => after = next,
4340            Some(_) => {
4341                return Err(invalid_feed(
4342                    "self-custody candidate cursor did not advance",
4343                ))
4344            }
4345        }
4346    }
4347    if files.len() != expected.len()
4348        || files.iter().any(|(path, file)| {
4349            expected.get(path).is_none_or(|expected| {
4350                expected.sha256 != file.sha256 || expected.bytes != file.bytes
4351            })
4352        })
4353    {
4354        return Err(invalid_feed(
4355            "self-custody candidate contains an unexpected file mutation",
4356        ));
4357    }
4358    let mut assets = std::collections::BTreeMap::new();
4359    after.clear();
4360    loop {
4361        let encoded_after: String =
4362            url::form_urlencoded::byte_serialize(after.as_bytes()).collect();
4363        let path = format!("{expected_endpoint}?kind=assets&limit=500&after={encoded_after}");
4364        let value = ensure_ok(
4365            request_capped(
4366                cfg,
4367                "GET",
4368                &path,
4369                None,
4370                Auth::Required,
4371                MAX_FEED_RESPONSE_BYTES,
4372            )?,
4373            "v2 self-custody asset candidate",
4374        )?;
4375        let page: V2SigningCandidatePage = serde_json::from_value(value)
4376            .map_err(|_| invalid_feed("self-custody asset candidate has an invalid shape"))?;
4377        let coordinate = (
4378            page.request_hash.clone(),
4379            page.candidate.signing_bytes_base64.clone(),
4380            page.candidate.changes_base64.clone(),
4381            page.candidate.actor_claim_base64.clone(),
4382            page.candidate.content_root.clone(),
4383            page.candidate.asset_root.clone(),
4384            page.parent.seq,
4385            page.parent.commit_hash.clone(),
4386        );
4387        if page.v != 2
4388            || page.challenge_id != challenge_id
4389            || page.mutation_id != mutation_id
4390            || page.assets.len() > 500
4391            || pinned.as_ref() != Some(&coordinate)
4392        {
4393            return Err(invalid_feed(
4394                "self-custody asset candidate changed or is not bound",
4395            ));
4396        }
4397        let root = page.candidate.asset_root.as_deref();
4398        if !page.assets.is_empty() && root.is_none() {
4399            return Err(invalid_feed("asset candidate has no asset root"));
4400        }
4401        for item in page.assets {
4402            verify_v2_asset_proof(root.expect("non-empty assets checked"), &item)?;
4403            if assets
4404                .insert(
4405                    item.path.clone(),
4406                    V2BaselineAsset {
4407                        blob_sha256: item.blob_sha256,
4408                        bytes: item.bytes,
4409                        media_type: item.media_type,
4410                        wrappers: item.wrappers,
4411                        required: item.required,
4412                        disposition: item.disposition,
4413                        leaf_hash: item.leaf_hash,
4414                    },
4415                )
4416                .is_some()
4417            {
4418                return Err(invalid_feed("self-custody candidate repeats an asset"));
4419            }
4420        }
4421        match page.next_cursor {
4422            None => break,
4423            Some(next) if next > after => after = next,
4424            Some(_) => {
4425                return Err(invalid_feed(
4426                    "self-custody asset candidate cursor did not advance",
4427                ))
4428            }
4429        }
4430    }
4431    if assets.len() != expected_assets.len()
4432        || assets.iter().any(|(path, asset)| {
4433            expected_assets.get(path).is_none_or(|expected| {
4434                asset.blob_sha256 != expected.blob_sha256
4435                    || asset.bytes != expected.bytes
4436                    || asset.media_type != expected.media_type
4437                    || asset.wrappers != expected.wrappers
4438                    || asset.required != expected.required
4439                    || asset.disposition != expected.disposition
4440            })
4441        })
4442    {
4443        return Err(invalid_feed(
4444            "self-custody candidate contains an unexpected asset mutation",
4445        ));
4446    }
4447    let Some((
4448        request_hash,
4449        signing_b64,
4450        changes_b64,
4451        actor_b64,
4452        root,
4453        asset_root,
4454        parent_seq,
4455        parent,
4456    )) = pinned
4457    else {
4458        return Err(invalid_feed("self-custody candidate has no manifest"));
4459    };
4460    let current_seq = head.pointer.as_ref().map_or(0, |pointer| pointer.seq);
4461    let current_commit = head
4462        .pointer
4463        .as_ref()
4464        .map(|pointer| pointer.commit_hash.clone());
4465    if parent_seq != current_seq || parent != current_commit {
4466        return Err(LinkError::RemoteAdvancedDuringSync);
4467    }
4468    let changes = STANDARD
4469        .decode(changes_b64)
4470        .map_err(|_| invalid_feed("self-custody changeset is not base64"))?;
4471    let mut expected_changes = json!({
4472        "mutation_id": mutation_id,
4473        "operations": request_body.get("operations").cloned().unwrap_or(Value::Null),
4474        "reason": request_body.get("reason").cloned().unwrap_or(Value::Null),
4475        "v": 2,
4476    });
4477    if let Some(withheld_links) = request_body.get("withheld_links") {
4478        expected_changes["withheld_links"] = withheld_links.clone();
4479    }
4480    if let Some(checkout_id) = request_body.get("checkout_id") {
4481        expected_changes["checkout_id"] = checkout_id.clone();
4482    }
4483    let expected_changes_bytes = crate::linkmd_v2::canonical_bytes(&expected_changes)
4484        .map_err(|error| invalid_feed(error.to_string()))?;
4485    if changes != expected_changes_bytes {
4486        return Err(invalid_feed(
4487            "self-custody changeset differs from the requested mutation",
4488        ));
4489    }
4490    let changes_hash = crate::linkmd_v2::domain_hash_bytes("v2/changeset", &changes)
4491        .map_err(|error| invalid_feed(error.to_string()))?;
4492    let request_value = json!({
4493        "base": request_body.get("base").cloned().unwrap_or(Value::Null),
4494        "brain": head.brain_id,
4495        "changes_sha256": changes_hash,
4496        "rebase": request_body.get("rebase").cloned().unwrap_or(Value::Null),
4497        "v": 2,
4498        "v1_bridge": Value::Null,
4499    });
4500    let expected_request_hash = crate::linkmd_v2::domain_hash("v2/request", &request_value)
4501        .map_err(|error| invalid_feed(error.to_string()))?;
4502    if request_hash != expected_request_hash {
4503        return Err(invalid_feed(
4504            "self-custody request hash differs from the requested mutation",
4505        ));
4506    }
4507    let actor = STANDARD
4508        .decode(actor_b64)
4509        .map_err(|_| invalid_feed("self-custody actor claim is not base64"))?;
4510    let actor_value: Value = serde_json::from_slice(&actor)
4511        .map_err(|_| invalid_feed("self-custody actor claim is not JSON"))?;
4512    if crate::linkmd_v2::canonical_bytes(&actor_value)
4513        .map_err(|error| invalid_feed(error.to_string()))?
4514        != actor
4515    {
4516        return Err(invalid_feed("self-custody actor claim is not canonical"));
4517    }
4518    let actor_object = actor_value
4519        .as_object()
4520        .ok_or_else(|| invalid_feed("self-custody actor claim is not an object"))?;
4521    let actor_claim = actor_object
4522        .get("claim")
4523        .ok_or_else(|| invalid_feed("self-custody actor claim body is missing"))?;
4524    let actor_public_key = actor_object
4525        .get("public_key")
4526        .and_then(Value::as_str)
4527        .ok_or_else(|| invalid_feed("self-custody actor signer is missing"))?;
4528    let actor_fingerprint = actor_object
4529        .get("fingerprint")
4530        .and_then(Value::as_str)
4531        .ok_or_else(|| invalid_feed("self-custody actor fingerprint is missing"))?;
4532    let actor_signature = actor_object
4533        .get("sig")
4534        .and_then(Value::as_str)
4535        .ok_or_else(|| invalid_feed("self-custody actor signature is missing"))?;
4536    let actor_message = crate::linkmd_v2::canonical_bytes(actor_claim)
4537        .map_err(|error| invalid_feed(error.to_string()))?;
4538    let actor_der = verify_v2_spki_signature(actor_public_key, &actor_message, actor_signature)?;
4539    let expected_actor_signer = format!("{actor_fingerprint}:{actor_public_key}");
4540    let expected_actor_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4541    let expected_actor_asset_root = asset_root.clone().map(Value::String).unwrap_or(Value::Null);
4542    let impact = actor_claim
4543        .get("result")
4544        .and_then(|result| result.get("impact"))
4545        .and_then(Value::as_object);
4546    let impact_fields = [
4547        "creates",
4548        "updates",
4549        "deletes",
4550        "withdrawals",
4551        "renames",
4552        "restores",
4553        "asset_changes",
4554        "public_expansions",
4555        "executable_activations",
4556    ];
4557    let impact_is_valid = impact.is_some_and(|impact| {
4558        impact.len() == impact_fields.len() + 1
4559            && impact.get("v").and_then(Value::as_u64) == Some(1)
4560            && impact_fields
4561                .iter()
4562                .all(|field| impact.get(*field).and_then(Value::as_u64).is_some())
4563    });
4564    if format!("{:x}", Sha256::digest(&actor_der)) != actor_fingerprint
4565        || head
4566            .trust
4567            .hub_signer
4568            .as_ref()
4569            .is_some_and(|known| known != &expected_actor_signer)
4570        || actor_claim.get("mutation_id").and_then(Value::as_str) != Some(mutation_id)
4571        || actor_claim.get("request_hash").and_then(Value::as_str) != Some(request_hash.as_str())
4572        || actor_claim
4573            .get("candidate")
4574            .and_then(|candidate| candidate.get("changes_sha256"))
4575            .and_then(Value::as_str)
4576            != Some(changes_hash.as_str())
4577        || actor_claim
4578            .get("candidate")
4579            .and_then(|candidate| candidate.get("state_root"))
4580            != Some(&expected_actor_root)
4581        || actor_claim
4582            .get("candidate")
4583            .and_then(|candidate| candidate.get("asset_root"))
4584            != Some(&expected_actor_asset_root)
4585        || actor_claim
4586            .get("candidate")
4587            .and_then(|candidate| candidate.get("control_revision"))
4588            .and_then(Value::as_str)
4589            != Some(head.control_revision.as_str())
4590        || !impact_is_valid
4591    {
4592        return Err(invalid_feed(
4593            "self-custody actor claim does not bind the verified authority",
4594        ));
4595    }
4596    let actor_hash = crate::linkmd_v2::domain_hash_bytes("v2/actor-claim", &actor)
4597        .map_err(|error| invalid_feed(error.to_string()))?;
4598    let signing = STANDARD
4599        .decode(signing_b64)
4600        .map_err(|_| invalid_feed("self-custody signing bytes are not base64"))?;
4601    let signing_value: Value = serde_json::from_slice(&signing)
4602        .map_err(|_| invalid_feed("self-custody signing bytes are not JSON"))?;
4603    if crate::linkmd_v2::canonical_bytes(&signing_value)
4604        .map_err(|error| invalid_feed(error.to_string()))?
4605        != signing
4606    {
4607        return Err(invalid_feed("self-custody signing bytes are not canonical"));
4608    }
4609    let pointer = head.pointer.as_ref();
4610    let expected_materializer = pointer
4611        .map(|value| value.materializer.as_str())
4612        .unwrap_or("dbmd-projection-v1");
4613    let expected_parent_commit = request_body
4614        .get("base")
4615        .and_then(|base| base.get("commit_hash"))
4616        .cloned()
4617        .unwrap_or(Value::Null);
4618    let expected_parent_root = request_body
4619        .get("base")
4620        .and_then(|base| base.get("content_root"))
4621        .cloned()
4622        .unwrap_or(Value::Null);
4623    let expected_state_root = root.clone().map(Value::String).unwrap_or(Value::Null);
4624    let expected_parent_asset_root = request_body
4625        .get("base")
4626        .and_then(|base| base.get("asset_root"))
4627        .cloned()
4628        .unwrap_or(Value::Null);
4629    let expected_asset_root = asset_root.map(Value::String).unwrap_or(Value::Null);
4630    let expected_prev_entry = pointer
4631        .map(|value| Value::String(value.feed_hash.clone()))
4632        .unwrap_or(Value::Null);
4633    let expected_signer_epoch = u64::try_from(head.identity.previous.len())
4634        .map_err(|_| invalid_feed("brain identity history is too large"))?
4635        + 1;
4636    if signing_value.get("v").and_then(Value::as_u64) != Some(2)
4637        || signing_value.get("seq").and_then(Value::as_u64) != Some(current_seq + 1)
4638        || signing_value.get("signer_epoch").and_then(Value::as_u64) != Some(expected_signer_epoch)
4639        || signing_value.get("brain").and_then(Value::as_str) != Some(key.multikey.as_str())
4640        || signing_value.get("public_key").and_then(Value::as_str)
4641            != Some(key.public_key_spki.as_str())
4642        || signing_value.get("parent_commit") != Some(&expected_parent_commit)
4643        || signing_value.get("parent_root") != Some(&expected_parent_root)
4644        || signing_value.get("state_root") != Some(&expected_state_root)
4645        || signing_value.get("parent_asset_root") != Some(&expected_parent_asset_root)
4646        || signing_value.get("asset_root") != Some(&expected_asset_root)
4647        || signing_value.get("materializer").and_then(Value::as_str) != Some(expected_materializer)
4648        || signing_value.get("changes_sha256").and_then(Value::as_str)
4649            != Some(changes_hash.as_str())
4650        || signing_value.get("actor_ref").and_then(Value::as_str) != Some(actor_hash.as_str())
4651        || signing_value
4652            .get("control_revision")
4653            .and_then(Value::as_str)
4654            != Some(head.control_revision.as_str())
4655        || signing_value.get("prev_entry_hash") != Some(&expected_prev_entry)
4656        || signing_value.get("v1_bridge") != Some(&Value::Null)
4657        || signing_value.get("op").and_then(Value::as_str) != Some("changeset")
4658    {
4659        return Err(invalid_feed(
4660            "self-custody signing bytes do not bind the verified candidate",
4661        ));
4662    }
4663    let pair = agent_keypair(&key.pkcs8)?;
4664    let signature = URL_SAFE_NO_PAD.encode(pair.sign(&signing).as_ref());
4665    Ok((challenge_id.to_string(), signature, expected_actor_signer))
4666}
4667
4668fn v2_baseline_name(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<String> {
4669    let origin = normalized_origin(&cfg.hub)?;
4670    let absolute = if checkout.is_absolute() {
4671        checkout.to_path_buf()
4672    } else {
4673        std::env::current_dir()?.join(checkout)
4674    };
4675    Ok(format!(
4676        "sync-{}.json",
4677        content_sha256(format!("{origin}\0{brain}\0{}", absolute.display()).as_bytes())
4678    ))
4679}
4680
4681fn v2_checkout_id(existing: Option<&str>) -> LinkResult<String> {
4682    if let Some(value) = existing {
4683        if !is_sha256(value) {
4684            return Err(invalid_feed("v2 checkout pseudonym is invalid"));
4685        }
4686        return Ok(value.to_string());
4687    }
4688    use ring::rand::SecureRandom as _;
4689    let mut random = [0_u8; 32];
4690    ring::rand::SystemRandom::new()
4691        .fill(&mut random)
4692        .map_err(|_| invalid_feed("could not generate a checkout pseudonym"))?;
4693    Ok(random.iter().map(|byte| format!("{byte:02x}")).collect())
4694}
4695
4696#[cfg(any(unix, windows))]
4697fn lock_v2_sync_operation(cfg: &HubConfig, brain: &str) -> LinkResult<TrustLock> {
4698    let directory = open_trust_dir(cfg)?;
4699    let origin = normalized_origin(&cfg.hub)?;
4700    let name = format!(
4701        "operation-{}.lock",
4702        content_sha256(format!("{origin}\0{brain}").as_bytes())
4703    );
4704    lock_trust_name(&directory, &name)
4705}
4706
4707#[cfg(not(any(unix, windows)))]
4708fn lock_v2_sync_operation(_cfg: &HubConfig, _brain: &str) -> LinkResult<()> {
4709    Err(LinkError::UnsupportedPlatform {
4710        operation: "serialized link.md v2 sync",
4711    })
4712}
4713
4714fn same_v2_head(left: &V2VerifiedHead, right: &V2VerifiedHead) -> bool {
4715    left.brain_id == right.brain_id
4716        && left.view_kind == right.view_kind
4717        && left.view_revision == right.view_revision
4718        && left.control_revision == right.control_revision
4719        && match (&left.pointer, &right.pointer) {
4720            (None, None) => true,
4721            (Some(left), Some(right)) => {
4722                left.seq == right.seq
4723                    && left.commit_hash == right.commit_hash
4724                    && left.content_root == right.content_root
4725                    && left.asset_root == right.asset_root
4726                    && left.feed_hash == right.feed_hash
4727            }
4728            _ => false,
4729        }
4730}
4731
4732fn scoped_projection_bytes(brain: &str) -> Vec<u8> {
4733    format!(
4734        "---\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"
4735    )
4736    .into_bytes()
4737}
4738
4739fn scoped_projection_sha256(brain: &str) -> String {
4740    content_sha256(&scoped_projection_bytes(brain))
4741}
4742
4743#[derive(Deserialize)]
4744struct LocalScopedViewMarker {
4745    v: u8,
4746    kind: String,
4747    authoritative: bool,
4748    brain: String,
4749    projection_sha256: String,
4750}
4751
4752/// True only when this store carries the exact generated marker for a local
4753/// link.md scoped view. This is presentation context, never authorization;
4754/// the hub remains the only authority for reads and writes.
4755pub fn has_verified_local_scoped_view(store: &Store) -> bool {
4756    let marker = store
4757        .read_bounded(Path::new(".dbmd/view.json"), 64 * 1024)
4758        .ok()
4759        .and_then(|bytes| serde_json::from_slice::<LocalScopedViewMarker>(&bytes).ok());
4760    let Some(marker) = marker else {
4761        return false;
4762    };
4763    if marker.v != 1
4764        || marker.kind != "link.md-scoped-view"
4765        || marker.authoritative
4766        || !crate::ulid::is_ulid(&marker.brain)
4767        || marker.projection_sha256 != scoped_projection_sha256(&marker.brain)
4768    {
4769        return false;
4770    }
4771    store
4772        .read_bounded(Path::new("DB.md"), crate::parser::MAX_DBMD_FILE_BYTES)
4773        .is_ok_and(|bytes| content_sha256(&bytes) == marker.projection_sha256)
4774}
4775
4776fn scoped_view_metadata(head: &V2VerifiedHead, files: usize) -> LinkResult<Vec<u8>> {
4777    let mut bytes = serde_json::to_vec_pretty(&json!({
4778        "v": 1,
4779        "kind": "link.md-scoped-view",
4780        "authoritative": false,
4781        "brain": head.brain_id,
4782        "view_revision": head.view_revision,
4783        "head_seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
4784        "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
4785        "content_root": head.pointer.as_ref().and_then(|pointer| pointer.content_root.as_ref()),
4786        "visible_files": files,
4787        "projection_sha256": scoped_projection_sha256(&head.brain_id),
4788    }))
4789    .map_err(|_| invalid_feed("could not serialize scoped view metadata"))?;
4790    bytes.push(b'\n');
4791    Ok(bytes)
4792}
4793
4794fn refresh_scoped_view_marker(
4795    store: &Store,
4796    head: &V2VerifiedHead,
4797    files: usize,
4798) -> LinkResult<()> {
4799    if head.view_kind == "scoped" {
4800        store.write_atomic(
4801            Path::new(".dbmd/view.json"),
4802            &scoped_view_metadata(head, files)?,
4803        )?;
4804    }
4805    Ok(())
4806}
4807
4808fn ensure_v2_view_compatible(
4809    head: &V2VerifiedHead,
4810    baseline: Option<&V2SyncBaseline>,
4811) -> LinkResult<()> {
4812    let Some(baseline) = baseline else {
4813        return Ok(());
4814    };
4815    match (
4816        baseline.view_kind.as_deref(),
4817        baseline.view_revision.as_deref(),
4818    ) {
4819        (None, None) if head.view_kind == "full" => Ok(()),
4820        (Some(kind), Some(revision))
4821            if kind == head.view_kind && revision == head.view_revision =>
4822        {
4823            Ok(())
4824        }
4825        _ => Err(LinkError::ScopedViewChanged),
4826    }
4827}
4828
4829fn ensure_established_v2_checkout_opened(
4830    head: &V2VerifiedHead,
4831    baseline: Option<&V2SyncBaseline>,
4832    opened: bool,
4833) -> LinkResult<()> {
4834    if baseline.is_none() || opened {
4835        return Ok(());
4836    }
4837    if head.view_kind == "scoped" {
4838        return Err(LinkError::ScopedProjectionModified);
4839    }
4840    Err(LinkError::InvalidPack {
4841        message: "the established v2 checkout is no longer a valid db.md store; repair its DB.md before syncing".to_string(),
4842    })
4843}
4844
4845fn remove_scoped_projection(
4846    head: &V2VerifiedHead,
4847    baseline: Option<&V2SyncBaseline>,
4848    view: &mut V2LocalView,
4849) -> LinkResult<()> {
4850    if head.view_kind != "scoped" {
4851        return Ok(());
4852    }
4853    let expected = scoped_projection_sha256(&head.brain_id);
4854    if baseline
4855        .and_then(|state| state.projection_sha256.as_deref())
4856        .is_some_and(|pinned| pinned != expected)
4857    {
4858        return Err(LinkError::ScopedViewChanged);
4859    }
4860    if view.riding.get("DB.md").map(|(sha256, _)| sha256.as_str()) != Some(expected.as_str()) {
4861        return Err(LinkError::ScopedProjectionModified);
4862    }
4863    view.riding.remove("DB.md");
4864    view.eligibility.remove("DB.md");
4865    Ok(())
4866}
4867
4868fn local_view_for_v2_push(
4869    store: &Store,
4870    head: &V2VerifiedHead,
4871    baseline: Option<&V2SyncBaseline>,
4872    carried: Option<V2LocalView>,
4873) -> LinkResult<V2LocalView> {
4874    match carried {
4875        // The pull half already verified and removed the generated scoped
4876        // projection before constructing its in-memory handoff. Requiring it
4877        // a second time would turn every scoped bidirectional write into a
4878        // false tamper refusal.
4879        Some(view) => Ok(view),
4880        None => {
4881            let mut view = v2_local_files(store)?;
4882            remove_scoped_projection(head, baseline, &mut view)?;
4883            Ok(view)
4884        }
4885    }
4886}
4887
4888fn files_for_v2_view(
4889    head: &V2VerifiedHead,
4890    mut files: std::collections::BTreeMap<String, V2BaselineFile>,
4891) -> std::collections::BTreeMap<String, V2BaselineFile> {
4892    if head.view_kind == "scoped" {
4893        // Even when a grant explicitly includes canonical DB.md, a partial
4894        // checkout uses a generated marker. Canonical validation remains a
4895        // server-side operation over the complete brain.
4896        files.remove("DB.md");
4897    }
4898    files
4899}
4900
4901fn parse_v2_baseline(cfg: &HubConfig, brain: &str, bytes: &[u8]) -> LinkResult<V2SyncBaseline> {
4902    let baseline: V2SyncBaseline =
4903        serde_json::from_slice(bytes).map_err(|_| invalid_feed("v2 sync baseline is corrupt"))?;
4904    if baseline.v != 2
4905        || baseline.origin != normalized_origin(&cfg.hub)?
4906        || baseline.brain != brain
4907        || baseline
4908            .commit_hash
4909            .as_deref()
4910            .is_some_and(|hash| !is_sha256(hash))
4911        || baseline
4912            .content_root
4913            .as_deref()
4914            .is_some_and(|hash| !is_sha256(hash))
4915        || baseline
4916            .asset_root
4917            .as_deref()
4918            .is_some_and(|hash| !is_sha256(hash))
4919        || baseline
4920            .local_policy_digest
4921            .as_deref()
4922            .is_some_and(|hash| !is_sha256(hash))
4923        || baseline
4924            .view_kind
4925            .as_deref()
4926            .is_some_and(|kind| !matches!(kind, "full" | "scoped"))
4927        || baseline
4928            .view_revision
4929            .as_deref()
4930            .is_some_and(|hash| !is_sha256(hash))
4931        || baseline
4932            .projection_sha256
4933            .as_deref()
4934            .is_some_and(|hash| !is_sha256(hash))
4935        || (baseline.view_kind.as_deref() == Some("scoped")
4936            && (baseline.view_revision.is_none() || baseline.projection_sha256.is_none()))
4937        || baseline.files.len() > MAX_PUSH_FILES
4938        || baseline.assets.len() > MAX_PUSH_FILES
4939        || baseline.local_eligibility.len() > MAX_PUSH_FILES
4940        || baseline.remote_copy_remains.len() > MAX_PUSH_FILES
4941        || baseline.files.iter().any(|(path, file)| {
4942            crate::linkmd_v2::normalize_path(path).is_err()
4943                || !is_sha256(&file.sha256)
4944                || file.bytes > MAX_STORE_BYTES
4945        })
4946        || baseline.assets.iter().any(|(path, asset)| {
4947            crate::linkmd_v2::normalize_path(path).is_err()
4948                || !is_sha256(&asset.blob_sha256)
4949                || !is_sha256(&asset.leaf_hash)
4950                || asset.bytes > MAX_ASSET_BYTES
4951                || !matches!(asset.disposition.as_str(), "hosted" | "withheld")
4952                || asset.wrappers.is_empty()
4953                || asset
4954                    .wrappers
4955                    .iter()
4956                    .any(|wrapper| crate::linkmd_v2::normalize_path(wrapper).is_err())
4957        })
4958        || baseline
4959            .local_eligibility
4960            .keys()
4961            .chain(baseline.remote_copy_remains.keys())
4962            .any(|path| crate::linkmd_v2::normalize_path(path).is_err())
4963        || baseline
4964            .remote_copy_remains
4965            .values()
4966            .any(|hash| !is_sha256(hash))
4967        || baseline
4968            .checkout_id
4969            .as_deref()
4970            .is_some_and(|checkout_id| !is_sha256(checkout_id))
4971    {
4972        return Err(invalid_feed("v2 sync baseline failed validation"));
4973    }
4974    Ok(baseline)
4975}
4976
4977#[cfg(unix)]
4978fn load_v2_baseline(
4979    cfg: &HubConfig,
4980    brain: &str,
4981    checkout: &Path,
4982) -> LinkResult<Option<V2SyncBaseline>> {
4983    use std::os::fd::{AsRawFd as _, FromRawFd as _};
4984    let directory = open_trust_dir(cfg)?;
4985    let name_string = v2_baseline_name(cfg, brain, checkout)?;
4986    let _lock = lock_trust_name(&directory, &name_string)?;
4987    let name = c_name(name_string.as_bytes(), &name_string)?;
4988    let fd = unsafe {
4989        libc::openat(
4990            directory.as_raw_fd(),
4991            name.as_ptr(),
4992            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
4993        )
4994    };
4995    if fd < 0 {
4996        let error = std::io::Error::last_os_error();
4997        return if error.kind() == std::io::ErrorKind::NotFound {
4998            Ok(None)
4999        } else {
5000            Err(LinkError::UnsafePath { path: name_string })
5001        };
5002    }
5003    let file = unsafe { std::fs::File::from_raw_fd(fd) };
5004    let mut bytes = Vec::new();
5005    file.take(MAX_FEED_RESPONSE_BYTES + 1)
5006        .read_to_end(&mut bytes)?;
5007    if bytes.len() as u64 > MAX_FEED_RESPONSE_BYTES {
5008        return Err(invalid_feed("v2 sync baseline is oversized"));
5009    }
5010    Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?))
5011}
5012
5013#[cfg(windows)]
5014fn load_v2_baseline(
5015    cfg: &HubConfig,
5016    brain: &str,
5017    checkout: &Path,
5018) -> LinkResult<Option<V2SyncBaseline>> {
5019    let directory = open_trust_dir(cfg)?;
5020    let name = v2_baseline_name(cfg, brain, checkout)?;
5021    let _lock = lock_trust_name(&directory, &name)?;
5022    let mut reader = crate::fsx::BoundedDirReader::from_root(&directory)?;
5023    match reader.read(Path::new(&name), MAX_FEED_RESPONSE_BYTES) {
5024        Ok(bytes) => Ok(Some(parse_v2_baseline(cfg, brain, &bytes)?)),
5025        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
5026        Err(_) => Err(LinkError::UnsafePath { path: name }),
5027    }
5028}
5029
5030#[cfg(not(any(unix, windows)))]
5031fn load_v2_baseline(
5032    _cfg: &HubConfig,
5033    _brain: &str,
5034    _checkout: &Path,
5035) -> LinkResult<Option<V2SyncBaseline>> {
5036    Err(LinkError::UnsupportedPlatform {
5037        operation: "verified link.md v2 baseline",
5038    })
5039}
5040
5041#[cfg(unix)]
5042fn save_v2_baseline(
5043    cfg: &HubConfig,
5044    brain: &str,
5045    checkout: &Path,
5046    baseline: &V2SyncBaseline,
5047) -> LinkResult<()> {
5048    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5049    let directory = open_trust_dir(cfg)?;
5050    let name_string = v2_baseline_name(cfg, brain, checkout)?;
5051    let _lock = lock_trust_name(&directory, &name_string)?;
5052    let name = c_name(name_string.as_bytes(), &name_string)?;
5053    let mut bytes = serde_json::to_vec(baseline)
5054        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5055    bytes.push(b'\n');
5056    let temp_string = format!(
5057        ".{name_string}.tmp.{}-{}",
5058        std::process::id(),
5059        std::time::SystemTime::now()
5060            .duration_since(std::time::UNIX_EPOCH)
5061            .unwrap_or_default()
5062            .as_nanos()
5063    );
5064    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5065    let fd = unsafe {
5066        libc::openat(
5067            directory.as_raw_fd(),
5068            temp.as_ptr(),
5069            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5070            0o600,
5071        )
5072    };
5073    if fd < 0 {
5074        return Err(std::io::Error::last_os_error().into());
5075    }
5076    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
5077    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
5078        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5079        return Err(error.into());
5080    }
5081    drop(file);
5082    if unsafe {
5083        libc::renameat(
5084            directory.as_raw_fd(),
5085            temp.as_ptr(),
5086            directory.as_raw_fd(),
5087            name.as_ptr(),
5088        )
5089    } != 0
5090    {
5091        let error = std::io::Error::last_os_error();
5092        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5093        return Err(error.into());
5094    }
5095    directory.sync_all()?;
5096    Ok(())
5097}
5098
5099#[cfg(windows)]
5100fn save_v2_baseline(
5101    cfg: &HubConfig,
5102    brain: &str,
5103    checkout: &Path,
5104    baseline: &V2SyncBaseline,
5105) -> LinkResult<()> {
5106    let directory = open_trust_dir(cfg)?;
5107    let name = v2_baseline_name(cfg, brain, checkout)?;
5108    let _lock = lock_trust_name(&directory, &name)?;
5109    let mut bytes = serde_json::to_vec(baseline)
5110        .map_err(|_| invalid_feed("could not serialize v2 sync baseline"))?;
5111    bytes.push(b'\n');
5112    crate::fsx::write_atomic_beneath(&directory, Path::new(&name), &bytes, false, true)?;
5113    Ok(())
5114}
5115
5116#[cfg(not(any(unix, windows)))]
5117fn save_v2_baseline(
5118    _cfg: &HubConfig,
5119    _brain: &str,
5120    _checkout: &Path,
5121    _baseline: &V2SyncBaseline,
5122) -> LinkResult<()> {
5123    Err(LinkError::UnsupportedPlatform {
5124        operation: "verified link.md v2 baseline",
5125    })
5126}
5127
5128fn v2_baseline_from_head(
5129    cfg: &HubConfig,
5130    head: &V2VerifiedHead,
5131    files: std::collections::BTreeMap<String, V2BaselineFile>,
5132    assets: std::collections::BTreeMap<String, V2BaselineAsset>,
5133    local: Option<&V2LocalView>,
5134    checkout_id: Option<&str>,
5135) -> LinkResult<V2SyncBaseline> {
5136    let mut local_eligibility = local
5137        .map(|view| view.eligibility.clone())
5138        .unwrap_or_default();
5139    if let Some(view) = local {
5140        for path in files.keys() {
5141            local_eligibility
5142                .entry(path.clone())
5143                .or_insert_with(|| !view.policy.keeps_home(path));
5144        }
5145    }
5146    let remote_copy_remains = local_eligibility
5147        .iter()
5148        .filter(|(_, riding)| !**riding)
5149        .filter_map(|(path, _)| {
5150            files
5151                .get(path)
5152                .map(|file| (path.clone(), file.sha256.clone()))
5153        })
5154        .collect();
5155    Ok(V2SyncBaseline {
5156        v: 2,
5157        origin: normalized_origin(&cfg.hub)?,
5158        brain: head.brain_id.clone(),
5159        checkout_id: Some(v2_checkout_id(checkout_id)?),
5160        head_seq: Some(head.pointer.as_ref().map_or(0, |pointer| pointer.seq)),
5161        commit_hash: head
5162            .pointer
5163            .as_ref()
5164            .map(|pointer| pointer.commit_hash.clone()),
5165        content_root: head
5166            .pointer
5167            .as_ref()
5168            .and_then(|pointer| pointer.content_root.clone()),
5169        asset_root: head
5170            .pointer
5171            .as_ref()
5172            .and_then(|pointer| pointer.asset_root.clone()),
5173        assets,
5174        view_kind: Some(head.view_kind.clone()),
5175        view_revision: Some(head.view_revision.clone()),
5176        projection_sha256: (head.view_kind == "scoped")
5177            .then(|| scoped_projection_sha256(&head.brain_id)),
5178        files,
5179        local_policy_digest: local.map(|view| view.policy.digest.clone()),
5180        local_eligibility,
5181        remote_copy_remains,
5182    })
5183}
5184
5185fn v2_local_files(store: &Store) -> LinkResult<V2LocalView> {
5186    let policy = crate::linkmd_sync_policy::load(store)
5187        .map_err(|message| LinkError::InvalidPack { message })?;
5188    let asset_paths = crate::assets::read_manifest(store)
5189        .map_err(|error| invalid_feed(format!("local asset manifest is invalid: {error}")))?
5190        .into_iter()
5191        .map(|asset| asset.path)
5192        .collect::<std::collections::BTreeSet<_>>();
5193    let mut result = std::collections::BTreeMap::new();
5194    let mut eligibility = std::collections::BTreeMap::new();
5195    let mut riding_links = Vec::<(String, Vec<String>)>::new();
5196    let mut total = 0_u64;
5197    let mut paths = vec![PathBuf::from("DB.md")];
5198    paths.extend(store.walk()?);
5199    for relative in paths {
5200        let path = relative.to_string_lossy().replace('\\', "/");
5201        // v2 catalogs and asset inventory are materialized/signed separately.
5202        if matches!(path.as_str(), "assets.jsonl" | "index.md" | "index.jsonl") {
5203            continue;
5204        }
5205        if asset_paths.contains(&path) {
5206            continue;
5207        }
5208        crate::linkmd_v2::normalize_path(&path).map_err(|error| LinkError::UnsafePath {
5209            path: error.to_string(),
5210        })?;
5211        let riding = !policy.keeps_home(&path);
5212        eligibility.insert(path.clone(), riding);
5213        if !riding {
5214            continue;
5215        }
5216        let remaining = MAX_STORE_BYTES.saturating_sub(total);
5217        let bytes = store.read_bounded(&relative, remaining)?;
5218        total = total
5219            .checked_add(bytes.len() as u64)
5220            .ok_or_else(|| LinkError::PushTooLarge {
5221                detail: "v2 local byte count overflow".to_string(),
5222            })?;
5223        if total > MAX_STORE_BYTES {
5224            return Err(LinkError::PushTooLarge {
5225                detail: format!("{total} uncompressed bytes"),
5226            });
5227        }
5228        if std::str::from_utf8(&bytes).is_err() {
5229            return Err(LinkError::NotUtf8 { path });
5230        }
5231        let text = std::str::from_utf8(&bytes).expect("UTF-8 was checked");
5232        riding_links.push((path.clone(), crate::store::extract_edge_targets(text)));
5233        result.insert(path, (content_sha256(&bytes), bytes.len() as u64));
5234    }
5235    let kept_home = eligibility
5236        .iter()
5237        .filter(|(_, riding)| !**riding)
5238        .map(|(path, _)| path.clone())
5239        .collect::<std::collections::BTreeSet<_>>();
5240    let mut withheld_links = riding_links
5241        .into_iter()
5242        .flat_map(|(source, targets)| {
5243            let kept_home = &kept_home;
5244            let policy = &policy;
5245            targets.into_iter().filter_map(move |target| {
5246                let target = format!("{target}.md");
5247                // A kept-home target need not be a file on THIS machine. An
5248                // export omits kept-home content by construction, so a store
5249                // restored from one links to names it does not carry — and
5250                // without this the owner's own export could never be pushed
5251                // back, which is the opposite of owning your data. The policy
5252                // is the owner's statement about where a path lives, and it
5253                // stands whether or not this checkout holds a copy. It
5254                // discloses nothing either way: the name is already written in
5255                // the content that links to it.
5256                (kept_home.contains(&target) || policy.keeps_home(&target)).then_some(
5257                    V2WithheldLink {
5258                        source: source.clone(),
5259                        target,
5260                    },
5261                )
5262            })
5263        })
5264        .collect::<Vec<_>>();
5265    withheld_links.sort();
5266    withheld_links.dedup();
5267    Ok(V2LocalView {
5268        riding: result,
5269        eligibility,
5270        policy,
5271        withheld_links,
5272    })
5273}
5274
5275#[derive(Debug, Deserialize)]
5276struct V2DownloadItem {
5277    path: String,
5278    sha256: String,
5279    bytes: u64,
5280    url: String,
5281    method: String,
5282}
5283
5284#[derive(Debug, Deserialize)]
5285struct V2DownloadWindow {
5286    v: u8,
5287    commit: String,
5288    downloads: Vec<V2DownloadItem>,
5289}
5290
5291#[derive(Debug, Deserialize)]
5292struct V2BulkStreamHeader {
5293    v: u8,
5294    path: String,
5295    sha256: String,
5296    bytes: u64,
5297}
5298
5299fn parse_v2_bulk_stream(
5300    bytes: &[u8],
5301    expected: &[(&String, &V2BaselineFile)],
5302) -> LinkResult<Vec<(String, Vec<u8>)>> {
5303    if !bytes.starts_with(V2_BULK_STREAM_MAGIC) {
5304        return Err(invalid_feed("v2 bulk stream has an invalid magic"));
5305    }
5306    let mut cursor = V2_BULK_STREAM_MAGIC.len();
5307    let mut result = Vec::with_capacity(expected.len());
5308    for (expected_path, expected_file) in expected {
5309        let length_bytes = bytes
5310            .get(cursor..cursor + 4)
5311            .ok_or_else(|| invalid_feed("v2 bulk stream ended before a frame header"))?;
5312        cursor += 4;
5313        let header_len = u32::from_be_bytes(length_bytes.try_into().expect("four bytes")) as usize;
5314        if header_len == 0 || header_len > 4 * 1024 {
5315            return Err(invalid_feed("v2 bulk stream has an invalid frame length"));
5316        }
5317        let header_bytes = bytes
5318            .get(cursor..cursor + header_len)
5319            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside a frame header"))?;
5320        cursor += header_len;
5321        let header: V2BulkStreamHeader = serde_json::from_slice(header_bytes)
5322            .map_err(|_| invalid_feed("v2 bulk stream has an invalid frame header"))?;
5323        if header.v != 2
5324            || &header.path != *expected_path
5325            || header.sha256 != expected_file.sha256
5326            || header.bytes != expected_file.bytes
5327            || header.bytes > V2_BULK_STREAM_CONTENT_BYTES
5328        {
5329            return Err(invalid_feed(
5330                "v2 bulk stream frame differs from its proven manifest entry",
5331            ));
5332        }
5333        let body_len = usize::try_from(header.bytes)
5334            .map_err(|_| invalid_feed("v2 bulk stream frame length overflows this platform"))?;
5335        let body = bytes
5336            .get(cursor..cursor + body_len)
5337            .ok_or_else(|| invalid_feed("v2 bulk stream ended inside file bytes"))?;
5338        cursor += body_len;
5339        if content_sha256(body) != header.sha256 {
5340            return Err(invalid_feed(
5341                "v2 bulk stream file differs from its proven manifest entry",
5342            ));
5343        }
5344        result.push((header.path, body.to_vec()));
5345    }
5346    if bytes.get(cursor..cursor + 4) != Some(&[0, 0, 0, 0]) {
5347        return Err(invalid_feed("v2 bulk stream has no exact end marker"));
5348    }
5349    cursor += 4;
5350    if cursor != bytes.len() {
5351        return Err(invalid_feed("v2 bulk stream carries trailing data"));
5352    }
5353    Ok(result)
5354}
5355
5356fn download_v2_bulk_stream(
5357    cfg: &HubConfig,
5358    brain: &str,
5359    pointer: &V2PointerBody,
5360    pending: &[(&String, &V2BaselineFile)],
5361) -> LinkResult<Vec<(String, Vec<u8>)>> {
5362    let claims = pending
5363        .iter()
5364        .map(|(path, file)| {
5365            Ok(json!({
5366                "path": path,
5367                "sha256": file.sha256,
5368                "bytes": file.bytes,
5369                "proof": file.proof.as_ref().ok_or_else(|| {
5370                    invalid_feed("v2 manifest omitted a bulk-stream proof")
5371                })?,
5372            }))
5373        })
5374        .collect::<LinkResult<Vec<_>>>()?;
5375    let raw = request_raw(
5376        cfg,
5377        "POST",
5378        &format!("/api/hub/brains/{brain}/v2/stream"),
5379        Some(&json!({
5380            "commit": pointer.commit_hash,
5381            "files": claims,
5382        })),
5383        Auth::Required,
5384        V2_BULK_STREAM_RESPONSE_BYTES,
5385    )?;
5386    let body = ensure_raw_ok(raw, "download v2 bulk stream")?;
5387    parse_v2_bulk_stream(&body, pending)
5388}
5389
5390fn prepare_v2_downloads(
5391    cfg: &HubConfig,
5392    brain: &str,
5393    pointer: &V2PointerBody,
5394    pending: &[(&String, &V2BaselineFile)],
5395) -> LinkResult<Vec<V2DownloadItem>> {
5396    let mut result = Vec::with_capacity(pending.len());
5397    for chunk in pending.chunks(128) {
5398        let claims = chunk
5399            .iter()
5400            .map(|(path, file)| {
5401                Ok(json!({
5402                    "path": path,
5403                    "sha256": file.sha256,
5404                    "bytes": file.bytes,
5405                    "proof": file.proof.as_ref().ok_or_else(|| {
5406                        invalid_feed("v2 manifest omitted a download proof")
5407                    })?,
5408                }))
5409            })
5410            .collect::<LinkResult<Vec<_>>>()?;
5411        let value = ensure_ok(
5412            request_capped(
5413                cfg,
5414                "POST",
5415                &format!("/api/hub/brains/{brain}/v2/downloads"),
5416                Some(&json!({
5417                    "commit": pointer.commit_hash,
5418                    "files": claims,
5419                })),
5420                Auth::Required,
5421                MAX_FEED_RESPONSE_BYTES,
5422            )?,
5423            "prepare v2 blob downloads",
5424        )?;
5425        let window: V2DownloadWindow = serde_json::from_value(value)
5426            .map_err(|_| invalid_feed("v2 download window has an invalid shape"))?;
5427        if window.v != 2
5428            || window.commit != pointer.commit_hash
5429            || window.downloads.len() != chunk.len()
5430        {
5431            return Err(invalid_feed(
5432                "v2 download window is not bound to the requested files",
5433            ));
5434        }
5435        let mut by_path = window
5436            .downloads
5437            .into_iter()
5438            .map(|item| (item.path.clone(), item))
5439            .collect::<std::collections::BTreeMap<_, _>>();
5440        if by_path.len() != chunk.len() {
5441            return Err(invalid_feed("v2 download window repeats a path"));
5442        }
5443        for (path, file) in chunk {
5444            let item = by_path
5445                .remove(*path)
5446                .ok_or_else(|| invalid_feed("v2 download window omitted a path"))?;
5447            if item.method != "GET"
5448                || item.sha256 != file.sha256
5449                || item.bytes != file.bytes
5450                || item.url.is_empty()
5451            {
5452                return Err(invalid_feed(
5453                    "v2 download capability differs from its proven file",
5454                ));
5455            }
5456            result.push(item);
5457        }
5458    }
5459    Ok(result)
5460}
5461
5462fn prepare_v2_asset_downloads(
5463    cfg: &HubConfig,
5464    brain: &str,
5465    pointer: &V2PointerBody,
5466    pending: &[(&String, &V2BaselineAsset)],
5467) -> LinkResult<Vec<V2DownloadItem>> {
5468    let mut result = Vec::with_capacity(pending.len());
5469    for chunk in pending.chunks(128) {
5470        let claims = chunk
5471            .iter()
5472            .map(|(path, asset)| {
5473                json!({
5474                    "path": path,
5475                    "sha256": asset.blob_sha256,
5476                    "bytes": asset.bytes,
5477                    "leaf_hash": asset.leaf_hash,
5478                })
5479            })
5480            .collect::<Vec<_>>();
5481        let value = ensure_ok(
5482            request_capped(
5483                cfg,
5484                "POST",
5485                &format!("/api/hub/brains/{brain}/v2/assets/downloads"),
5486                Some(&json!({
5487                    "commit": pointer.commit_hash,
5488                    "assets": claims,
5489                })),
5490                Auth::Required,
5491                MAX_FEED_RESPONSE_BYTES,
5492            )?,
5493            "prepare v2 asset downloads",
5494        )?;
5495        let window: V2DownloadWindow = serde_json::from_value(value)
5496            .map_err(|_| invalid_feed("v2 asset download window has an invalid shape"))?;
5497        if window.v != 2
5498            || window.commit != pointer.commit_hash
5499            || window.downloads.len() != chunk.len()
5500        {
5501            return Err(invalid_feed(
5502                "v2 asset download window is not bound to the requested assets",
5503            ));
5504        }
5505        let mut by_path = window
5506            .downloads
5507            .into_iter()
5508            .map(|item| (item.path.clone(), item))
5509            .collect::<std::collections::BTreeMap<_, _>>();
5510        if by_path.len() != chunk.len() {
5511            return Err(invalid_feed("v2 asset download window repeats a path"));
5512        }
5513        for (path, asset) in chunk {
5514            let item = by_path
5515                .remove(*path)
5516                .ok_or_else(|| invalid_feed("v2 asset download window omitted a path"))?;
5517            if item.method != "GET"
5518                || item.sha256 != asset.blob_sha256
5519                || item.bytes != asset.bytes
5520                || item.url.is_empty()
5521            {
5522                return Err(invalid_feed(
5523                    "v2 asset download capability differs from its signed leaf",
5524                ));
5525            }
5526            result.push(item);
5527        }
5528    }
5529    Ok(result)
5530}
5531
5532fn download_v2_blob(cfg: &HubConfig, item: &V2DownloadItem) -> LinkResult<Vec<u8>> {
5533    let bytes = get_presigned(cfg, &item.url)?;
5534    if bytes.len() as u64 != item.bytes || content_sha256(&bytes) != item.sha256 {
5535        return Err(invalid_feed("v2 blob differs from its proven path entry"));
5536    }
5537    Ok(bytes)
5538}
5539
5540#[derive(Debug, Clone)]
5541struct V2StagedFile {
5542    path: String,
5543    source: PathBuf,
5544    sha256: String,
5545    bytes: u64,
5546}
5547
5548#[cfg(unix)]
5549fn v2_download_cache_dir(
5550    cfg: &HubConfig,
5551    brain: &str,
5552    pointer: &V2PointerBody,
5553) -> LinkResult<PathBuf> {
5554    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5555}
5556
5557#[cfg(unix)]
5558fn v2_download_cache_dir_for(
5559    cfg: &HubConfig,
5560    brain: &str,
5561    transaction: &str,
5562) -> LinkResult<PathBuf> {
5563    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5564        return Err(invalid_feed("v2 download cache address is invalid"));
5565    }
5566    let path = cfg
5567        .state_dir
5568        .join("downloads")
5569        .join(brain)
5570        .join(transaction);
5571    let directory = open_or_create_dir_nofollow(&path)?;
5572    use std::os::fd::AsRawFd as _;
5573    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
5574        return Err(std::io::Error::last_os_error().into());
5575    }
5576    directory.sync_all()?;
5577    Ok(path)
5578}
5579
5580#[cfg(windows)]
5581fn v2_download_cache_dir(
5582    cfg: &HubConfig,
5583    brain: &str,
5584    pointer: &V2PointerBody,
5585) -> LinkResult<PathBuf> {
5586    v2_download_cache_dir_for(cfg, brain, &pointer.commit_hash)
5587}
5588
5589#[cfg(windows)]
5590fn v2_download_cache_dir_for(
5591    cfg: &HubConfig,
5592    brain: &str,
5593    transaction: &str,
5594) -> LinkResult<PathBuf> {
5595    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5596        return Err(invalid_feed("v2 download cache address is invalid"));
5597    }
5598    let path = cfg
5599        .state_dir
5600        .join("downloads")
5601        .join(brain)
5602        .join(transaction);
5603    crate::fsx::write_atomic(&path.join(".directory"), b"v2 download cache\n")?;
5604    crate::fsx::open_directory_nofollow(&path)?;
5605    Ok(path)
5606}
5607
5608#[cfg(unix)]
5609fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5610    use std::os::fd::AsRawFd as _;
5611    let parent = cfg.state_dir.join("downloads").join(brain);
5612    let Ok(directory) = open_existing_dir_nofollow(&parent) else {
5613        return;
5614    };
5615    let Ok(name) = c_name(transaction.as_bytes(), transaction) else {
5616        return;
5617    };
5618    let _ = remove_tree_at(directory.as_raw_fd(), &name, &parent.display().to_string());
5619    let _ = directory.sync_all();
5620}
5621
5622#[cfg(windows)]
5623fn cleanup_v2_download_cache(cfg: &HubConfig, brain: &str, transaction: &str) {
5624    if !crate::ulid::is_ulid(brain) || !is_sha256(transaction) {
5625        return;
5626    }
5627    let parent = cfg.state_dir.join("downloads").join(brain);
5628    let Ok(root) = crate::fsx::open_directory_nofollow(&parent) else {
5629        return;
5630    };
5631    let _ = crate::fsx::remove_tree_beneath(&root, Path::new(transaction));
5632}
5633
5634#[cfg(not(any(unix, windows)))]
5635fn cleanup_v2_download_cache(_cfg: &HubConfig, _brain: &str, _transaction: &str) {}
5636
5637#[cfg(not(any(unix, windows)))]
5638fn v2_download_cache_dir_for(
5639    _cfg: &HubConfig,
5640    _brain: &str,
5641    _transaction: &str,
5642) -> LinkResult<PathBuf> {
5643    Err(LinkError::UnsupportedPlatform {
5644        operation: "resumable v2 download staging",
5645    })
5646}
5647
5648#[cfg(any(unix, windows))]
5649fn cached_blob_is_exact(path: &Path, sha256: &str, bytes: u64) -> LinkResult<bool> {
5650    let file = match crate::fsx::open_regular_nofollow(path) {
5651        Ok(file) => file,
5652        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false),
5653        Err(error) => return Err(error.into()),
5654    };
5655    if file.metadata()?.len() != bytes {
5656        return Ok(false);
5657    }
5658    Ok(content_sha256_reader(file)? == sha256)
5659}
5660
5661#[cfg(any(unix, windows))]
5662fn cache_v2_blob_bytes(
5663    cache_dir: &Path,
5664    sha256: &str,
5665    expected_bytes: u64,
5666    bytes: &[u8],
5667) -> LinkResult<PathBuf> {
5668    if bytes.len() as u64 != expected_bytes || content_sha256(bytes) != sha256 {
5669        return Err(invalid_feed("v2 cached blob differs from its declaration"));
5670    }
5671    let path = cache_dir.join(sha256);
5672    if !cached_blob_is_exact(&path, sha256, expected_bytes)? {
5673        crate::fsx::write_atomic(&path, bytes)?;
5674    }
5675    Ok(path)
5676}
5677
5678#[cfg(not(any(unix, windows)))]
5679fn cache_v2_blob_bytes(
5680    _cache_dir: &Path,
5681    _sha256: &str,
5682    _expected_bytes: u64,
5683    _bytes: &[u8],
5684) -> LinkResult<PathBuf> {
5685    Err(LinkError::UnsupportedPlatform {
5686        operation: "resumable v2 download staging",
5687    })
5688}
5689
5690#[cfg(unix)]
5691fn download_presigned_to_cache(
5692    cfg: &HubConfig,
5693    url: &str,
5694    cache_dir: &Path,
5695    sha256: &str,
5696    expected_bytes: u64,
5697) -> LinkResult<PathBuf> {
5698    use std::os::fd::{AsRawFd as _, FromRawFd as _};
5699
5700    let target = cache_dir.join(sha256);
5701    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5702        return Ok(target);
5703    }
5704    let directory = open_existing_dir_nofollow(cache_dir)?;
5705    let mut nonce = [0_u8; 16];
5706    ring::rand::SecureRandom::fill(&ring::rand::SystemRandom::new(), &mut nonce)
5707        .map_err(|_| invalid_feed("could not mint a download cache name"))?;
5708    let temp_string = format!(".download-{}", URL_SAFE_NO_PAD.encode(nonce));
5709    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
5710    let fd = unsafe {
5711        libc::openat(
5712            directory.as_raw_fd(),
5713            temp.as_ptr(),
5714            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
5715            0o600,
5716        )
5717    };
5718    if fd < 0 {
5719        return Err(std::io::Error::last_os_error().into());
5720    }
5721    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
5722    let response = match presigned_agent(cfg, url)?.get(url).call() {
5723        Ok(response) => response,
5724        Err(ureq::Error::Status(_, response)) => {
5725            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5726            return Err(LinkError::Http {
5727                what: "v2 direct download",
5728                status: response.status(),
5729                message: "object store rejected the download".to_string(),
5730                code: None,
5731                details: None,
5732            });
5733        }
5734        Err(ureq::Error::Transport(error)) => {
5735            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5736            return Err(LinkError::Transport {
5737                hub: cfg.hub.clone(),
5738                message: error.to_string(),
5739            });
5740        }
5741    };
5742    let mut reader = response
5743        .into_reader()
5744        .take(expected_bytes.saturating_add(1));
5745    let mut digest = Sha256::new();
5746    let mut total = 0_u64;
5747    let mut buffer = [0_u8; 64 * 1024];
5748    // Reading the socket and writing the file are different failures wearing
5749    // the same io::Error: a stalled download is the peer's problem and a full
5750    // disk is this machine's, and an operator who is told only "timed out"
5751    // cannot tell which one to act on.
5752    let write_result = (|| -> LinkResult<()> {
5753        loop {
5754            let read = reader
5755                .read(&mut buffer)
5756                .map_err(|error| LinkError::Transport {
5757                    hub: cfg.hub.clone(),
5758                    message: error.to_string(),
5759                })?;
5760            if read == 0 {
5761                break;
5762            }
5763            total = total.saturating_add(read as u64);
5764            digest.update(&buffer[..read]);
5765            output.write_all(&buffer[..read])?;
5766        }
5767        output.sync_all().map_err(LinkError::from)
5768    })();
5769    if let Err(error) = write_result {
5770        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5771        return Err(error);
5772    }
5773    drop(output);
5774    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5775        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5776        return Err(invalid_feed(
5777            "v2 direct download failed integrity verification",
5778        ));
5779    }
5780    let target_name = c_name(sha256.as_bytes(), sha256)?;
5781    // Replace only a previously inspected cache leaf. Cache bytes are private
5782    // and content-addressed; final install re-verifies them again.
5783    if unsafe {
5784        libc::renameat(
5785            directory.as_raw_fd(),
5786            temp.as_ptr(),
5787            directory.as_raw_fd(),
5788            target_name.as_ptr(),
5789        )
5790    } != 0
5791    {
5792        let error = std::io::Error::last_os_error();
5793        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
5794        return Err(error.into());
5795    }
5796    directory.sync_all()?;
5797    Ok(target)
5798}
5799
5800#[cfg(windows)]
5801fn download_presigned_to_cache(
5802    cfg: &HubConfig,
5803    url: &str,
5804    cache_dir: &Path,
5805    sha256: &str,
5806    expected_bytes: u64,
5807) -> LinkResult<PathBuf> {
5808    use std::fs::OpenOptions;
5809
5810    let target = cache_dir.join(sha256);
5811    if cached_blob_is_exact(&target, sha256, expected_bytes)? {
5812        return Ok(target);
5813    }
5814    // Keep the checked directory handle alive while the private cache path is
5815    // used. Windows opens omit FILE_SHARE_DELETE, so neither this directory nor
5816    // an ancestor can be swapped after validation.
5817    let _directory = crate::fsx::open_directory_nofollow(cache_dir)?;
5818    let temp = cache_dir.join(format!(".download-{}", crate::ulid::mint()));
5819    let mut output = OpenOptions::new()
5820        .write(true)
5821        .create_new(true)
5822        .open(&temp)?;
5823    let response = match presigned_agent(cfg, url)?.get(url).call() {
5824        Ok(response) => response,
5825        Err(ureq::Error::Status(_, response)) => {
5826            let _ = std::fs::remove_file(&temp);
5827            return Err(LinkError::Http {
5828                what: "v2 direct download",
5829                status: response.status(),
5830                message: "object store rejected the download".to_string(),
5831                code: None,
5832                details: None,
5833            });
5834        }
5835        Err(ureq::Error::Transport(error)) => {
5836            let _ = std::fs::remove_file(&temp);
5837            return Err(LinkError::Transport {
5838                hub: cfg.hub.clone(),
5839                message: error.to_string(),
5840            });
5841        }
5842    };
5843    let mut reader = response
5844        .into_reader()
5845        .take(expected_bytes.saturating_add(1));
5846    let mut digest = Sha256::new();
5847    let mut total = 0_u64;
5848    let mut buffer = [0_u8; 64 * 1024];
5849    // Same split as the cache-fill path: a stalled socket is not a disk error.
5850    let copied = (|| -> LinkResult<()> {
5851        loop {
5852            let read = reader
5853                .read(&mut buffer)
5854                .map_err(|error| LinkError::Transport {
5855                    hub: cfg.hub.clone(),
5856                    message: error.to_string(),
5857                })?;
5858            if read == 0 {
5859                break;
5860            }
5861            total = total.saturating_add(read as u64);
5862            digest.update(&buffer[..read]);
5863            output.write_all(&buffer[..read])?;
5864        }
5865        output.sync_all()?;
5866        Ok(())
5867    })();
5868    if let Err(error) = copied {
5869        let _ = std::fs::remove_file(&temp);
5870        return Err(error);
5871    }
5872    drop(output);
5873    if total != expected_bytes || format!("{:x}", digest.finalize()) != sha256 {
5874        let _ = std::fs::remove_file(&temp);
5875        return Err(invalid_feed(
5876            "v2 direct download failed integrity verification",
5877        ));
5878    }
5879    if target.exists() {
5880        std::fs::remove_file(&target)?;
5881    }
5882    if let Err(error) = std::fs::rename(&temp, &target) {
5883        let _ = std::fs::remove_file(&temp);
5884        return Err(error.into());
5885    }
5886    Ok(target)
5887}
5888
5889#[cfg(not(any(unix, windows)))]
5890fn download_presigned_to_cache(
5891    _cfg: &HubConfig,
5892    _url: &str,
5893    _cache_dir: &Path,
5894    _sha256: &str,
5895    _expected_bytes: u64,
5896) -> LinkResult<PathBuf> {
5897    Err(LinkError::UnsupportedPlatform {
5898        operation: "resumable v2 download staging",
5899    })
5900}
5901
5902fn download_v2_blobs(
5903    cfg: &HubConfig,
5904    brain: &str,
5905    pointer: &V2PointerBody,
5906    pending: Vec<(&String, &V2BaselineFile)>,
5907) -> LinkResult<Vec<(String, Vec<u8>)>> {
5908    if pending.is_empty() {
5909        return Ok(Vec::new());
5910    }
5911    let expected_order = pending
5912        .iter()
5913        .map(|(path, _)| (*path).clone())
5914        .collect::<Vec<_>>();
5915    let mut streamed = std::collections::BTreeMap::new();
5916    let mut direct = Vec::new();
5917    let mut window = Vec::new();
5918    let mut window_bytes = 0_u64;
5919    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
5920                 window_bytes: &mut u64,
5921                 streamed: &mut std::collections::BTreeMap<String, Vec<u8>>|
5922     -> LinkResult<()> {
5923        if window.is_empty() {
5924            return Ok(());
5925        }
5926        for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, window)? {
5927            if streamed.insert(path, bytes).is_some() {
5928                return Err(invalid_feed("v2 bulk streams repeated a path"));
5929            }
5930        }
5931        window.clear();
5932        *window_bytes = 0;
5933        Ok(())
5934    };
5935    for &(path, file) in &pending {
5936        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
5937            flush(&mut window, &mut window_bytes, &mut streamed)?;
5938            direct.push((path, file));
5939            continue;
5940        }
5941        if window.len() == V2_BULK_STREAM_FILES
5942            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
5943        {
5944            flush(&mut window, &mut window_bytes, &mut streamed)?;
5945        }
5946        window.push((path, file));
5947        window_bytes += file.bytes;
5948    }
5949    flush(&mut window, &mut window_bytes, &mut streamed)?;
5950
5951    let downloads = prepare_v2_downloads(cfg, brain, pointer, &direct)?;
5952    let next = std::sync::atomic::AtomicUsize::new(0);
5953    let worker_count = downloads.len().min(V2_BLOB_DOWNLOAD_WORKERS);
5954    let mut results = std::iter::repeat_with(|| None)
5955        .take(downloads.len())
5956        .collect::<Vec<Option<LinkResult<(String, Vec<u8>)>>>>();
5957    std::thread::scope(|scope| {
5958        let (sender, receiver) = std::sync::mpsc::channel();
5959        for _ in 0..worker_count {
5960            let sender = sender.clone();
5961            let downloads = &downloads;
5962            let next = &next;
5963            scope.spawn(move || loop {
5964                let index = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
5965                let Some(item) = downloads.get(index) else {
5966                    break;
5967                };
5968                let result = download_v2_blob(cfg, item).map(|bytes| (item.path.clone(), bytes));
5969                if sender.send((index, result)).is_err() {
5970                    break;
5971                }
5972            });
5973        }
5974        drop(sender);
5975        for (index, result) in receiver {
5976            results[index] = Some(result);
5977        }
5978    });
5979    for result in results.into_iter().map(|result| {
5980        result.ok_or_else(|| LinkError::Transport {
5981            hub: cfg.hub.clone(),
5982            message: "a bounded v2 blob worker stopped before reporting its result".to_string(),
5983        })?
5984    }) {
5985        let (path, bytes) = result?;
5986        if streamed.insert(path, bytes).is_some() {
5987            return Err(invalid_feed("v2 download lanes repeated a path"));
5988        }
5989    }
5990    expected_order
5991        .into_iter()
5992        .map(|path| {
5993            streamed
5994                .remove(&path)
5995                .map(|bytes| (path, bytes))
5996                .ok_or_else(|| invalid_feed("v2 download lanes omitted a proven path"))
5997        })
5998        .collect()
5999}
6000
6001/// Resume an exact-head materialization into the private global cache. Each
6002/// verified frame is durable before the next network window begins, so a
6003/// retry skips it and memory never grows with total changed bytes.
6004#[cfg(any(unix, windows))]
6005fn stage_v2_blobs(
6006    cfg: &HubConfig,
6007    brain: &str,
6008    pointer: &V2PointerBody,
6009    pending: Vec<(&String, &V2BaselineFile)>,
6010) -> LinkResult<Vec<V2StagedFile>> {
6011    let cache_dir = v2_download_cache_dir(cfg, brain, pointer)?;
6012    let mut staged = std::collections::BTreeMap::<String, V2StagedFile>::new();
6013    let mut direct = Vec::new();
6014    let mut window = Vec::new();
6015    let mut window_bytes = 0_u64;
6016    let flush = |window: &mut Vec<(&String, &V2BaselineFile)>,
6017                 window_bytes: &mut u64,
6018                 staged: &mut std::collections::BTreeMap<String, V2StagedFile>|
6019     -> LinkResult<()> {
6020        if window.is_empty() {
6021            return Ok(());
6022        }
6023        let missing = window
6024            .iter()
6025            .filter_map(|(path, file)| {
6026                let target = cache_dir.join(&file.sha256);
6027                match cached_blob_is_exact(&target, &file.sha256, file.bytes) {
6028                    Ok(true) => {
6029                        staged.insert(
6030                            (*path).clone(),
6031                            V2StagedFile {
6032                                path: (*path).clone(),
6033                                source: target,
6034                                sha256: file.sha256.clone(),
6035                                bytes: file.bytes,
6036                            },
6037                        );
6038                        None
6039                    }
6040                    Ok(false) => Some(Ok((*path, *file))),
6041                    Err(error) => Some(Err(error)),
6042                }
6043            })
6044            .collect::<LinkResult<Vec<_>>>()?;
6045        if !missing.is_empty() {
6046            for (path, bytes) in download_v2_bulk_stream(cfg, brain, pointer, &missing)? {
6047                let file = missing
6048                    .iter()
6049                    .find_map(|(expected_path, file)| (*expected_path == &path).then_some(*file))
6050                    .ok_or_else(|| invalid_feed("v2 stream returned an unrequested cache path"))?;
6051                let source = cache_v2_blob_bytes(&cache_dir, &file.sha256, file.bytes, &bytes)?;
6052                staged.insert(
6053                    path.clone(),
6054                    V2StagedFile {
6055                        path,
6056                        source,
6057                        sha256: file.sha256.clone(),
6058                        bytes: file.bytes,
6059                    },
6060                );
6061            }
6062        }
6063        window.clear();
6064        *window_bytes = 0;
6065        Ok(())
6066    };
6067    for &(path, file) in &pending {
6068        if file.bytes > V2_BULK_STREAM_CONTENT_BYTES {
6069            flush(&mut window, &mut window_bytes, &mut staged)?;
6070            direct.push((path, file));
6071            continue;
6072        }
6073        if window.len() == V2_BULK_STREAM_FILES
6074            || window_bytes.saturating_add(file.bytes) > V2_BULK_STREAM_CONTENT_BYTES
6075        {
6076            flush(&mut window, &mut window_bytes, &mut staged)?;
6077        }
6078        window.push((path, file));
6079        window_bytes += file.bytes;
6080    }
6081    flush(&mut window, &mut window_bytes, &mut staged)?;
6082    for item in prepare_v2_downloads(cfg, brain, pointer, &direct)? {
6083        let source =
6084            download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6085        staged.insert(
6086            item.path.clone(),
6087            V2StagedFile {
6088                path: item.path,
6089                source,
6090                sha256: item.sha256,
6091                bytes: item.bytes,
6092            },
6093        );
6094    }
6095    pending
6096        .into_iter()
6097        .map(|(path, _)| {
6098            staged
6099                .remove(path)
6100                .ok_or_else(|| invalid_feed("v2 download cache omitted a proven path"))
6101        })
6102        .collect()
6103}
6104
6105#[cfg(not(any(unix, windows)))]
6106fn stage_v2_blobs(
6107    _cfg: &HubConfig,
6108    _brain: &str,
6109    _pointer: &V2PointerBody,
6110    _pending: Vec<(&String, &V2BaselineFile)>,
6111) -> LinkResult<Vec<V2StagedFile>> {
6112    Err(LinkError::UnsupportedPlatform {
6113        operation: "resumable v2 download staging",
6114    })
6115}
6116
6117const V2_CONFLICT_BUNDLE_MAX: usize = 32;
6118const V2_CONFLICT_BUNDLE_TTL_SECS: u64 = 7 * 24 * 60 * 60;
6119const V2_CONFLICT_REMOTE_BYTES_MAX: u64 = 64 * 1024 * 1024;
6120
6121#[derive(Debug, Clone, Deserialize, Serialize)]
6122struct V2ConflictCoordinate {
6123    sha256: Option<String>,
6124    bytes: Option<u64>,
6125    file: Option<String>,
6126}
6127
6128#[derive(Debug, Clone, Deserialize, Serialize)]
6129struct V2ConflictFile {
6130    path: String,
6131    base: V2ConflictCoordinate,
6132    local: V2ConflictCoordinate,
6133    remote: V2ConflictCoordinate,
6134}
6135
6136#[derive(Debug, Clone, Deserialize, Serialize)]
6137struct V2ConflictPlan {
6138    v: u8,
6139    class: String,
6140    bundle: String,
6141    brain: String,
6142    origin: String,
6143    created_unix: u64,
6144    expires_unix: u64,
6145    base_seq: Option<u64>,
6146    base_commit: Option<String>,
6147    remote_seq: u64,
6148    remote_commit: Option<String>,
6149    remote_content_root: Option<String>,
6150    view_kind: String,
6151    view_revision: String,
6152    files: Vec<V2ConflictFile>,
6153}
6154
6155fn v2_take_remote_selection(
6156    files: &[V2ConflictFile],
6157    current: &std::collections::BTreeMap<String, V2BaselineFile>,
6158) -> LinkResult<(
6159    std::collections::BTreeMap<String, V2BaselineFile>,
6160    Vec<String>,
6161)> {
6162    let mut selected = std::collections::BTreeMap::new();
6163    let mut deleted = Vec::new();
6164    for file in files {
6165        match (&file.remote.sha256, file.remote.bytes) {
6166            (Some(sha256), Some(bytes)) => {
6167                let proven = current.get(&file.path).ok_or_else(|| {
6168                    invalid_feed("conflict remote coordinate disappeared from the exact head")
6169                })?;
6170                if proven.sha256 != *sha256 || proven.bytes != bytes {
6171                    return Err(invalid_feed(
6172                        "conflict remote coordinate differs from the exact head",
6173                    ));
6174                }
6175                if selected.insert(file.path.clone(), proven.clone()).is_some() {
6176                    return Err(invalid_feed("conflict plan repeats a remote coordinate"));
6177                }
6178            }
6179            (None, None) => {
6180                if current.contains_key(&file.path) {
6181                    return Err(invalid_feed(
6182                        "conflict remote deletion differs from the exact head",
6183                    ));
6184                }
6185                deleted.push(file.path.clone());
6186            }
6187            _ => return Err(invalid_feed("conflict remote coordinate is incomplete")),
6188        }
6189    }
6190    Ok((selected, deleted))
6191}
6192
6193fn v2_conflict_relative(bundle: &str, suffix: &str) -> PathBuf {
6194    PathBuf::from(".dbmd")
6195        .join("conflicts")
6196        .join(bundle)
6197        .join(suffix)
6198}
6199
6200fn read_historical_conflict_blob(
6201    cfg: &HubConfig,
6202    brain: &str,
6203    baseline: &V2SyncBaseline,
6204    path: &str,
6205    file: &V2BaselineFile,
6206) -> LinkResult<Option<Vec<u8>>> {
6207    let (Some(seq), Some(commit)) = (baseline.head_seq, baseline.commit_hash.as_deref()) else {
6208        return Ok(None);
6209    };
6210    if seq == 0 {
6211        return Ok(None);
6212    }
6213    let encoded_path: String = url::form_urlencoded::byte_serialize(path.as_bytes()).collect();
6214    let endpoint = format!(
6215        "/api/hub/brains/{brain}/v2/history/blob?seq={seq}&commit={commit}&path={encoded_path}&sha256={}",
6216        file.sha256
6217    );
6218    let raw = request_raw(cfg, "GET", &endpoint, None, Auth::Required, file.bytes)?;
6219    if raw.status == 404 || raw.status == 403 {
6220        return Ok(None);
6221    }
6222    let bytes = ensure_raw_ok(raw, "v2 conflict base")?;
6223    if bytes.len() as u64 != file.bytes || content_sha256(&bytes) != file.sha256 {
6224        return Err(invalid_feed(
6225            "v2 conflict base failed integrity verification",
6226        ));
6227    }
6228    Ok(Some(bytes))
6229}
6230
6231/// Persist a readable content conflict without modifying either side. The
6232/// plan is written last, so an interrupted bundle build is never actionable.
6233fn create_v2_conflict_bundle(
6234    cfg: &HubConfig,
6235    store: &Store,
6236    head: &V2VerifiedHead,
6237    baseline: Option<&V2SyncBaseline>,
6238    local: &std::collections::BTreeMap<String, (String, u64)>,
6239    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6240    paths: &[String],
6241) -> LinkResult<(String, Vec<String>)> {
6242    let conflicts_root = Path::new(".dbmd/conflicts");
6243    store.create_dir_all(conflicts_root)?;
6244    let completed = store
6245        .directory_names(conflicts_root)?
6246        .into_iter()
6247        .filter(|name| name.to_str().is_some_and(crate::ulid::is_ulid))
6248        .count();
6249    if completed >= V2_CONFLICT_BUNDLE_MAX {
6250        return Err(LinkError::InvalidPack {
6251            message: format!(
6252                "private conflict cache has {completed} bundles; resolve or prune one before syncing"
6253            ),
6254        });
6255    }
6256
6257    // A conflict bundle is diagnostic state, not a second whole-brain cache.
6258    // Materialize a deterministic prefix whose remote artifacts fit one
6259    // bounded window. Resolving it exposes the next prefix on the next sync.
6260    let mut selected_paths = Vec::new();
6261    let mut selected_remote_bytes = 0_u64;
6262    for path in paths {
6263        let bytes = remote.get(path).map_or(0, |file| file.bytes);
6264        if !selected_paths.is_empty()
6265            && selected_remote_bytes.saturating_add(bytes) > V2_CONFLICT_REMOTE_BYTES_MAX
6266        {
6267            break;
6268        }
6269        selected_remote_bytes = selected_remote_bytes.saturating_add(bytes);
6270        selected_paths.push(path.clone());
6271        if selected_remote_bytes >= V2_CONFLICT_REMOTE_BYTES_MAX {
6272            break;
6273        }
6274    }
6275    if selected_paths.is_empty() {
6276        return Err(invalid_feed("content conflict set is empty"));
6277    }
6278    let bundle = crate::ulid::mint();
6279    let bundle_root = v2_conflict_relative(&bundle, "");
6280    store.create_dir_all(&bundle_root.join("files"))?;
6281    let pointer = head.pointer.as_ref();
6282    let remote_bytes = match pointer {
6283        Some(pointer) => download_v2_blobs(
6284            cfg,
6285            &head.brain_id,
6286            pointer,
6287            selected_paths
6288                .iter()
6289                .filter_map(|path| {
6290                    remote
6291                        .get(path)
6292                        .filter(|file| file.bytes <= V2_CONFLICT_REMOTE_BYTES_MAX)
6293                        .map(|file| (path, file))
6294                })
6295                .collect(),
6296        )?
6297        .into_iter()
6298        .collect::<std::collections::BTreeMap<_, _>>(),
6299        None => std::collections::BTreeMap::new(),
6300    };
6301
6302    let mut files = Vec::with_capacity(selected_paths.len());
6303    for (index, path) in selected_paths.iter().enumerate() {
6304        let base_file = baseline.and_then(|state| state.files.get(path));
6305        let base_bytes = match (baseline, base_file) {
6306            (Some(state), Some(file)) => {
6307                read_historical_conflict_blob(cfg, &head.brain_id, state, path, file)?
6308            }
6309            _ => None,
6310        };
6311        let local_file = local.get(path);
6312        let remote_file = remote.get(path);
6313        let remote_content = remote_bytes.get(path);
6314        let prefix = format!("files/{index:04}");
6315        let base_name = base_bytes.as_ref().map(|_| format!("{prefix}.base"));
6316        let local_name = local_file.as_ref().map(|_| format!("{prefix}.local"));
6317        let remote_name = remote_content.as_ref().map(|_| format!("{prefix}.remote"));
6318        if let (Some(name), Some(bytes)) = (&base_name, &base_bytes) {
6319            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6320        }
6321        if let (Some(name), Some((expected_hash, expected_bytes))) = (&local_name, local_file) {
6322            let bytes = store.read_bounded(Path::new(path), *expected_bytes)?;
6323            if bytes.len() as u64 != *expected_bytes || content_sha256(&bytes) != *expected_hash {
6324                return Err(LinkError::InvalidPack {
6325                    message: format!("local conflict path `{path}` changed while bundling"),
6326                });
6327            }
6328            store.write_atomic_new(&v2_conflict_relative(&bundle, name), &bytes)?;
6329        }
6330        if let (Some(name), Some(bytes)) = (&remote_name, remote_content) {
6331            store.write_atomic_new(&v2_conflict_relative(&bundle, name), bytes)?;
6332        }
6333        files.push(V2ConflictFile {
6334            path: path.clone(),
6335            base: V2ConflictCoordinate {
6336                sha256: base_file.map(|file| file.sha256.clone()),
6337                bytes: base_file.map(|file| file.bytes),
6338                file: base_name,
6339            },
6340            local: V2ConflictCoordinate {
6341                sha256: local_file.map(|(sha256, _)| sha256.clone()),
6342                bytes: local_file.map(|(_, bytes)| *bytes),
6343                file: local_name,
6344            },
6345            remote: V2ConflictCoordinate {
6346                sha256: remote_file.map(|file| file.sha256.clone()),
6347                bytes: remote_file.map(|file| file.bytes),
6348                file: remote_name,
6349            },
6350        });
6351    }
6352    let now = SystemTime::now()
6353        .duration_since(UNIX_EPOCH)
6354        .unwrap_or_default()
6355        .as_secs();
6356    let plan = V2ConflictPlan {
6357        v: 2,
6358        class: "content_resolution_required".to_string(),
6359        bundle: bundle.clone(),
6360        brain: head.brain_id.clone(),
6361        origin: normalized_origin(&cfg.hub)?,
6362        created_unix: now,
6363        expires_unix: now.saturating_add(V2_CONFLICT_BUNDLE_TTL_SECS),
6364        base_seq: baseline.and_then(|state| state.head_seq),
6365        base_commit: baseline.and_then(|state| state.commit_hash.clone()),
6366        remote_seq: pointer.map_or(0, |value| value.seq),
6367        remote_commit: pointer.map(|value| value.commit_hash.clone()),
6368        remote_content_root: pointer.and_then(|value| value.content_root.clone()),
6369        view_kind: head.view_kind.clone(),
6370        view_revision: head.view_revision.clone(),
6371        files,
6372    };
6373    let mut bytes = serde_json::to_vec_pretty(&plan)
6374        .map_err(|_| invalid_feed("could not serialize v2 conflict plan"))?;
6375    bytes.push(b'\n');
6376    store.write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), &bytes)?;
6377    Ok((bundle, selected_paths))
6378}
6379
6380fn v2_sync_pull_with_resolution(
6381    cfg: &HubConfig,
6382    requested_brain: &str,
6383    expected_head: V2VerifiedHead,
6384    out: Option<&Path>,
6385    take_remote: Option<&std::collections::BTreeSet<String>>,
6386) -> LinkResult<V2PulledSnapshot> {
6387    let dest = out
6388        .map(Path::to_path_buf)
6389        .unwrap_or_else(|| PathBuf::from(requested_brain));
6390    let _operation_lock = lock_v2_sync_operation(cfg, &expected_head.brain_id)?;
6391    recover_v2_pull(cfg, &expected_head.brain_id, &dest)?;
6392    let head = v2_verified_head(cfg, requested_brain)?
6393        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
6394    if take_remote.is_some() && !same_v2_head(&expected_head, &head) {
6395        return Err(LinkError::RemoteAdvancedDuringSync);
6396    }
6397    let remote = files_for_v2_view(
6398        &head,
6399        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
6400    );
6401    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
6402    let baseline = load_v2_baseline(cfg, &head.brain_id, &dest)?;
6403    ensure_v2_view_compatible(&head, baseline.as_ref())?;
6404    let local_store = Store::open_strict(&dest).ok();
6405    // A baseline proves this path is an established checkout. Never reinterpret
6406    // a damaged/edited store marker as an empty clone destination: doing so
6407    // would let the pull half erase the local evidence before the push half can
6408    // reject it (most importantly for the generated scoped-view DB.md).
6409    ensure_established_v2_checkout_opened(&head, baseline.as_ref(), local_store.is_some())?;
6410    let mut local_view = local_store.as_ref().map(v2_local_files).transpose()?;
6411    if head.view_kind == "scoped" && baseline.is_none() && local_view.is_some() {
6412        return Err(LinkError::ScopedViewChanged);
6413    }
6414    if let Some(view) = local_view.as_mut() {
6415        remove_scoped_projection(&head, baseline.as_ref(), view)?;
6416    }
6417    let empty_local = std::collections::BTreeMap::new();
6418    let local = local_view
6419        .as_ref()
6420        .map_or(&empty_local, |view| &view.riding);
6421    let kept_home = |path: &str| {
6422        local_view
6423            .as_ref()
6424            .is_some_and(|view| view.policy.keeps_home(path))
6425    };
6426    let empty_base = std::collections::BTreeMap::new();
6427    let base = baseline.as_ref().map_or(&empty_base, |state| &state.files);
6428    let empty_base_assets = std::collections::BTreeMap::new();
6429    let base_assets = baseline
6430        .as_ref()
6431        .map_or(&empty_base_assets, |state| &state.assets);
6432    let mut local_assets = local_store
6433        .as_ref()
6434        .map(v2_local_asset_records)
6435        .transpose()?
6436        .unwrap_or_default();
6437    let mut content_merge = merge_v2_pulled_records(
6438        base,
6439        &remote,
6440        local,
6441        |file, _| (file.sha256.clone(), file.bytes),
6442        |file, _| (file.sha256.clone(), file.bytes),
6443        kept_home,
6444    );
6445    if let Some(selected) = take_remote {
6446        for path in selected {
6447            if let Some(position) = content_merge
6448                .conflicts
6449                .iter()
6450                .position(|conflict| conflict == path)
6451            {
6452                content_merge.conflicts.remove(position);
6453                content_merge.accept_remote.insert(path.clone());
6454                match remote.get(path) {
6455                    Some(file) => {
6456                        content_merge
6457                            .records
6458                            .insert(path.clone(), (file.sha256.clone(), file.bytes));
6459                    }
6460                    None => {
6461                        content_merge.records.remove(path);
6462                    }
6463                }
6464            } else if !content_merge.accept_remote.contains(path) {
6465                return Err(LinkError::InvalidPack {
6466                    message: format!(
6467                        "take-remote path `{path}` is no longer at its conflict coordinate"
6468                    ),
6469                });
6470            }
6471        }
6472    }
6473    if !content_merge.conflicts.is_empty() {
6474        let mut conflicts = content_merge.conflicts.clone();
6475        conflicts.truncate(100);
6476        if let Some(store) = local_store.as_ref() {
6477            let (bundle, paths) = create_v2_conflict_bundle(
6478                cfg,
6479                store,
6480                &head,
6481                baseline.as_ref(),
6482                local,
6483                &remote,
6484                &conflicts,
6485            )?;
6486            return Err(LinkError::ConflictBundle { bundle, paths });
6487        }
6488        return Err(LinkError::Conflict { paths: conflicts });
6489    }
6490    let asset_merge = merge_v2_pulled_records(
6491        base_assets,
6492        &remote_assets,
6493        &local_assets,
6494        v2_asset_record,
6495        v2_asset_record,
6496        |_| false,
6497    );
6498    if !asset_merge.conflicts.is_empty() {
6499        let mut conflicts = asset_merge.conflicts.clone();
6500        conflicts.truncate(100);
6501        return Err(LinkError::Conflict { paths: conflicts });
6502    }
6503    let pointer = head.pointer.as_ref();
6504    let cache_transaction = pointer.map_or_else(
6505        || content_sha256(format!("empty\0{}", head.view_revision).as_bytes()),
6506        |value| value.commit_hash.clone(),
6507    );
6508    let cache_dir = v2_download_cache_dir_for(cfg, &head.brain_id, &cache_transaction)?;
6509    let mut changed = match pointer {
6510        Some(pointer) => stage_v2_blobs(
6511            cfg,
6512            &head.brain_id,
6513            pointer,
6514            remote
6515                .iter()
6516                .filter(|(path, file)| {
6517                    content_merge.accept_remote.contains(*path)
6518                        && local.get(*path).map(|value| value.0.as_str())
6519                            != Some(file.sha256.as_str())
6520                })
6521                .collect(),
6522        )?,
6523        None => Vec::new(),
6524    };
6525    let mut deleted = content_merge
6526        .accept_remote
6527        .iter()
6528        .filter(|path| !remote.contains_key(*path) && local.contains_key(*path))
6529        .cloned()
6530        .collect::<Vec<_>>();
6531    if local_assets != asset_merge.records {
6532        if asset_merge.records.is_empty() {
6533            deleted.push("assets.jsonl".to_string());
6534        } else {
6535            let bytes = v2_asset_record_manifest_bytes(&asset_merge.records)?;
6536            let sha256 = content_sha256(&bytes);
6537            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6538            changed.push(V2StagedFile {
6539                path: "assets.jsonl".to_string(),
6540                source,
6541                sha256,
6542                bytes: bytes.len() as u64,
6543            });
6544        }
6545    }
6546    if let Some(pointer) = pointer {
6547        let mut pending_assets = Vec::new();
6548        for (path, asset) in &remote_assets {
6549            if asset.disposition != "hosted"
6550                || kept_home(path)
6551                || !asset_merge.accept_remote.contains(path)
6552            {
6553                continue;
6554            }
6555            let already_current = local_store.as_ref().is_some_and(|store| {
6556                matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6557                    && store
6558                        .read_bounded(Path::new(path), asset.bytes)
6559                        .ok()
6560                        .is_some_and(|bytes| {
6561                            bytes.len() as u64 == asset.bytes
6562                                && content_sha256(&bytes) == asset.blob_sha256
6563                        })
6564            });
6565            if !already_current {
6566                pending_assets.push((path, asset));
6567            }
6568        }
6569        for item in prepare_v2_asset_downloads(cfg, &head.brain_id, pointer, &pending_assets)? {
6570            let source =
6571                download_presigned_to_cache(cfg, &item.url, &cache_dir, &item.sha256, item.bytes)?;
6572            changed.push(V2StagedFile {
6573                path: item.path,
6574                source,
6575                sha256: item.sha256,
6576                bytes: item.bytes,
6577            });
6578        }
6579    }
6580    for (path, prior) in base_assets {
6581        if remote_assets.contains_key(path)
6582            || kept_home(path)
6583            || !asset_merge.accept_remote.contains(path)
6584        {
6585            continue;
6586        }
6587        let unchanged = local_store.as_ref().is_some_and(|store| {
6588            matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6589                && store
6590                    .read_bounded(Path::new(path), prior.bytes)
6591                    .ok()
6592                    .is_some_and(|bytes| content_sha256(&bytes) == prior.blob_sha256)
6593        });
6594        if unchanged {
6595            deleted.push(path.clone());
6596        }
6597    }
6598    let extra_local = content_merge
6599        .records
6600        .keys()
6601        .filter(|path| !remote.contains_key(*path))
6602        .cloned()
6603        .collect::<Vec<_>>();
6604    if head.view_kind == "scoped" {
6605        for (path, bytes) in [
6606            ("DB.md".to_string(), scoped_projection_bytes(&head.brain_id)),
6607            (
6608                ".dbmd/view.json".to_string(),
6609                scoped_view_metadata(&head, remote.len())?,
6610            ),
6611        ] {
6612            let sha256 = content_sha256(&bytes);
6613            let source = cache_v2_blob_bytes(&cache_dir, &sha256, bytes.len() as u64, &bytes)?;
6614            changed.push(V2StagedFile {
6615                path,
6616                source,
6617                sha256,
6618                bytes: bytes.len() as u64,
6619            });
6620        }
6621    }
6622    let install_changed = !changed.is_empty() || !deleted.is_empty();
6623    install_pulled_delta_sources(&dest, &changed, &deleted, true, baseline.as_ref(), &head)?;
6624    let finalized = (|| -> LinkResult<(bool, V2LocalView, std::collections::BTreeMap<String, crate::AssetRecord>)> {
6625        let installed_store =
6626            Store::open_strict(&dest).map_err(|error| LinkError::InvalidPack {
6627                message: format!("installed v2 checkout is not a valid db.md store: {error}"),
6628            })?;
6629        let installed_local = if install_changed {
6630            let mut scanned = v2_local_files(&installed_store)?;
6631            remove_scoped_projection(&head, baseline.as_ref(), &mut scanned)?;
6632            scanned
6633        } else {
6634            local_view
6635                .take()
6636                .ok_or_else(|| invalid_feed("an unchanged pull has no installed local view"))?
6637        };
6638        if installed_local.riding != content_merge.records {
6639            return Err(LinkError::InvalidPack {
6640                message: "local content changed while installing the v2 pull".to_string(),
6641            });
6642        }
6643        let installed_assets = if install_changed {
6644            v2_local_asset_records(&installed_store)?
6645        } else {
6646            std::mem::take(&mut local_assets)
6647        };
6648        if installed_assets != asset_merge.records {
6649            return Err(LinkError::InvalidPack {
6650                message: "local assets changed while installing the v2 pull".to_string(),
6651            });
6652        }
6653        let local_dirty = !v2_riding_matches_remote(&installed_local.riding, &remote, |path| {
6654            installed_local.policy.keeps_home(path)
6655        })
6656            || !v2_asset_records_match_remote(&installed_assets, &remote_assets);
6657        let final_head = v2_verified_head(cfg, requested_brain)?
6658            .ok_or_else(|| invalid_feed("v2 head disappeared during pull"))?;
6659        if !same_v2_head(&head, &final_head) {
6660            return Err(LinkError::RemoteAdvancedDuringSync);
6661        }
6662        accept_v2_head(cfg, &final_head)?;
6663        save_v2_baseline(
6664            cfg,
6665            &head.brain_id,
6666            &dest,
6667            &v2_baseline_from_head(
6668                cfg,
6669                &head,
6670                remote.clone(),
6671                remote_assets.clone(),
6672                Some(&installed_local),
6673                baseline
6674                    .as_ref()
6675                    .and_then(|current| current.checkout_id.as_deref()),
6676            )?,
6677        )?;
6678        complete_v2_pull(&dest)?;
6679        Ok((local_dirty, installed_local, installed_assets))
6680    })();
6681    let (local_dirty, installed_local, installed_assets) = match finalized {
6682        Ok(value) => value,
6683        Err(error) => {
6684            if let Err(recovery) = recover_v2_pull(cfg, &head.brain_id, &dest) {
6685                return Err(LinkError::InvalidPack {
6686                    message: format!("{error}; durable pull recovery also failed: {recovery}"),
6687                });
6688            }
6689            return Err(error);
6690        }
6691    };
6692    cleanup_v2_download_cache(cfg, &head.brain_id, &cache_transaction);
6693    let report = PullReport {
6694        brain: head.brain_id.clone(),
6695        slug: requested_brain.to_string(),
6696        head_seq: pointer.map_or(0, |value| value.seq),
6697        files: remote.len() + remote_assets.len(),
6698        dest: dest.to_string_lossy().into_owned(),
6699        extra_local,
6700        sync_status: if local_dirty {
6701            "local_dirty_after_install".to_string()
6702        } else {
6703            "synced".to_string()
6704        },
6705    };
6706    Ok(V2PulledSnapshot {
6707        report,
6708        head,
6709        files: remote,
6710        assets: remote_assets,
6711        local: installed_local,
6712        local_assets: installed_assets,
6713    })
6714}
6715
6716fn v2_sync_pull(
6717    cfg: &HubConfig,
6718    requested_brain: &str,
6719    head: V2VerifiedHead,
6720    out: Option<&Path>,
6721) -> LinkResult<PullReport> {
6722    Ok(v2_sync_pull_with_resolution(cfg, requested_brain, head, out, None)?.report)
6723}
6724
6725fn v2_expected(remote: Option<&V2BaselineFile>) -> Value {
6726    match remote {
6727        Some(file) => json!({ "kind": "blob", "hash": file.sha256 }),
6728        None => json!({ "kind": "absent" }),
6729    }
6730}
6731
6732fn v2_asset_expected(remote: Option<&V2BaselineAsset>) -> Value {
6733    match remote {
6734        Some(asset) => json!({ "kind": "asset", "hash": asset.leaf_hash }),
6735        None => json!({ "kind": "absent" }),
6736    }
6737}
6738
6739fn v2_content_withdrawal_operation(
6740    store: &Store,
6741    local_view: &V2LocalView,
6742    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
6743    path: &str,
6744    reason: &str,
6745) -> LinkResult<Value> {
6746    if (!(path.starts_with("records/") || path.starts_with("sources/")) || !path.ends_with(".md"))
6747        || path == "DB.md"
6748    {
6749        return Err(LinkError::InvalidPack {
6750            message: format!("content withdrawal path `{path}` is not a record or source"),
6751        });
6752    }
6753    if !local_view.policy.keeps_home(path)
6754        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6755    {
6756        return Err(LinkError::InvalidPack {
6757            message: format!(
6758                "withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6759            ),
6760        });
6761    }
6762    let current = remote.get(path).ok_or_else(|| LinkError::InvalidPack {
6763        message: format!("withdrawal path `{path}` has no readable hosted coordinate"),
6764    })?;
6765    verify_v2_upload_source(store, path, &current.sha256, current.bytes)?;
6766    Ok(json!({
6767        "op": "withdraw_from_hosting",
6768        "path": path,
6769        "expected": { "kind": "blob", "hash": current.sha256 },
6770        "reason": reason,
6771    }))
6772}
6773
6774fn v2_asset_withdrawal_operation(
6775    store: &Store,
6776    local_view: &V2LocalView,
6777    path: &str,
6778    local: &crate::AssetRecord,
6779    current: &V2BaselineAsset,
6780    reason: &str,
6781) -> LinkResult<Value> {
6782    if !local_view.policy.keeps_home(path)
6783        || !matches!(store.regular_file_exists(Path::new(path)), Ok(true))
6784    {
6785        return Err(LinkError::InvalidPack {
6786            message: format!(
6787                "asset withdrawal path `{path}` must be an existing regular file kept home by .sevralocal"
6788            ),
6789        });
6790    }
6791    verify_v2_upload_source(store, path, &local.sha256, local.bytes)?;
6792    if current.disposition != "hosted" || v2_asset_record(current, path) != *local {
6793        return Err(LinkError::InvalidPack {
6794            message: format!(
6795                "asset withdrawal path `{path}` must exactly match its currently hosted signed leaf"
6796            ),
6797        });
6798    }
6799    Ok(json!({
6800        "op": "asset_withdraw",
6801        "path": path,
6802        "expected": v2_asset_expected(Some(current)),
6803        "reason": reason,
6804    }))
6805}
6806
6807// A source is immutable, but its exact bytes may move from an intake namespace
6808// to an archive namespace under the distinct `promote_source` capability. A
6809// plain filesystem move appears to sync as one absent old path plus one new
6810// path. Preserve that intent as a protocol rename only when the hash has one
6811// unambiguous source delete and one unambiguous absent-destination source put.
6812// Ambiguous duplicate bytes stay as put/delete and fail closed at the hub.
6813fn infer_exact_source_promotions(operations: Vec<Value>) -> Vec<Value> {
6814    let mut deletes = std::collections::BTreeMap::<String, Vec<(usize, String)>>::new();
6815    let mut puts = std::collections::BTreeMap::<String, Vec<(usize, String, u64)>>::new();
6816    for (index, operation) in operations.iter().enumerate() {
6817        match operation.get("op").and_then(Value::as_str) {
6818            Some("delete") => {
6819                let Some(path) = operation.get("path").and_then(Value::as_str) else {
6820                    continue;
6821                };
6822                let Some(hash) = operation
6823                    .get("expected")
6824                    .and_then(|value| value.get("hash"))
6825                    .and_then(Value::as_str)
6826                else {
6827                    continue;
6828                };
6829                if path.starts_with("sources/") {
6830                    deletes
6831                        .entry(hash.to_string())
6832                        .or_default()
6833                        .push((index, path.to_string()));
6834                }
6835            }
6836            Some("put") => {
6837                let Some(path) = operation.get("path").and_then(Value::as_str) else {
6838                    continue;
6839                };
6840                let Some(hash) = operation.get("blob").and_then(Value::as_str) else {
6841                    continue;
6842                };
6843                let Some(bytes) = operation.get("bytes").and_then(Value::as_u64) else {
6844                    continue;
6845                };
6846                let destination_absent = operation
6847                    .get("expected")
6848                    .and_then(|value| value.get("kind"))
6849                    .and_then(Value::as_str)
6850                    == Some("absent");
6851                if path.starts_with("sources/") && destination_absent {
6852                    puts.entry(hash.to_string()).or_default().push((
6853                        index,
6854                        path.to_string(),
6855                        bytes,
6856                    ));
6857                }
6858            }
6859            _ => {}
6860        }
6861    }
6862    let mut rename_at = std::collections::BTreeMap::<usize, Value>::new();
6863    let mut consumed_puts = std::collections::BTreeSet::<usize>::new();
6864    for (hash, source) in deletes {
6865        let Some(destination) = puts.get(&hash) else {
6866            continue;
6867        };
6868        if source.len() != 1 || destination.len() != 1 {
6869            continue;
6870        }
6871        let (delete_index, from) = &source[0];
6872        let (put_index, to, bytes) = &destination[0];
6873        if from == to {
6874            continue;
6875        }
6876        rename_at.insert(
6877            *delete_index,
6878            json!({
6879                "op": "rename",
6880                "from": from,
6881                "to": to,
6882                "expected_from": { "kind": "blob", "hash": hash },
6883                "expected_to": { "kind": "absent" },
6884                "blob": hash,
6885                "bytes": bytes,
6886            }),
6887        );
6888        consumed_puts.insert(*put_index);
6889    }
6890    operations
6891        .into_iter()
6892        .enumerate()
6893        .filter_map(|(index, operation)| {
6894            if let Some(rename) = rename_at.remove(&index) {
6895                Some(rename)
6896            } else if consumed_puts.contains(&index) {
6897                None
6898            } else {
6899                Some(operation)
6900            }
6901        })
6902        .collect()
6903}
6904
6905fn v2_asset_value(record: &crate::AssetRecord, disposition: &str) -> Value {
6906    json!({
6907        "blob_sha256": record.sha256,
6908        "bytes": record.bytes,
6909        "media_type": record.media_type,
6910        "wrappers": record.wrappers,
6911        "required": record.required,
6912        "disposition": disposition,
6913    })
6914}
6915
6916/// Apply the exact operation subset generated by this client to an already
6917/// verified manifest. This avoids downloading the same 50k-path listing again
6918/// after an unrebased commit; a rebased response still re-reads the manifest.
6919fn apply_generated_v2_operations(
6920    operations: &[Value],
6921    local_assets: &std::collections::BTreeMap<String, crate::AssetRecord>,
6922    candidate: &mut std::collections::BTreeMap<String, V2BaselineFile>,
6923    candidate_assets: &mut std::collections::BTreeMap<String, V2BaselineAsset>,
6924) -> LinkResult<bool> {
6925    let mut asset_changed = false;
6926    for operation in operations {
6927        match operation.get("op").and_then(Value::as_str) {
6928            Some("put") => {
6929                let path = operation
6930                    .get("path")
6931                    .and_then(Value::as_str)
6932                    .ok_or_else(|| invalid_feed("v2 put has no path"))?;
6933                let sha256 = operation
6934                    .get("blob")
6935                    .and_then(Value::as_str)
6936                    .ok_or_else(|| invalid_feed("v2 put has no blob"))?;
6937                let bytes = operation
6938                    .get("bytes")
6939                    .and_then(Value::as_u64)
6940                    .ok_or_else(|| invalid_feed("v2 put has no byte count"))?;
6941                candidate.insert(
6942                    path.to_string(),
6943                    V2BaselineFile {
6944                        sha256: sha256.to_string(),
6945                        bytes,
6946                        proof: None,
6947                    },
6948                );
6949            }
6950            Some("rename") => {
6951                let from = operation
6952                    .get("from")
6953                    .and_then(Value::as_str)
6954                    .ok_or_else(|| invalid_feed("v2 rename has no source path"))?;
6955                let to = operation
6956                    .get("to")
6957                    .and_then(Value::as_str)
6958                    .ok_or_else(|| invalid_feed("v2 rename has no destination path"))?;
6959                let sha256 = operation
6960                    .get("blob")
6961                    .and_then(Value::as_str)
6962                    .ok_or_else(|| invalid_feed("v2 rename has no blob"))?;
6963                let bytes = operation
6964                    .get("bytes")
6965                    .and_then(Value::as_u64)
6966                    .ok_or_else(|| invalid_feed("v2 rename has no byte count"))?;
6967                let expected_from = operation
6968                    .get("expected_from")
6969                    .and_then(|expected| expected.get("hash"))
6970                    .and_then(Value::as_str);
6971                let expected_to_absent = operation
6972                    .get("expected_to")
6973                    .and_then(|expected| expected.get("kind"))
6974                    .and_then(Value::as_str)
6975                    == Some("absent");
6976                if from == to
6977                    || !from.starts_with("sources/")
6978                    || !to.starts_with("sources/")
6979                    || expected_from != Some(sha256)
6980                    || !expected_to_absent
6981                    || candidate.contains_key(to)
6982                {
6983                    return Err(invalid_feed("generated v2 source rename is malformed"));
6984                }
6985                let source = candidate
6986                    .remove(from)
6987                    .ok_or_else(|| invalid_feed("v2 rename source is absent"))?;
6988                if source.sha256 != sha256 || source.bytes != bytes {
6989                    return Err(invalid_feed(
6990                        "v2 rename source differs from its exact-byte claim",
6991                    ));
6992                }
6993                candidate.insert(
6994                    to.to_string(),
6995                    V2BaselineFile {
6996                        sha256: sha256.to_string(),
6997                        bytes,
6998                        proof: None,
6999                    },
7000                );
7001            }
7002            Some("delete" | "withdraw_from_hosting") => {
7003                let path = operation
7004                    .get("path")
7005                    .and_then(Value::as_str)
7006                    .ok_or_else(|| invalid_feed("v2 delete has no path"))?;
7007                candidate.remove(path);
7008            }
7009            Some("asset_delete") => {
7010                let path = operation
7011                    .get("path")
7012                    .and_then(Value::as_str)
7013                    .ok_or_else(|| invalid_feed("v2 asset delete has no path"))?;
7014                candidate_assets.remove(path);
7015                asset_changed = true;
7016            }
7017            Some("asset_put" | "asset_resume" | "asset_withdraw") => {
7018                let path = operation
7019                    .get("path")
7020                    .and_then(Value::as_str)
7021                    .ok_or_else(|| invalid_feed("v2 asset write has no path"))?;
7022                let record = local_assets
7023                    .get(path)
7024                    .ok_or_else(|| invalid_feed("v2 asset write has no local record"))?;
7025                let disposition =
7026                    if operation.get("op").and_then(Value::as_str) == Some("asset_withdraw") {
7027                        "withheld"
7028                    } else {
7029                        operation
7030                            .get("asset")
7031                            .and_then(|asset| asset.get("disposition"))
7032                            .and_then(Value::as_str)
7033                            .ok_or_else(|| invalid_feed("v2 asset write has no disposition"))?
7034                    };
7035                candidate_assets.insert(
7036                    path.to_string(),
7037                    V2BaselineAsset {
7038                        blob_sha256: record.sha256.clone(),
7039                        bytes: record.bytes,
7040                        media_type: record.media_type.clone(),
7041                        wrappers: record.wrappers.clone(),
7042                        required: record.required,
7043                        disposition: disposition.to_string(),
7044                        // The server assigns the signed leaf address. Callers
7045                        // that need an exact asset manifest re-read it below.
7046                        leaf_hash: String::new(),
7047                    },
7048                );
7049                asset_changed = true;
7050            }
7051            _ => return Err(invalid_feed("dbmd generated an unsupported v2 operation")),
7052        }
7053    }
7054    Ok(asset_changed)
7055}
7056
7057fn v2_riding_matches_remote(
7058    local: &std::collections::BTreeMap<String, (String, u64)>,
7059    remote: &std::collections::BTreeMap<String, V2BaselineFile>,
7060    keeps_home: impl Fn(&str) -> bool,
7061) -> bool {
7062    remote.iter().all(|(path, file)| {
7063        keeps_home(path)
7064            || local.get(path).map(|value| value.0.as_str()) == Some(file.sha256.as_str())
7065    }) && local.iter().all(|(path, (hash, _))| {
7066        remote.get(path).map(|file| file.sha256.as_str()) == Some(hash.as_str())
7067    })
7068}
7069
7070#[derive(Debug, Clone)]
7071struct V2ResolutionOverride {
7072    expected_remote: Option<String>,
7073    selected_local: Option<String>,
7074}
7075
7076#[derive(Debug, Clone)]
7077struct V2UploadSource {
7078    path: String,
7079    bytes: u64,
7080}
7081
7082struct V2SyncPushOptions<'a> {
7083    resume_local_policy: bool,
7084    bulk_confirmation: Option<&'a V2BulkConfirmation>,
7085    resolution: Option<&'a std::collections::BTreeMap<String, V2ResolutionOverride>>,
7086    pulled: Option<V2PulledSnapshot>,
7087    withdrawal_paths: &'a [String],
7088    withdrawal_reason: Option<&'a str>,
7089}
7090
7091fn verify_v2_upload_source(
7092    store: &Store,
7093    path: &str,
7094    sha256: &str,
7095    expected_bytes: u64,
7096) -> LinkResult<()> {
7097    let file = store.open_regular(Path::new(path))?;
7098    if file.metadata()?.len() != expected_bytes || content_sha256_reader(file)? != sha256 {
7099        return Err(LinkError::InvalidPack {
7100            message: format!("local path `{path}` changed during sync planning"),
7101        });
7102    }
7103    Ok(())
7104}
7105
7106/// One reserved object waiting to be staged, held until its whole batch can be
7107/// uploaded together.
7108struct V2PendingUpload<'a> {
7109    url: String,
7110    headers: Value,
7111    sha256: String,
7112    source: &'a V2UploadSource,
7113}
7114
7115/// How many objects a migration stages at once. Bounded so a first push does
7116/// not behave like a burst against the object store, while still being far
7117/// faster than one-at-a-time.
7118/// Workers staging one batch. With a shared pooled connection the cost of an
7119/// object is the PUT itself rather than a handshake, so a wider pool converts
7120/// directly into throughput on a migration.
7121const V2_UPLOAD_CONCURRENCY: usize = 16;
7122
7123/// Stage a reserved batch, uploading up to `V2_UPLOAD_CONCURRENCY` objects at a
7124/// time and verifying each against the store after it lands. The first failure
7125/// is returned and the remaining work is abandoned.
7126fn upload_v2_batch_concurrently(
7127    cfg: &HubConfig,
7128    store: &Store,
7129    pending: &[V2PendingUpload<'_>],
7130) -> LinkResult<()> {
7131    if pending.is_empty() {
7132        return Ok(());
7133    }
7134    let urls = pending
7135        .iter()
7136        .map(|task| task.url.as_str())
7137        .collect::<Vec<_>>();
7138    let shared = shared_staging_agent(cfg, &urls);
7139    if pending.len() == 1 {
7140        let task = &pending[0];
7141        put_presigned_source(
7142            cfg,
7143            &task.url,
7144            &task.headers,
7145            store,
7146            task.source,
7147            shared.as_ref(),
7148        )?;
7149        return verify_v2_upload_source(store, &task.source.path, &task.sha256, task.source.bytes);
7150    }
7151    let next = std::sync::atomic::AtomicUsize::new(0);
7152    let failure: std::sync::Mutex<Option<LinkError>> = std::sync::Mutex::new(None);
7153    let workers = V2_UPLOAD_CONCURRENCY.min(pending.len());
7154    std::thread::scope(|scope| {
7155        for _ in 0..workers {
7156            scope.spawn(|| loop {
7157                if failure.lock().map(|guard| guard.is_some()).unwrap_or(true) {
7158                    return;
7159                }
7160                let index = next.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
7161                let Some(task) = pending.get(index) else {
7162                    return;
7163                };
7164                let outcome = put_presigned_source(
7165                    cfg,
7166                    &task.url,
7167                    &task.headers,
7168                    store,
7169                    task.source,
7170                    shared.as_ref(),
7171                )
7172                .and_then(|()| {
7173                    verify_v2_upload_source(
7174                        store,
7175                        &task.source.path,
7176                        &task.sha256,
7177                        task.source.bytes,
7178                    )
7179                });
7180                if let Err(error) = outcome {
7181                    if let Ok(mut guard) = failure.lock() {
7182                        guard.get_or_insert(error);
7183                    }
7184                    return;
7185                }
7186            });
7187        }
7188    });
7189    match failure.into_inner() {
7190        Ok(Some(error)) => Err(error),
7191        Ok(None) => Ok(()),
7192        Err(_) => Err(invalid_feed("v2 upload worker state was poisoned")),
7193    }
7194}
7195
7196fn put_presigned_source(
7197    cfg: &HubConfig,
7198    raw: &str,
7199    headers: &Value,
7200    store: &Store,
7201    source: &V2UploadSource,
7202    shared: Option<&ureq::Agent>,
7203) -> LinkResult<()> {
7204    put_presigned_source_with_budget(
7205        cfg,
7206        raw,
7207        headers,
7208        store,
7209        source,
7210        shared,
7211        std::time::Duration::from_secs(UPLOAD_TOTAL_TIMEOUT_SECS),
7212    )
7213}
7214
7215fn put_presigned_source_with_budget(
7216    cfg: &HubConfig,
7217    raw: &str,
7218    headers: &Value,
7219    store: &Store,
7220    source: &V2UploadSource,
7221    shared: Option<&ureq::Agent>,
7222    total_budget: std::time::Duration,
7223) -> LinkResult<()> {
7224    // The URL's own safety checks run either way; only the connection pool is
7225    // shared, so a batch never trades a check for a warm socket.
7226    let owned = match shared {
7227        Some(_) => {
7228            checked_presigned_url(cfg, raw)?;
7229            None
7230        }
7231        None => Some(presigned_agent(cfg, raw)?),
7232    };
7233    let http = shared.unwrap_or_else(|| owned.as_ref().expect("an agent for this upload"));
7234    let deadline = std::time::Instant::now()
7235        .checked_add(total_budget)
7236        .ok_or_else(upload_deadline_error)?;
7237    let mut attempt = 0;
7238    let result = loop {
7239        let file = store.open_regular(Path::new(&source.path))?;
7240        if file.metadata()?.len() != source.bytes {
7241            return Err(LinkError::InvalidPack {
7242                message: format!("local path `{}` changed before upload", source.path),
7243            });
7244        }
7245        // The reservation already states content-length, lowercased. The HTTP
7246        // client de-duplicates headers by exact name, not case, so setting
7247        // `Content-Length` here as well leaves both on the wire and an edge
7248        // rejects the request as malformed. Supply it only when absent.
7249        let mut req = http.put(raw).timeout(upload_attempt_timeout(deadline)?);
7250        let mut has_content_length = false;
7251        if let Some(map) = headers.as_object() {
7252            for (name, value) in map {
7253                if let Some(value) = value.as_str() {
7254                    has_content_length |= name.eq_ignore_ascii_case("content-length");
7255                    req = req.set(name, value);
7256                }
7257            }
7258        }
7259        if !has_content_length {
7260            req = req.set("Content-Length", &source.bytes.to_string());
7261        }
7262        match req.send(file) {
7263            // Every transport failure is retryable here, not only the
7264            // pre-request kinds: staging an object is idempotent (fixed content
7265            // address, exact length and checksum, write-once precondition), the
7266            // file is reopened on each attempt, and a migration holds
7267            // connections long enough that a mid-request drop is ordinary.
7268            Err(ureq::Error::Transport(_)) if wait_for_upload_retry(deadline, attempt) => {
7269                attempt += 1;
7270            }
7271            // A migration stages tens of thousands of immutable objects, and an
7272            // edge or object store may refuse a single one under sustained
7273            // load. The upload is content-addressed and precondition-guarded,
7274            // so replaying it is safe; failing the whole push on one refusal is
7275            // not. 412 already means "someone else stored these exact bytes".
7276            Err(ureq::Error::Status(status, _))
7277                if status != 412
7278                    && is_retryable_upload_status(status)
7279                    && wait_for_upload_retry(deadline, attempt) =>
7280            {
7281                attempt += 1;
7282            }
7283            result => break result,
7284        }
7285    };
7286    match result {
7287        Ok(response) if (200..300).contains(&response.status()) => {
7288            drain_presigned_response(response);
7289            Ok(())
7290        }
7291        Ok(response) => {
7292            // The object store states its own reason (checksum mismatch,
7293            // precondition, expiry). Carrying a bounded excerpt turns an
7294            // unactionable refusal into one an operator can diagnose; the
7295            // coordinate is the store path, never file content.
7296            let status = response.status();
7297            let detail = response
7298                .into_string()
7299                .ok()
7300                .map(|body| body.chars().take(400).collect::<String>())
7301                .filter(|body| !body.trim().is_empty());
7302            Err(LinkError::Http {
7303                what: "v2 changed-byte upload",
7304                status,
7305                message: match detail {
7306                    Some(body) => format!(
7307                        "object store rejected the upload of `{}`: {}",
7308                        source.path,
7309                        body.replace('\n', " ")
7310                    ),
7311                    None => format!("object store rejected the upload of `{}`", source.path),
7312                },
7313                code: None,
7314                details: None,
7315            })
7316        }
7317        Err(error) => match error {
7318            ureq::Error::Status(412, _) => Ok(()),
7319            ureq::Error::Status(_, response) => {
7320                let status = response.status();
7321                let detail = response
7322                    .into_string()
7323                    .ok()
7324                    .map(|body| body.chars().take(400).collect::<String>())
7325                    .filter(|body| !body.trim().is_empty());
7326                Err(LinkError::Http {
7327                    what: "v2 changed-byte upload",
7328                    status,
7329                    message: match detail {
7330                        Some(body) => format!(
7331                            "object store rejected the upload of `{}`: {}",
7332                            source.path,
7333                            body.replace('\n', " ")
7334                        ),
7335                        None => {
7336                            format!("object store rejected the upload of `{}`", source.path)
7337                        }
7338                    },
7339                    code: None,
7340                    details: None,
7341                })
7342            }
7343            ureq::Error::Transport(error) => Err(object_store_transport_error(error)),
7344        },
7345    }
7346}
7347
7348/// The request as it is hashed and signed. A staged push carries its operations
7349/// by address rather than inline, so the signed view restores them: the request
7350/// hash covers the change itself, never the transport that carried it.
7351fn v2_signed_request_view(body: &Value, operations: &[Value]) -> Value {
7352    if body.get("operations").is_some() {
7353        return body.clone();
7354    }
7355    let mut value = body.clone();
7356    if let Some(map) = value.as_object_mut() {
7357        map.remove("staged_change");
7358        map.insert("operations".to_string(), Value::Array(operations.to_vec()));
7359    }
7360    value
7361}
7362
7363/// Open one staging window, waiting out a busy hub rather than abandoning the
7364/// push. The call reserves capacity and mints URLs; it commits nothing, so
7365/// asking again after a refusal costs only the wait.
7366fn reserve_upload_window(
7367    cfg: &HubConfig,
7368    path: &str,
7369    body: &Value,
7370    what: &'static str,
7371) -> LinkResult<Value> {
7372    let mut attempt = 0;
7373    loop {
7374        let pause = |attempt: usize| {
7375            std::thread::sleep(std::time::Duration::from_millis(
7376                RESERVATION_BACKOFF_MS[attempt.min(RESERVATION_BACKOFF_MS.len() - 1)],
7377            ));
7378        };
7379        match request(cfg, "POST", path, Some(body), Auth::Required) {
7380            // A migration opens dozens of windows over tens of minutes, so one
7381            // response that never arrives is ordinary rather than final. The
7382            // call reserves capacity and mints URLs; it commits nothing, so a
7383            // dropped or timed-out attempt is worth asking again.
7384            Err(LinkError::Transport { .. }) if attempt + 1 < RESERVATION_ATTEMPTS => {
7385                pause(attempt);
7386                attempt += 1;
7387            }
7388            Err(error) => return Err(error),
7389            Ok(response) => {
7390                if is_retryable_hub_status(response.status) && attempt + 1 < RESERVATION_ATTEMPTS {
7391                    pause(attempt);
7392                    attempt += 1;
7393                    continue;
7394                }
7395                return ensure_ok(response, what);
7396            }
7397        }
7398    }
7399}
7400
7401/// The exact bytes of a staged change. The hub refuses a manifest carrying any
7402/// other key, so this is the whole contract: operations, and the blob
7403/// references they need, and nothing else.
7404fn v2_change_manifest(operations: &[Value], blobs: Value) -> LinkResult<Vec<u8>> {
7405    let bytes = serde_json::to_vec(&json!({ "operations": operations, "blobs": blobs }))
7406        .map_err(|_| invalid_feed("could not serialize the v2 change manifest"))?;
7407    if bytes.len() > MAX_STAGED_CHANGE_BYTES {
7408        return Err(LinkError::PushTooLarge {
7409            detail: "v2 change metadata exceeds the staged-change ceiling".to_string(),
7410        });
7411    }
7412    Ok(bytes)
7413}
7414
7415/// State one change's operations and blob references through the same
7416/// content-addressed lane its bytes already ride.
7417///
7418/// A first push of a real store names one operation and one blob reference per
7419/// file, which alone outgrows the bounded commit request. Staging that metadata
7420/// and addressing it by hash keeps the push a single atomic commit: the hub
7421/// verifies the address, splices the manifest in, and validates exactly what it
7422/// would have validated inline. The alternative — many partial commits — would
7423/// make a failed migration leave a half-populated brain readable.
7424fn stage_v2_change(
7425    cfg: &HubConfig,
7426    requested_brain: &str,
7427    operations: &[Value],
7428    blobs: Value,
7429) -> LinkResult<Value> {
7430    let bytes = v2_change_manifest(operations, blobs)?;
7431    let sha256 = content_sha256(&bytes);
7432    let reserved = reserve_upload_window(
7433        cfg,
7434        &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
7435        &json!({
7436            "blobs": [{
7437                "sha256": sha256,
7438                "bytes": bytes.len(),
7439                "kind": "staged_change",
7440            }],
7441        }),
7442        "stage the v2 change",
7443    )?;
7444    let items = reserved
7445        .get("uploads")
7446        .and_then(Value::as_array)
7447        .ok_or_else(|| invalid_feed("v2 change staging response has no items"))?;
7448    let [item] = items.as_slice() else {
7449        return Err(invalid_feed(
7450            "v2 change staging response changed the requested set",
7451        ));
7452    };
7453    let reservation_id = item
7454        .get("reservation_id")
7455        .and_then(Value::as_str)
7456        .ok_or_else(|| invalid_feed("v2 change staging has no opaque id"))?;
7457    if item.get("sha256").and_then(Value::as_str) != Some(sha256.as_str())
7458        || item.get("bytes").and_then(Value::as_u64) != Some(bytes.len() as u64)
7459        || !crate::ulid::is_ulid(reservation_id)
7460    {
7461        return Err(invalid_feed("v2 change staging item is inconsistent"));
7462    }
7463    match item.get("status").and_then(Value::as_str) {
7464        Some("upload") => put_presigned(
7465            cfg,
7466            item.get("url")
7467                .and_then(Value::as_str)
7468                .ok_or_else(|| invalid_feed("v2 change staging has no URL"))?,
7469            item.get("headers").unwrap_or(&Value::Null),
7470            &bytes,
7471        )?,
7472        Some("already_present") => {}
7473        _ => return Err(invalid_feed("v2 change staging has an unknown status")),
7474    }
7475    Ok(json!({
7476        "sha256": sha256,
7477        "bytes": bytes.len(),
7478        "reservation_id": reservation_id,
7479    }))
7480}
7481
7482/// Move a commit request's operations and blob references off the wire body
7483/// when they no longer fit in it. Idempotent by construction: the mutation ID
7484/// is derived from the change, never from how the change was transported.
7485fn stage_oversized_v2_change(
7486    cfg: &HubConfig,
7487    requested_brain: &str,
7488    operations: &[Value],
7489    body: &mut Value,
7490) -> LinkResult<()> {
7491    if body.to_string().len() <= MAX_PUSH_BYTES {
7492        return Ok(());
7493    }
7494    let staged = stage_v2_change(
7495        cfg,
7496        requested_brain,
7497        operations,
7498        body.get("blobs")
7499            .cloned()
7500            .unwrap_or(Value::Array(Vec::new())),
7501    )?;
7502    let map = body
7503        .as_object_mut()
7504        .ok_or_else(|| invalid_feed("v2 commit request is not an object"))?;
7505    map.remove("operations");
7506    map.remove("blobs");
7507    map.insert("staged_change".to_string(), staged);
7508    Ok(())
7509}
7510
7511fn v2_sync_push(
7512    cfg: &HubConfig,
7513    requested_brain: &str,
7514    store: &Store,
7515    head: V2VerifiedHead,
7516    options: V2SyncPushOptions<'_>,
7517) -> LinkResult<Value> {
7518    let V2SyncPushOptions {
7519        resume_local_policy,
7520        bulk_confirmation,
7521        resolution,
7522        pulled,
7523        withdrawal_paths,
7524        withdrawal_reason,
7525    } = options;
7526    let _operation_lock = lock_v2_sync_operation(cfg, &head.brain_id)?;
7527    let head = v2_verified_head(cfg, requested_brain)?
7528        .ok_or_else(|| invalid_feed("v2 head disappeared while waiting for the checkout lock"))?;
7529    let (mut remote, mut remote_assets, carried_local, carried_local_assets) =
7530        match pulled.filter(|snapshot| same_v2_head(&snapshot.head, &head)) {
7531            Some(snapshot) => (
7532                snapshot.files,
7533                snapshot.assets,
7534                Some(snapshot.local),
7535                Some(snapshot.local_assets),
7536            ),
7537            None => (
7538                files_for_v2_view(
7539                    &head,
7540                    v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7541                ),
7542                v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
7543                None,
7544                None,
7545            ),
7546        };
7547    let baseline = load_v2_baseline(cfg, &head.brain_id, &store.root)?;
7548    ensure_v2_view_compatible(&head, baseline.as_ref())?;
7549    if head.view_kind == "scoped" && baseline.is_none() {
7550        return Err(LinkError::ScopedViewChanged);
7551    }
7552    let local_view = local_view_for_v2_push(store, &head, baseline.as_ref(), carried_local)?;
7553    let local = &local_view.riding;
7554    let local_assets = match carried_local_assets {
7555        Some(assets) => assets,
7556        None => v2_local_asset_records(store)?,
7557    };
7558    if withdrawal_paths.len() > MAX_PUSH_FILES {
7559        return Err(LinkError::PushTooLarge {
7560            detail: "too many explicit withdrawal paths".to_string(),
7561        });
7562    }
7563    let withdrawal_reason = if withdrawal_paths.is_empty() {
7564        None
7565    } else {
7566        let reason = withdrawal_reason
7567            .map(str::trim)
7568            .filter(|reason| !reason.is_empty() && reason.len() <= 1000)
7569            .ok_or_else(|| LinkError::InvalidPack {
7570                message: "explicit withdrawal requires a non-empty --withdraw-reason of at most 1000 bytes".to_string(),
7571            })?;
7572        Some(reason)
7573    };
7574    let mut withdrawals = withdrawal_paths
7575        .iter()
7576        .map(|path| {
7577            crate::linkmd_v2::normalize_path(path).map_err(|error| LinkError::UnsafePath {
7578                path: error.to_string(),
7579            })
7580        })
7581        .collect::<LinkResult<Vec<_>>>()?;
7582    withdrawals.sort();
7583    withdrawals.dedup();
7584    if withdrawals.len() != withdrawal_paths.len() {
7585        return Err(LinkError::InvalidPack {
7586            message: "explicit withdrawal paths must be unique".to_string(),
7587        });
7588    }
7589    let withdrawal_set = withdrawals.iter().cloned().collect::<BTreeSet<_>>();
7590    let mut consumed_withdrawals = BTreeSet::new();
7591    if let Some(previous) = baseline.as_ref() {
7592        if previous.local_policy_digest.as_deref() != Some(&local_view.policy.digest)
7593            && !resume_local_policy
7594        {
7595            let mut newly_eligible = previous
7596                .local_eligibility
7597                .iter()
7598                .filter(|(path, riding)| !**riding && !local_view.policy.keeps_home(path))
7599                .map(|(path, _)| path.clone())
7600                .collect::<Vec<_>>();
7601            if !newly_eligible.is_empty() {
7602                newly_eligible.truncate(100);
7603                return Err(LinkError::LocalPolicyTransition {
7604                    paths: newly_eligible,
7605                });
7606            }
7607        }
7608    }
7609    let base = match baseline.as_ref() {
7610        Some(state) => &state.files,
7611        None if remote.is_empty() => &remote,
7612        None => {
7613            let mut conflicts = remote
7614                .iter()
7615                .filter(|(path, file)| {
7616                    local.get(*path).map(|value| value.0.as_str()) != Some(file.sha256.as_str())
7617                })
7618                .map(|(path, _)| path.clone())
7619                .collect::<Vec<_>>();
7620            if !conflicts.is_empty() {
7621                conflicts.truncate(100);
7622                let (bundle, paths) =
7623                    create_v2_conflict_bundle(cfg, store, &head, None, local, &remote, &conflicts)?;
7624                return Err(LinkError::ConflictBundle { bundle, paths });
7625            }
7626            &remote
7627        }
7628    };
7629    let all_paths = base
7630        .keys()
7631        .chain(remote.keys())
7632        .chain(local.keys())
7633        .cloned()
7634        .collect::<std::collections::BTreeSet<_>>();
7635    let mut conflicts = Vec::new();
7636    let mut operations = Vec::new();
7637    let mut upload_sources = std::collections::BTreeMap::<String, V2UploadSource>::new();
7638    for path in all_paths {
7639        let base_hash = base.get(&path).map(|file| file.sha256.as_str());
7640        let remote_file = remote.get(&path);
7641        let remote_hash = remote_file.map(|file| file.sha256.as_str());
7642        let local_file = local.get(&path);
7643        let local_hash = local_file.map(|file| file.0.as_str());
7644        if local_hash == base_hash {
7645            continue;
7646        }
7647        if resolution.is_some_and(|allowed| !allowed.contains_key(&path)) {
7648            continue;
7649        }
7650        if local_view.policy.keeps_home(&path) {
7651            // Kept-home is a local transfer exclusion, never an implicit
7652            // delete of the company's already-hosted coordinate.
7653            continue;
7654        }
7655        if remote_hash != base_hash && local_hash != remote_hash {
7656            let explicitly_resolved = resolution
7657                .and_then(|allowed| allowed.get(&path))
7658                .is_some_and(|selected| {
7659                    selected.expected_remote.as_deref() == remote_hash
7660                        && selected.selected_local.as_deref() == local_hash
7661                });
7662            if !explicitly_resolved {
7663                conflicts.push(path);
7664                continue;
7665            }
7666        }
7667        match local_file {
7668            Some((sha256, byte_count)) => {
7669                verify_v2_upload_source(store, &path, sha256, *byte_count)?;
7670                operations.push(json!({
7671                    "op": "put",
7672                    "path": path,
7673                    "expected": v2_expected(remote_file),
7674                    "blob": sha256,
7675                    "bytes": byte_count,
7676                }));
7677                upload_sources
7678                    .entry(sha256.clone())
7679                    .or_insert_with(|| V2UploadSource {
7680                        path: path.clone(),
7681                        bytes: *byte_count,
7682                    });
7683            }
7684            None => {
7685                let Some(current) = remote_file else {
7686                    continue;
7687                };
7688                operations.push(json!({
7689                    "op": "delete",
7690                    "path": path,
7691                    "expected": { "kind": "blob", "hash": current.sha256 },
7692                }));
7693            }
7694        }
7695    }
7696    operations = infer_exact_source_promotions(operations);
7697    for path in &withdrawals {
7698        if local_assets.contains_key(path) {
7699            continue;
7700        }
7701        operations.push(v2_content_withdrawal_operation(
7702            store,
7703            &local_view,
7704            &remote,
7705            path,
7706            withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7707        )?);
7708        consumed_withdrawals.insert(path.clone());
7709    }
7710    if !conflicts.is_empty() {
7711        conflicts.truncate(100);
7712        let (bundle, paths) = create_v2_conflict_bundle(
7713            cfg,
7714            store,
7715            &head,
7716            baseline.as_ref(),
7717            local,
7718            &remote,
7719            &conflicts,
7720        )?;
7721        return Err(LinkError::ConflictBundle { bundle, paths });
7722    }
7723    let base_assets = match baseline.as_ref() {
7724        Some(state) => &state.assets,
7725        None if remote_assets.is_empty() => &remote_assets,
7726        None => {
7727            let mismatched = remote_assets.iter().any(|(path, remote)| {
7728                local_assets.get(path) != Some(&v2_asset_record(remote, path))
7729            }) || local_assets.len() != remote_assets.len();
7730            if mismatched {
7731                return Err(LinkError::Conflict {
7732                    paths: vec!["assets.jsonl".to_string()],
7733                });
7734            }
7735            &remote_assets
7736        }
7737    };
7738    let asset_paths = base_assets
7739        .keys()
7740        .chain(remote_assets.keys())
7741        .chain(local_assets.keys())
7742        .cloned()
7743        .collect::<std::collections::BTreeSet<_>>();
7744    let mut asset_policy_transitions = Vec::new();
7745    for path in asset_paths {
7746        let base_record = base_assets
7747            .get(&path)
7748            .map(|asset| v2_asset_record(asset, &path));
7749        let remote = remote_assets.get(&path);
7750        let remote_record = remote.map(|asset| v2_asset_record(asset, &path));
7751        let local_record = local_assets.get(&path);
7752        if withdrawal_set.contains(&path) {
7753            let record = local_record.ok_or_else(|| LinkError::InvalidPack {
7754                message: format!("asset withdrawal path `{path}` is absent from assets.jsonl"),
7755            })?;
7756            let current = remote.ok_or_else(|| LinkError::InvalidPack {
7757                message: format!(
7758                    "asset withdrawal path `{path}` has no readable hosted coordinate"
7759                ),
7760            })?;
7761            operations.push(v2_asset_withdrawal_operation(
7762                store,
7763                &local_view,
7764                &path,
7765                record,
7766                current,
7767                withdrawal_reason.expect("non-empty withdrawal set has a reason"),
7768            )?);
7769            consumed_withdrawals.insert(path.clone());
7770            continue;
7771        }
7772        let mut raw_present = false;
7773        let mut disposition = "withheld";
7774        let mut resumes_hosting = false;
7775        if let Some(record) = local_record {
7776            crate::linkmd_v2::normalize_path(&record.path)
7777                .map_err(|error| invalid_feed(error.to_string()))?;
7778            let kept_home = local_view.policy.keeps_home(&path);
7779            raw_present = matches!(store.regular_file_exists(Path::new(&path)), Ok(true));
7780            disposition = if kept_home || !raw_present {
7781                "withheld"
7782            } else {
7783                "hosted"
7784            };
7785            if !raw_present && record.required && !kept_home {
7786                return Err(LinkError::InvalidPack {
7787                    message: format!("required asset {path} is missing"),
7788                });
7789            }
7790            resumes_hosting = v2_asset_resumes_hosting(remote, &path, record, disposition);
7791        }
7792        if local_record == base_record.as_ref() && !resumes_hosting {
7793            continue;
7794        }
7795        if remote_record != base_record && local_record != remote_record.as_ref() {
7796            conflicts.push(path);
7797            continue;
7798        }
7799        let Some(record) = local_record else {
7800            if let Some(remote) = remote {
7801                operations.push(json!({
7802                    "op": "asset_delete",
7803                    "path": path,
7804                    "expected": v2_asset_expected(Some(remote)),
7805                }));
7806            }
7807            continue;
7808        };
7809        let raw = if raw_present {
7810            verify_v2_upload_source(store, &path, &record.sha256, record.bytes)?;
7811            Some(())
7812        } else {
7813            None
7814        };
7815        let op = if resumes_hosting {
7816            if !resume_local_policy {
7817                asset_policy_transitions.push(path);
7818                continue;
7819            }
7820            "asset_resume"
7821        } else {
7822            "asset_put"
7823        };
7824        operations.push(json!({
7825            "op": op,
7826            "path": path,
7827            "expected": v2_asset_expected(remote),
7828            "asset": v2_asset_value(record, disposition),
7829        }));
7830        if disposition == "hosted" {
7831            raw.expect("hosted asset was checked present");
7832            upload_sources
7833                .entry(record.sha256.clone())
7834                .or_insert_with(|| V2UploadSource {
7835                    path: path.clone(),
7836                    bytes: record.bytes,
7837                });
7838        }
7839    }
7840    if consumed_withdrawals != withdrawal_set {
7841        let missing = withdrawal_set
7842            .difference(&consumed_withdrawals)
7843            .next()
7844            .expect("different withdrawal sets have one member");
7845        return Err(LinkError::InvalidPack {
7846            message: format!(
7847                "withdrawal path `{missing}` is not a readable content or asset coordinate"
7848            ),
7849        });
7850    }
7851    if !conflicts.is_empty() {
7852        conflicts.truncate(100);
7853        return Err(LinkError::Conflict { paths: conflicts });
7854    }
7855    if !asset_policy_transitions.is_empty() {
7856        asset_policy_transitions.truncate(100);
7857        return Err(LinkError::LocalPolicyTransition {
7858            paths: asset_policy_transitions,
7859        });
7860    }
7861    let touched_sources = operations
7862        .iter()
7863        .filter_map(
7864            |operation| match operation.get("op").and_then(Value::as_str) {
7865                Some("put" | "restore") => operation.get("path").and_then(Value::as_str),
7866                Some("rename") => operation.get("to").and_then(Value::as_str),
7867                _ => None,
7868            },
7869        )
7870        .collect::<std::collections::BTreeSet<_>>();
7871    let withheld_links = local_view
7872        .withheld_links
7873        .iter()
7874        .filter(|link| touched_sources.contains(link.source.as_str()))
7875        .collect::<Vec<_>>();
7876    let checkout_pseudonym = v2_checkout_id(
7877        baseline
7878            .as_ref()
7879            .and_then(|current| current.checkout_id.as_deref()),
7880    )?;
7881    let checkout_id = if withheld_links.is_empty() {
7882        None
7883    } else {
7884        Some(checkout_pseudonym.clone())
7885    };
7886    if operations.is_empty() {
7887        let final_head = v2_verified_head(cfg, requested_brain)?
7888            .ok_or_else(|| invalid_feed("v2 head disappeared during sync"))?;
7889        if !same_v2_head(&head, &final_head) {
7890            return Err(LinkError::RemoteAdvancedDuringSync);
7891        }
7892        let mut final_local = v2_local_files(store)?;
7893        remove_scoped_projection(&head, baseline.as_ref(), &mut final_local)?;
7894        let final_assets = v2_local_asset_records(store)?;
7895        let local_changed = final_local.riding != local_view.riding || final_assets != local_assets;
7896        let remote_ahead = !v2_riding_matches_remote(&final_local.riding, &remote, |path| {
7897            final_local.policy.keeps_home(path)
7898        }) || !v2_asset_records_match_remote(&final_assets, &remote_assets);
7899        let next = v2_baseline_from_head(
7900            cfg,
7901            &head,
7902            remote,
7903            remote_assets,
7904            Some(&final_local),
7905            Some(&checkout_pseudonym),
7906        )?;
7907        let split_count = next.remote_copy_remains.len();
7908        accept_v2_head(cfg, &final_head)?;
7909        if !local_changed && !remote_ahead {
7910            refresh_scoped_view_marker(store, &head, next.files.len())?;
7911            save_v2_baseline(cfg, &head.brain_id, &store.root, &next)?;
7912        }
7913        return Ok(json!({
7914            "v": 2,
7915            "outcome": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "no_change" },
7916            "sync_status": if local_changed { "local_dirty" } else if remote_ahead { "remote_ahead" } else { "synced" },
7917            "seq": head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
7918            "commit_hash": head.pointer.as_ref().map(|pointer| &pointer.commit_hash),
7919            "local_policy": {
7920                "remote_copy_remains": split_count,
7921            },
7922        }));
7923    }
7924    let includes_contract = operations
7925        .iter()
7926        .any(|operation| operation.get("path").and_then(Value::as_str) == Some("DB.md"));
7927    let rebase = if head.pointer.is_none() || includes_contract {
7928        "strict"
7929    } else {
7930        "disjoint"
7931    };
7932    let base_value = head.pointer.as_ref().map(|pointer| {
7933        json!({
7934            "seq": pointer.seq,
7935            "commit_hash": pointer.commit_hash,
7936            "content_root": pointer.content_root,
7937            "asset_root": pointer.asset_root,
7938        })
7939    });
7940    // The mutation coordinate is stable for the exact observed base and local
7941    // operation set. A lost response can therefore be retried without a
7942    // duplicate commit/proposal even across process restarts.
7943    let entropy = format!(
7944        "{}\0{}\0{}\0{}\0{}\0{}",
7945        normalized_origin(&cfg.hub)?,
7946        head.brain_id,
7947        serde_json::to_string(&base_value).unwrap_or_default(),
7948        serde_json::to_string(&operations).unwrap_or_default(),
7949        serde_json::to_string(&withheld_links).unwrap_or_default(),
7950        checkout_id.as_deref().unwrap_or("")
7951    );
7952    let mutation_id = format!("dbmd-{}", content_sha256(entropy.as_bytes()));
7953    let changed_bytes = upload_sources.values().try_fold(0_u64, |total, source| {
7954        total
7955            .checked_add(source.bytes)
7956            .ok_or_else(|| LinkError::PushTooLarge {
7957                detail: "v2 changed-byte total overflow".to_string(),
7958            })
7959    })?;
7960    let inline = changed_bytes <= 3 * 1024 * 1024;
7961    let inline_blobs = if inline {
7962        upload_sources
7963            .iter()
7964            .map(|(sha256, source)| {
7965                let bytes = store.read_bounded(Path::new(&source.path), source.bytes)?;
7966                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != *sha256 {
7967                    return Err(LinkError::InvalidPack {
7968                        message: format!("local path `{}` changed before upload", source.path),
7969                    });
7970                }
7971                Ok(json!({
7972                    "sha256": sha256,
7973                    "bytes": source.bytes,
7974                    "content_base64": base64::engine::general_purpose::STANDARD.encode(bytes),
7975                }))
7976            })
7977            .collect::<LinkResult<Vec<_>>>()?
7978    } else {
7979        Vec::new()
7980    };
7981    let mut body = json!({
7982        "mutation_id": mutation_id,
7983        "base": base_value,
7984        "rebase": rebase,
7985        "reason": "dbmd sync",
7986        "operations": operations,
7987        "blobs": inline_blobs,
7988    });
7989    if !withheld_links.is_empty() {
7990        body["withheld_links"] = serde_json::to_value(&withheld_links)
7991            .map_err(|_| invalid_feed("could not serialize withheld-link observations"))?;
7992        body["checkout_id"] =
7993            Value::String(checkout_id.expect("non-empty withheld links have a checkout pseudonym"));
7994    }
7995    if let Some(confirmation) = bulk_confirmation {
7996        if !crate::ulid::is_ulid(&confirmation.id) || !is_sha256(&confirmation.digest) {
7997            return Err(LinkError::InvalidPack {
7998                message: "bulk confirmation must contain a lowercase ULID and SHA-256 digest"
7999                    .to_string(),
8000            });
8001        }
8002        // A preview is bound to the exact observed head. Confirmation can
8003        // therefore never use disjoint rebase semantics, even when the normal
8004        // small-mutation path could.
8005        body["rebase"] = Value::String("strict".to_string());
8006        body["bulk_preview_id"] = Value::String(confirmation.id.clone());
8007        body["bulk_preview_digest"] = Value::String(confirmation.digest.clone());
8008    }
8009    if !inline || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
8010        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8011        for operation in &operations {
8012            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
8013                return Err(invalid_feed("v2 upload operation has no kind"));
8014            };
8015            let hash = match kind {
8016                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
8017                "asset_put" | "asset_resume" => operation
8018                    .get("asset")
8019                    .and_then(|asset| asset.get("blob_sha256"))
8020                    .and_then(Value::as_str),
8021                _ => None,
8022            };
8023            let Some(hash) = hash else { continue };
8024            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
8025            if kind == "rename" {
8026                for field in ["from", "to"] {
8027                    coordinates.insert(
8028                        operation
8029                            .get(field)
8030                            .and_then(Value::as_str)
8031                            .ok_or_else(|| invalid_feed("v2 rename upload has no coordinate"))?
8032                            .to_string(),
8033                    );
8034                }
8035            } else {
8036                let path = operation
8037                    .get("path")
8038                    .and_then(Value::as_str)
8039                    .ok_or_else(|| invalid_feed("v2 upload has no coordinate"))?;
8040                coordinates.insert(if kind.starts_with("asset_") {
8041                    format!("assets/{path}")
8042                } else {
8043                    path.to_string()
8044                });
8045            }
8046        }
8047        let declarations = upload_sources
8048            .iter()
8049            .map(|(sha256, source)| {
8050                json!({
8051                    "sha256": sha256,
8052                    "bytes": source.bytes,
8053                    "coordinates": coordinates_by_hash
8054                        .get(sha256)
8055                        .into_iter()
8056                        .flatten()
8057                        .collect::<Vec<_>>(),
8058                })
8059            })
8060            .collect::<Vec<_>>();
8061        let mut references = Vec::with_capacity(upload_sources.len());
8062        let mut seen = std::collections::BTreeSet::new();
8063        let mut reserved_count = 0usize;
8064        let mut pending_uploads: Vec<V2PendingUpload<'_>> = Vec::new();
8065        // A reservation's presigned URL is short-lived, so each batch's bytes
8066        // ride before the next batch is reserved. Reserving every blob first
8067        // and uploading afterwards expires the earliest URLs on a real store.
8068        for batch in batch_upload_declarations(declarations) {
8069            let batch_len = batch.len();
8070            let reserved = reserve_upload_window(
8071                cfg,
8072                &format!("/api/hub/brains/{requested_brain}/v2/uploads"),
8073                &json!({ "blobs": batch }),
8074                "prepare v2 changed-byte uploads",
8075            )?;
8076            let items = reserved
8077                .get("uploads")
8078                .and_then(Value::as_array)
8079                .ok_or_else(|| invalid_feed("v2 upload reservation response has no items"))?;
8080            if items.len() != batch_len {
8081                return Err(invalid_feed(
8082                    "v2 upload reservation response changed the requested set",
8083                ));
8084            }
8085            reserved_count += items.len();
8086            for item in items {
8087                let sha256 = item
8088                    .get("sha256")
8089                    .and_then(Value::as_str)
8090                    .ok_or_else(|| invalid_feed("v2 upload reservation has no hash"))?;
8091                let source = upload_sources
8092                    .get(sha256)
8093                    .ok_or_else(|| invalid_feed("v2 upload reservation introduced a blob"))?;
8094                let declared_bytes = item
8095                    .get("bytes")
8096                    .and_then(Value::as_u64)
8097                    .ok_or_else(|| invalid_feed("v2 upload reservation has no byte length"))?;
8098                let reservation_id = item
8099                    .get("reservation_id")
8100                    .and_then(Value::as_str)
8101                    .ok_or_else(|| invalid_feed("v2 upload reservation has no opaque id"))?;
8102                let expected_coordinates = coordinates_by_hash.get(sha256).ok_or_else(|| {
8103                    invalid_feed("v2 upload reservation has no coordinate binding")
8104                })?;
8105                let returned_coordinates = item
8106                    .get("coordinates")
8107                    .and_then(Value::as_array)
8108                    .ok_or_else(|| invalid_feed("v2 upload reservation has no coordinates"))?;
8109                if declared_bytes != source.bytes
8110                    || !crate::ulid::is_ulid(reservation_id)
8111                    || !seen.insert(sha256.to_string())
8112                    || returned_coordinates.len() != expected_coordinates.len()
8113                    || returned_coordinates
8114                        .iter()
8115                        .zip(expected_coordinates)
8116                        .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
8117                {
8118                    return Err(invalid_feed("v2 upload reservation item is inconsistent"));
8119                }
8120                match item.get("status").and_then(Value::as_str) {
8121                    Some("upload") => {
8122                        let url = item
8123                            .get("url")
8124                            .and_then(Value::as_str)
8125                            .ok_or_else(|| invalid_feed("v2 upload reservation has no URL"))?;
8126                        pending_uploads.push(V2PendingUpload {
8127                            url: url.to_string(),
8128                            headers: item.get("headers").cloned().unwrap_or(Value::Null),
8129                            sha256: sha256.to_string(),
8130                            source,
8131                        });
8132                    }
8133                    Some("already_present") => {}
8134                    _ => return Err(invalid_feed("v2 upload reservation has an unknown status")),
8135                }
8136                references.push(json!({
8137                    "sha256": sha256,
8138                    "bytes": source.bytes,
8139                    "reservation_id": reservation_id,
8140                }));
8141            }
8142            // Stage this batch's bytes concurrently. Sequential PUTs move a real
8143            // store at a few objects a second, which both takes hours and pushes
8144            // the batch past its reservation's upload window; the object store
8145            // is content-addressed and precondition-guarded, so parallel writes
8146            // stay safe.
8147            upload_v2_batch_concurrently(cfg, store, &pending_uploads)?;
8148            pending_uploads.clear();
8149        }
8150        if reserved_count != upload_sources.len() {
8151            return Err(invalid_feed(
8152                "v2 upload reservation response changed the requested set",
8153            ));
8154        }
8155        body["blobs"] = Value::Array(references);
8156    }
8157    stage_oversized_v2_change(cfg, requested_brain, &operations, &mut body)?;
8158    let path = format!("/api/hub/brains/{requested_brain}/v2/commits");
8159    let mut candidate_hub_signer: Option<String> = None;
8160    let mut response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8161    let bulk_preview_required = !(200..300).contains(&response.status)
8162        && response.body.as_ref().is_some_and(|value| {
8163            value.get("code").and_then(Value::as_str) == Some("bulk_preview_required")
8164                || value
8165                    .get("details")
8166                    .and_then(|details| details.get("code"))
8167                    .and_then(Value::as_str)
8168                    == Some("bulk_preview_required")
8169        });
8170    if bulk_preview_required && bulk_confirmation.is_none() {
8171        body["rebase"] = Value::String("strict".to_string());
8172        body["preview_only"] = Value::Bool(true);
8173        let preview = ensure_ok(
8174            request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
8175            "v2 bulk preview",
8176        )?;
8177        let preview_code = preview.get("code").and_then(Value::as_str);
8178        let required = preview.get("required").and_then(Value::as_bool);
8179        if preview.get("v").and_then(Value::as_u64) != Some(2)
8180            || preview.get("mutation_id").and_then(Value::as_str) != Some(mutation_id.as_str())
8181            || !matches!(
8182                preview_code,
8183                Some("bulk_preview_created" | "bulk_preview_not_required")
8184            )
8185            || required.is_none()
8186        {
8187            return Err(invalid_feed(
8188                "bulk preview response is not bound to the requested mutation",
8189            ));
8190        }
8191        if required == Some(true) {
8192            let preview_id = preview.get("bulk_preview_id").and_then(Value::as_str);
8193            let preview_digest = preview.get("bulk_preview_digest").and_then(Value::as_str);
8194            if preview_code != Some("bulk_preview_created")
8195                || preview_id.is_none_or(|value| !crate::ulid::is_ulid(value))
8196                || preview_digest.is_none_or(|value| !is_sha256(value))
8197                || preview.get("expires_at").and_then(Value::as_str).is_none()
8198                || !preview.get("impact").is_some_and(Value::is_object)
8199            {
8200                return Err(invalid_feed("bulk preview receipt is malformed"));
8201            }
8202            return Err(LinkError::BulkPreviewRequired { preview });
8203        }
8204        if preview_code != Some("bulk_preview_not_required") {
8205            return Err(invalid_feed("bulk preview requirement is inconsistent"));
8206        }
8207        // The rolling activity window can expire between the refusal and
8208        // preview. Retry once, still pinned strictly to the observed head.
8209        body.as_object_mut()
8210            .expect("v2 commit request is an object")
8211            .remove("preview_only");
8212        response = request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?;
8213    }
8214    let mut result = ensure_ok(response, "v2 sync push")?;
8215    if result.get("code").and_then(Value::as_str) == Some("proposal_queued") {
8216        if let Some(object) = result.as_object_mut() {
8217            object.insert(
8218                "sync_status".to_string(),
8219                Value::String("proposal_pending".to_string()),
8220            );
8221        }
8222        return Ok(result);
8223    }
8224    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
8225        let request_id = result
8226            .get("request_id")
8227            .and_then(Value::as_str)
8228            .ok_or_else(|| invalid_feed("self-custody response has no request id"))?
8229            .to_string();
8230        let challenge = result
8231            .get("signing_challenge")
8232            .ok_or_else(|| invalid_feed("self-custody response has no signing challenge"))?;
8233        let mut expected_candidate = remote.clone();
8234        let mut expected_candidate_assets = remote_assets.clone();
8235        apply_generated_v2_operations(
8236            &operations,
8237            &local_assets,
8238            &mut expected_candidate,
8239            &mut expected_candidate_assets,
8240        )?;
8241        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
8242            cfg,
8243            &head,
8244            &expected_candidate,
8245            &expected_candidate_assets,
8246            &mutation_id,
8247            &v2_signed_request_view(&body, &operations),
8248            challenge,
8249        )?;
8250        body["signing_challenge_id"] = Value::String(challenge_id);
8251        body["signature_base64url"] = Value::String(signature);
8252        candidate_hub_signer = Some(actor_signer);
8253        result = ensure_ok(
8254            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
8255            "v2 self-custody commit",
8256        )?;
8257    }
8258    let refreshed = v2_verified_head(cfg, requested_brain)?
8259        .ok_or_else(|| invalid_feed("v2 head disappeared after commit"))?;
8260    if candidate_hub_signer
8261        .as_ref()
8262        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
8263    {
8264        return Err(invalid_feed(
8265            "self-custody actor signer differs from the committed hub pointer signer",
8266        ));
8267    }
8268    let accepted_hash = result.get("commit_hash").and_then(Value::as_str);
8269    if refreshed
8270        .pointer
8271        .as_ref()
8272        .map(|pointer| pointer.commit_hash.as_str())
8273        != accepted_hash
8274    {
8275        return Err(LinkError::RemoteAdvancedDuringSync);
8276    }
8277    ensure_v2_view_compatible(&refreshed, baseline.as_ref())?;
8278    let rebased = result
8279        .get("rebased")
8280        .and_then(Value::as_bool)
8281        .ok_or_else(|| invalid_feed("v2 commit receipt has no rebase result"))?;
8282    let (refreshed_files, refreshed_assets) = if rebased {
8283        (
8284            files_for_v2_view(
8285                &refreshed,
8286                v2_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8287            ),
8288            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?,
8289        )
8290    } else {
8291        let asset_changed = apply_generated_v2_operations(
8292            &operations,
8293            &local_assets,
8294            &mut remote,
8295            &mut remote_assets,
8296        )?;
8297        let assets = if asset_changed {
8298            // Asset leaf hashes are server-generated signed coordinates; only
8299            // asset-changing commits need this usually tiny post-commit read.
8300            v2_asset_manifest(cfg, &refreshed.brain_id, refreshed.pointer.as_ref())?
8301        } else {
8302            remote_assets
8303        };
8304        (remote, assets)
8305    };
8306    let mut final_local = v2_local_files(store)?;
8307    remove_scoped_projection(&refreshed, baseline.as_ref(), &mut final_local)?;
8308    let final_assets = v2_local_asset_records(store)?;
8309    let local_dirty = final_local.riding != local_view.riding
8310        || !v2_riding_matches_remote(&final_local.riding, &refreshed_files, |path| {
8311            final_local.policy.keeps_home(path)
8312        })
8313        || final_assets != local_assets
8314        || !v2_asset_records_match_remote(&final_assets, &refreshed_assets);
8315    let next = v2_baseline_from_head(
8316        cfg,
8317        &refreshed,
8318        refreshed_files,
8319        refreshed_assets,
8320        Some(&final_local),
8321        Some(&checkout_pseudonym),
8322    )?;
8323    let split_count = next.remote_copy_remains.len();
8324    accept_v2_head(cfg, &refreshed)?;
8325    if !local_dirty {
8326        refresh_scoped_view_marker(store, &refreshed, next.files.len())?;
8327        save_v2_baseline(cfg, &refreshed.brain_id, &store.root, &next)?;
8328    }
8329    if let Some(object) = result.as_object_mut() {
8330        object.insert(
8331            "local_policy".to_string(),
8332            json!({ "remote_copy_remains": split_count }),
8333        );
8334        object.insert(
8335            "sync_status".to_string(),
8336            Value::String(if local_dirty {
8337                "remote_committed_local_dirty".to_string()
8338            } else {
8339                "synced".to_string()
8340            }),
8341        );
8342    }
8343    Ok(result)
8344}
8345
8346/// Negotiate v2 and send only local changes. A v1 hub retains the existing
8347/// whole-snapshot behavior until its brain advertises the new profile.
8348pub fn sync_push_incremental(cfg: &HubConfig, brain: &str, store: &Store) -> LinkResult<Value> {
8349    sync_push_incremental_with_policy(cfg, brain, store, false)
8350}
8351
8352/// The explicit `.sevralocal` adoption form. Ordinary sync never uploads a
8353/// path that became eligible because local policy was weakened or removed.
8354pub fn sync_push_incremental_with_policy(
8355    cfg: &HubConfig,
8356    brain: &str,
8357    store: &Store,
8358    resume_local_policy: bool,
8359) -> LinkResult<Value> {
8360    sync_push_incremental_with_options(cfg, brain, store, resume_local_policy, None)
8361}
8362
8363/// Incremental push with both local-policy adoption and an optional exact
8364/// permissioned bulk-preview confirmation.
8365pub fn sync_push_incremental_with_options(
8366    cfg: &HubConfig,
8367    brain: &str,
8368    store: &Store,
8369    resume_local_policy: bool,
8370    bulk_confirmation: Option<&V2BulkConfirmation>,
8371) -> LinkResult<Value> {
8372    sync_push_incremental_with_controls(
8373        cfg,
8374        brain,
8375        store,
8376        resume_local_policy,
8377        bulk_confirmation,
8378        &[],
8379        None,
8380    )
8381}
8382
8383/// Incremental push with explicit permissioned current-host withdrawal.
8384pub fn sync_push_incremental_with_controls(
8385    cfg: &HubConfig,
8386    brain: &str,
8387    store: &Store,
8388    resume_local_policy: bool,
8389    bulk_confirmation: Option<&V2BulkConfirmation>,
8390    withdrawal_paths: &[String],
8391    withdrawal_reason: Option<&str>,
8392) -> LinkResult<Value> {
8393    require_safe_ref(brain)?;
8394    if let Some(head) = v2_verified_head(cfg, brain)? {
8395        return v2_sync_push(
8396            cfg,
8397            brain,
8398            store,
8399            head,
8400            V2SyncPushOptions {
8401                resume_local_policy,
8402                bulk_confirmation,
8403                resolution: None,
8404                pulled: None,
8405                withdrawal_paths,
8406                withdrawal_reason,
8407            },
8408        );
8409    }
8410    if !withdrawal_paths.is_empty() {
8411        return Err(LinkError::InvalidPack {
8412            message: "explicit withdrawal requires a link.md v2 brain".to_string(),
8413        });
8414    }
8415    legacy_sync_push_incremental(cfg, brain, store, resume_local_policy, bulk_confirmation)
8416}
8417
8418/// Whether this exact hub/brain/path coordinate has an accepted v2 sync
8419/// baseline. CLI dispatch uses this to keep checkout identity sticky even when
8420/// a damaged `DB.md` makes strict store-open fail.
8421pub fn has_v2_sync_baseline(cfg: &HubConfig, brain: &str, checkout: &Path) -> LinkResult<bool> {
8422    require_safe_ref(brain)?;
8423    Ok(load_v2_baseline(cfg, brain, checkout)?.is_some())
8424}
8425
8426#[cfg(windows)]
8427fn legacy_sync_push_incremental(
8428    _cfg: &HubConfig,
8429    _brain: &str,
8430    _store: &Store,
8431    _resume_local_policy: bool,
8432    _bulk_confirmation: Option<&V2BulkConfirmation>,
8433) -> LinkResult<Value> {
8434    Err(LinkError::UnsupportedPlatform {
8435        operation: "legacy v1 whole-snapshot push on Windows; upgrade the brain to link.md v2",
8436    })
8437}
8438
8439#[cfg(not(windows))]
8440fn legacy_sync_push_incremental(
8441    cfg: &HubConfig,
8442    brain: &str,
8443    store: &Store,
8444    resume_local_policy: bool,
8445    bulk_confirmation: Option<&V2BulkConfirmation>,
8446) -> LinkResult<Value> {
8447    if resume_local_policy || bulk_confirmation.is_some() {
8448        return Err(LinkError::InvalidPack {
8449            message: "v2 sync options require a link.md v2 brain".to_string(),
8450        });
8451    }
8452    let files = collect_push_files(store)?;
8453    sync_push(cfg, brain, &files)
8454}
8455
8456/// One explicit resolution for a previously preserved private conflict bundle.
8457#[derive(Debug, Clone)]
8458pub enum V2ConflictChoice {
8459    KeepLocal,
8460    TakeRemote,
8461    From(PathBuf),
8462}
8463
8464fn load_v2_conflict_plan(store: &Store, bundle: &str) -> LinkResult<V2ConflictPlan> {
8465    if !crate::ulid::is_ulid(bundle) {
8466        return Err(LinkError::InvalidPack {
8467            message: "conflict bundle must be a lowercase ULID".to_string(),
8468        });
8469    }
8470    let bytes = store.read_bounded(&v2_conflict_relative(bundle, "plan.json"), 1024 * 1024)?;
8471    let plan: V2ConflictPlan = serde_json::from_slice(&bytes)
8472        .map_err(|_| invalid_feed("private conflict plan is corrupt"))?;
8473    if plan.v != 2
8474        || plan.class != "content_resolution_required"
8475        || plan.bundle != bundle
8476        || !crate::ulid::is_ulid(&plan.brain)
8477        || plan.files.is_empty()
8478        || plan.files.len() > 100
8479        || plan.files.iter().any(|file| {
8480            crate::linkmd_v2::normalize_path(&file.path).is_err()
8481                || [&file.base, &file.local, &file.remote]
8482                    .into_iter()
8483                    .any(|coordinate| {
8484                        coordinate
8485                            .sha256
8486                            .as_deref()
8487                            .is_some_and(|hash| !is_sha256(hash))
8488                            || coordinate.file.as_deref().is_some_and(|name| {
8489                                name.starts_with('/')
8490                                    || name
8491                                        .split('/')
8492                                        .any(|part| part.is_empty() || part == "." || part == "..")
8493                            })
8494                    })
8495        })
8496    {
8497        return Err(invalid_feed("private conflict plan failed validation"));
8498    }
8499    Ok(plan)
8500}
8501
8502/// Inspect or prune private conflict-control state. Ordinary pruning removes
8503/// only expired completed bundles and interrupted bundles with no `plan.json`;
8504/// corrupt completed plans fail closed. `all` is an explicit local discard of
8505/// every well-addressed bundle and never changes hosted brain data.
8506pub fn sync_conflicts(checkout: &Path, prune: bool, all: bool) -> LinkResult<Value> {
8507    require_hardened_filesystem("private conflict maintenance")?;
8508    if all && !prune {
8509        return Err(LinkError::InvalidPack {
8510            message: "discarding all conflict bundles requires prune=true".to_string(),
8511        });
8512    }
8513    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8514        message: format!("conflict checkout is not a valid db.md store: {error}"),
8515    })?;
8516    let _transaction = store.transaction()?;
8517    let root = Path::new(".dbmd/conflicts");
8518    let names = match store.directory_names(root) {
8519        Ok(names) => names,
8520        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
8521        Err(error) => return Err(error.into()),
8522    };
8523    let now = SystemTime::now()
8524        .duration_since(UNIX_EPOCH)
8525        .unwrap_or_default()
8526        .as_secs();
8527    let mut bundles = Vec::new();
8528    let mut pruned = 0_u64;
8529    for name in names {
8530        let Some(bundle) = name.to_str().filter(|value| crate::ulid::is_ulid(value)) else {
8531            continue;
8532        };
8533        let plan_path = v2_conflict_relative(bundle, "plan.json");
8534        let plan_exists = store.regular_file_exists(&plan_path)?;
8535        let expired = if plan_exists {
8536            match load_v2_conflict_plan(&store, bundle) {
8537                Ok(plan) => plan.expires_unix < now,
8538                Err(error) if all => {
8539                    let _ = error;
8540                    true
8541                }
8542                Err(error) => return Err(error),
8543            }
8544        } else {
8545            true
8546        };
8547        if prune && (all || expired) {
8548            store.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8549            pruned += 1;
8550            continue;
8551        }
8552        bundles.push(json!({
8553            "bundle": bundle,
8554            "complete": plan_exists,
8555            "expired": expired,
8556        }));
8557    }
8558    Ok(json!({
8559        "v": 2,
8560        "class": "private_conflict_state",
8561        "bundles": bundles.len(),
8562        "pruned": pruned,
8563        "items": bundles,
8564    }))
8565}
8566
8567/// Resolve one exact private conflict plan. Drift never lowers a precondition:
8568/// the remote head/view and every original local coordinate are rechecked
8569/// before either an explicit local install or a fresh normal commit.
8570pub fn sync_resolve_conflict(
8571    cfg: &HubConfig,
8572    checkout: &Path,
8573    bundle: &str,
8574    choice: V2ConflictChoice,
8575    bulk_confirmation: Option<&V2BulkConfirmation>,
8576) -> LinkResult<Value> {
8577    require_hardened_filesystem("conflict resolution")?;
8578    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8579        message: format!("conflict checkout is not a valid db.md store: {error}"),
8580    })?;
8581    let plan = load_v2_conflict_plan(&store, bundle)?;
8582    if plan.origin != normalized_origin(&cfg.hub)? {
8583        return Err(invalid_feed(
8584            "conflict bundle belongs to another hub origin",
8585        ));
8586    }
8587    let now = SystemTime::now()
8588        .duration_since(UNIX_EPOCH)
8589        .unwrap_or_default()
8590        .as_secs();
8591    if now > plan.expires_unix {
8592        return Err(LinkError::InvalidPack {
8593            message: "conflict bundle expired; rerun sync to obtain current coordinates"
8594                .to_string(),
8595        });
8596    }
8597    let head = v2_verified_head(cfg, &plan.brain)?
8598        .ok_or_else(|| invalid_feed("conflict brain no longer advertises v2"))?;
8599    let pointer = head.pointer.as_ref();
8600    if pointer.map_or(0, |value| value.seq) != plan.remote_seq
8601        || pointer.map(|value| value.commit_hash.as_str()) != plan.remote_commit.as_deref()
8602        || pointer.and_then(|value| value.content_root.as_deref())
8603            != plan.remote_content_root.as_deref()
8604        || head.view_kind != plan.view_kind
8605        || head.view_revision != plan.view_revision
8606    {
8607        return Err(LinkError::RemoteAdvancedDuringSync);
8608    }
8609
8610    // A resolution never overwrites a post-bundle local edit.
8611    for file in &plan.files {
8612        let actual = match store.regular_file_exists(Path::new(&file.path))? {
8613            true => Some(content_sha256(&store.read_bounded(
8614                Path::new(&file.path),
8615                file.local.bytes.unwrap_or(MAX_STORE_BYTES),
8616            )?)),
8617            false => None,
8618        };
8619        if actual.as_deref() != file.local.sha256.as_deref() {
8620            return Err(LinkError::InvalidPack {
8621                message: format!(
8622                    "local conflict path `{}` changed after the bundle was created",
8623                    file.path
8624                ),
8625            });
8626        }
8627    }
8628
8629    let from_source = match &choice {
8630        V2ConflictChoice::From(source) => Some(source.clone()),
8631        _ => None,
8632    };
8633    let result = match choice {
8634        V2ConflictChoice::TakeRemote => {
8635            if bulk_confirmation.is_some() {
8636                return Err(LinkError::InvalidPack {
8637                    message: "bulk confirmation applies to keep-local/from commits, not a local take-remote install".to_string(),
8638                });
8639            }
8640            // Re-read the exact current manifest so every selected remote byte
8641            // retains its signed inclusion proof. The private bundle is useful
8642            // for inspection, but is never promoted into live state as trust.
8643            let current_remote =
8644                files_for_v2_view(&head, v2_manifest(cfg, &plan.brain, head.pointer.as_ref())?);
8645            let _ = v2_take_remote_selection(&plan.files, &current_remote)?;
8646            let selected = plan
8647                .files
8648                .iter()
8649                .map(|file| file.path.clone())
8650                .collect::<std::collections::BTreeSet<_>>();
8651            serde_json::to_value(
8652                v2_sync_pull_with_resolution(
8653                    cfg,
8654                    &plan.brain,
8655                    head,
8656                    Some(checkout),
8657                    Some(&selected),
8658                )?
8659                .report,
8660            )
8661            .map_err(|_| invalid_feed("could not serialize conflict pull receipt"))?
8662        }
8663        V2ConflictChoice::KeepLocal | V2ConflictChoice::From(_) => {
8664            if let Some(source) = from_source.as_ref() {
8665                if plan.files.len() != 1 {
8666                    return Err(LinkError::InvalidPack {
8667                        message: "--from requires a bundle with exactly one conflict".to_string(),
8668                    });
8669                }
8670                let candidate = crate::fsx::read_bounded_nofollow(source, MAX_STORE_BYTES)?;
8671                if std::str::from_utf8(&candidate).is_err() {
8672                    return Err(LinkError::NotUtf8 {
8673                        path: source.display().to_string(),
8674                    });
8675                }
8676                store.write_atomic(Path::new(&plan.files[0].path), &candidate)?;
8677            }
8678            let refreshed_store =
8679                Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8680                    message: format!("resolved checkout is not a valid db.md store: {error}"),
8681                })?;
8682            let mut overrides = std::collections::BTreeMap::new();
8683            for file in &plan.files {
8684                let selected_local = match refreshed_store
8685                    .regular_file_exists(Path::new(&file.path))?
8686                {
8687                    true => Some(content_sha256(
8688                        &refreshed_store.read_bounded(Path::new(&file.path), MAX_STORE_BYTES)?,
8689                    )),
8690                    false => None,
8691                };
8692                overrides.insert(
8693                    file.path.clone(),
8694                    V2ResolutionOverride {
8695                        expected_remote: file.remote.sha256.clone(),
8696                        selected_local,
8697                    },
8698                );
8699            }
8700            v2_sync_push(
8701                cfg,
8702                &plan.brain,
8703                &refreshed_store,
8704                head,
8705                V2SyncPushOptions {
8706                    resume_local_policy: true,
8707                    bulk_confirmation,
8708                    resolution: Some(&overrides),
8709                    pulled: None,
8710                    withdrawal_paths: &[],
8711                    withdrawal_reason: None,
8712                },
8713            )?
8714        }
8715    };
8716
8717    if result.get("code").and_then(Value::as_str) != Some("proposal_queued") {
8718        let installed = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8719            message: format!("resolved checkout is not a valid db.md store: {error}"),
8720        })?;
8721        installed.remove_private_tree(&v2_conflict_relative(bundle, ""))?;
8722    }
8723    Ok(json!({
8724        "v": 2,
8725        "class": "auto_converged",
8726        "bundle": bundle,
8727        "receipt": result,
8728    }))
8729}
8730
8731/// Converge one established permissioned-v2 checkout in both directions.
8732///
8733/// The pull half first performs the ordinary three-way conflict check and
8734/// crash-safe install against the accepted baseline. The push half then opens
8735/// that exact installed checkout, takes its local transaction guard, computes
8736/// a fresh three-way delta, and submits it with mandatory preconditions. A
8737/// remote movement between halves is therefore a normal rebase/conflict input,
8738/// never a blind overwrite. Legacy v1 remains explicit pull-only/push-only so
8739/// its whole-pack replacement semantics are never presented as granular
8740/// convergence.
8741pub fn sync_converge(
8742    cfg: &HubConfig,
8743    brain: &str,
8744    checkout: &Path,
8745    resume_local_policy: bool,
8746) -> LinkResult<Value> {
8747    sync_converge_with_options(cfg, brain, checkout, resume_local_policy, None)
8748}
8749
8750/// Bidirectional convergence with optional exact bulk-preview confirmation.
8751pub fn sync_converge_with_options(
8752    cfg: &HubConfig,
8753    brain: &str,
8754    checkout: &Path,
8755    resume_local_policy: bool,
8756    bulk_confirmation: Option<&V2BulkConfirmation>,
8757) -> LinkResult<Value> {
8758    sync_converge_with_controls(
8759        cfg,
8760        brain,
8761        checkout,
8762        resume_local_policy,
8763        bulk_confirmation,
8764        &[],
8765        None,
8766    )
8767}
8768
8769/// Bidirectional convergence with optional exact withdrawal intents.
8770pub fn sync_converge_with_controls(
8771    cfg: &HubConfig,
8772    brain: &str,
8773    checkout: &Path,
8774    resume_local_policy: bool,
8775    bulk_confirmation: Option<&V2BulkConfirmation>,
8776    withdrawal_paths: &[String],
8777    withdrawal_reason: Option<&str>,
8778) -> LinkResult<Value> {
8779    require_hardened_filesystem("bidirectional sync")?;
8780    require_safe_ref(brain)?;
8781    let head = v2_verified_head(cfg, brain)?.ok_or_else(|| LinkError::InvalidPack {
8782        message:
8783            "bidirectional sync requires link.md v2; use --pull-only or --push-only for a legacy brain"
8784                .to_string(),
8785    })?;
8786    let pulled = v2_sync_pull_with_resolution(cfg, brain, head, Some(checkout), None)?;
8787    let store = Store::open_strict(checkout).map_err(|error| LinkError::InvalidPack {
8788        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
8789    })?;
8790    let _transaction = store.transaction()?;
8791    let pulled_report = pulled.report.clone();
8792    let pulled_head = pulled.head.clone();
8793    let mut result = v2_sync_push(
8794        cfg,
8795        brain,
8796        &store,
8797        pulled_head,
8798        V2SyncPushOptions {
8799            resume_local_policy,
8800            bulk_confirmation,
8801            resolution: None,
8802            pulled: Some(pulled),
8803            withdrawal_paths,
8804            withdrawal_reason,
8805        },
8806    )?;
8807    if let Some(object) = result.as_object_mut() {
8808        object.insert("pulled_files".to_string(), json!(pulled_report.files));
8809        object.insert("checkout".to_string(), Value::String(pulled_report.dest));
8810        object.insert(
8811            "mode".to_string(),
8812            Value::String("bidirectional".to_string()),
8813        );
8814    }
8815    Ok(result)
8816}
8817
8818/// Pull the granted slice of `brain` to `out` (default: `./<slug>`). Every
8819/// exported path is safety-gated before it touches disk; files are written
8820/// atomically; nothing local is ever deleted (locals the export lacks are
8821/// *reported* in `extra_local` instead). Returns the report; rebuilding the
8822/// local index catalog afterwards is the caller's (cheap, optional) step.
8823pub fn sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8824    require_hardened_filesystem("sync pull")?;
8825    require_safe_ref(brain)?;
8826    if let Some(head) = v2_verified_head(cfg, brain)? {
8827        return v2_sync_pull(cfg, brain, head, out);
8828    }
8829    legacy_sync_pull(cfg, brain, out)
8830}
8831
8832#[cfg(windows)]
8833fn legacy_sync_pull(_cfg: &HubConfig, _brain: &str, _out: Option<&Path>) -> LinkResult<PullReport> {
8834    Err(LinkError::UnsupportedPlatform {
8835        operation: "legacy v1 whole-snapshot pull on Windows; upgrade the brain to link.md v2",
8836    })
8837}
8838
8839#[cfg(not(windows))]
8840fn legacy_sync_pull(cfg: &HubConfig, brain: &str, out: Option<&Path>) -> LinkResult<PullReport> {
8841    let remote = verified_remote_head(cfg, brain, false)?;
8842    if !remote.head.verified {
8843        return Err(invalid_feed(
8844            "the grant exposes head movement but no signed snapshot; refusing an unverifiable pull",
8845        ));
8846    }
8847    let snapshot_hash = remote.head.feed_hash.as_deref().unwrap_or("none");
8848    let path = format!(
8849        "/api/hub/brains/{brain}/export?format=pack&atSeq={}&feedHash={snapshot_hash}",
8850        remote.head.seq
8851    );
8852    let body = ensure_ok(
8853        request(cfg, "GET", &path, None, Auth::Required)?,
8854        "sync pull",
8855    )?;
8856    if body.get("headSeq").and_then(Value::as_u64) != Some(remote.head.seq)
8857        || body.get("feedHash").and_then(Value::as_str) != remote.head.feed_hash.as_deref()
8858    {
8859        return Err(invalid_feed(
8860            "export response is not bound to the verified snapshot token",
8861        ));
8862    }
8863
8864    let remote_slug = body
8865        .get("slug")
8866        .and_then(Value::as_str)
8867        .filter(|slug| is_safe_slug(slug));
8868    let slug = remote_slug
8869        .or_else(|| is_safe_slug(brain).then_some(brain))
8870        .unwrap_or("brain")
8871        .to_string();
8872    let brain_id = body
8873        .get("brain")
8874        .and_then(Value::as_str)
8875        .unwrap_or(&remote.head.brain)
8876        .to_string();
8877    if brain_id != remote.head.brain {
8878        return Err(invalid_feed(
8879            "export response names a different brain than the verified head",
8880        ));
8881    }
8882    let head_seq = remote.head.seq;
8883    let dest: PathBuf = match out {
8884        Some(p) => p.to_path_buf(),
8885        None => PathBuf::from(&slug),
8886    };
8887    let entries = if head_seq == 0 {
8888        let files = body
8889            .get("files")
8890            .and_then(Value::as_array)
8891            .ok_or_else(|| invalid_feed("empty snapshot export did not carry an empty manifest"))?;
8892        if !files.is_empty() || body.get("url").is_some() {
8893            return Err(invalid_feed(
8894                "empty signed feed cannot authorize non-empty exported content",
8895            ));
8896        }
8897        Vec::new()
8898    } else {
8899        let signed_head = remote
8900            .head_entry
8901            .as_ref()
8902            .ok_or_else(|| invalid_feed("verified snapshot has no signed head entry"))?;
8903        let expected = &signed_head.entry.pack_sha256;
8904        if !is_sha256(expected) {
8905            return Err(invalid_feed(
8906                "signed head carries an invalid snapshot pack digest",
8907            ));
8908        }
8909        if let Some(url) = body.get("url").and_then(Value::as_str) {
8910            if body.get("sha256").and_then(Value::as_str) != Some(expected.as_str()) {
8911                return Err(invalid_feed(
8912                    "export pack digest does not match the signed head entry",
8913                ));
8914            }
8915            let bytes = get_presigned(cfg, url)?;
8916            let actual = format!("{:x}", Sha256::digest(&bytes));
8917            if actual != *expected {
8918                return Err(LinkError::InvalidPack {
8919                    message: "downloaded pack does not match the signed snapshot digest"
8920                        .to_string(),
8921                });
8922            }
8923            let entries = parse_store_pack(bytes)?;
8924            if signed_head.entry.kind == "push" {
8925                verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8926            }
8927            entries
8928        } else {
8929            if signed_head.entry.kind != "push" {
8930                return Err(invalid_feed(
8931                    "delta snapshots must export the exact signed pack",
8932                ));
8933            }
8934            let files = body.get("files").and_then(Value::as_array).ok_or_else(|| {
8935                invalid_feed("verified snapshot export carried neither a pack nor files")
8936            })?;
8937            let mut entries = Vec::with_capacity(files.len());
8938            for file in files {
8939                let path = file
8940                    .get("path")
8941                    .and_then(Value::as_str)
8942                    .ok_or_else(|| invalid_feed("exported file path is not a string"))?;
8943                let content = file
8944                    .get("content")
8945                    .and_then(Value::as_str)
8946                    .ok_or_else(|| invalid_feed("exported inline content is not UTF-8 text"))?;
8947                entries.push((path.to_string(), content.as_bytes().to_vec()));
8948            }
8949            verify_snapshot_manifest(&entries, &signed_head.entry.files)?;
8950            entries
8951        }
8952    };
8953
8954    // Gate the complete manifest before the first filesystem mutation.
8955    let mut seen = std::collections::HashSet::new();
8956    for (path, _) in &entries {
8957        if !safe_store_rel_path(path) {
8958            return Err(LinkError::UnsafePath { path: path.clone() });
8959        }
8960        if !seen.insert(path) {
8961            return Err(LinkError::InvalidPack {
8962                message: format!("duplicate path `{path}`"),
8963            });
8964        }
8965    }
8966    // Compute divergence against the still-live tree. The staged clone keeps
8967    // these extra locals byte-for-byte while overlaying the signed snapshot.
8968    let pulled: std::collections::BTreeSet<&str> =
8969        entries.iter().map(|(p, _)| p.as_str()).collect();
8970    let mut extra_local = Vec::new();
8971    if let Ok(store) = Store::open(&dest) {
8972        if let Ok(walked) = store.walk() {
8973            for rel in walked {
8974                let rel_str = rel.to_string_lossy().replace('\\', "/");
8975                if !pulled.contains(rel_str.as_str()) {
8976                    extra_local.push(rel_str);
8977                }
8978            }
8979        }
8980    }
8981    #[cfg(unix)]
8982    install_pulled_snapshot(&dest, &entries)?;
8983
8984    Ok(PullReport {
8985        brain: brain_id,
8986        slug,
8987        head_seq,
8988        files: entries.len(),
8989        dest: dest.to_string_lossy().into_owned(),
8990        extra_local,
8991        sync_status: "synced".to_string(),
8992    })
8993}
8994
8995#[cfg(unix)]
8996fn c_name(bytes: &[u8], display: &str) -> LinkResult<std::ffi::CString> {
8997    std::ffi::CString::new(bytes).map_err(|_| LinkError::UnsafePath {
8998        path: display.to_string(),
8999    })
9000}
9001
9002#[cfg(unix)]
9003fn open_dir_at(
9004    parent: std::os::fd::RawFd,
9005    name: &std::ffi::CStr,
9006    display: &str,
9007) -> LinkResult<std::fs::File> {
9008    use std::os::fd::FromRawFd as _;
9009    let fd = unsafe {
9010        libc::openat(
9011            parent,
9012            name.as_ptr(),
9013            libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9014        )
9015    };
9016    if fd < 0 {
9017        return Err(LinkError::UnsafePath {
9018            path: display.to_string(),
9019        });
9020    }
9021    Ok(unsafe { std::fs::File::from_raw_fd(fd) })
9022}
9023
9024/// Open (and, where absent, create) a directory path without following a
9025/// symlink in any component. The returned directory capability remains bound
9026/// to the opened inode even if an attacker renames or replaces an ancestor.
9027#[cfg(unix)]
9028fn open_dir_path_nofollow(path: &Path, create: bool) -> LinkResult<std::fs::File> {
9029    use std::os::fd::AsRawFd as _;
9030
9031    // macOS exposes a few system-owned compatibility symlinks at the root.
9032    // Normalize only those fixed OS aliases; never canonicalize an arbitrary
9033    // caller-controlled ancestor.
9034    #[cfg(target_os = "macos")]
9035    let normalized = [("/var", "/private/var"), ("/tmp", "/private/tmp")]
9036        .into_iter()
9037        .find_map(|(alias, real)| {
9038            path.strip_prefix(alias)
9039                .ok()
9040                .map(|rest| Path::new(real).join(rest))
9041        })
9042        .unwrap_or_else(|| path.to_path_buf());
9043    #[cfg(not(target_os = "macos"))]
9044    let normalized = path.to_path_buf();
9045
9046    let start = if normalized.is_absolute() {
9047        std::fs::File::open("/")?
9048    } else {
9049        std::fs::File::open(".")?
9050    };
9051    let mut directory = start;
9052    for component in normalized.components() {
9053        use std::path::Component;
9054        let name = match component {
9055            Component::RootDir | Component::CurDir => continue,
9056            Component::Normal(name) => name,
9057            Component::ParentDir | Component::Prefix(_) => {
9058                return Err(LinkError::UnsafePath {
9059                    path: path.display().to_string(),
9060                });
9061            }
9062        };
9063        use std::os::unix::ffi::OsStrExt as _;
9064        let name = c_name(name.as_bytes(), &path.display().to_string())?;
9065        if create {
9066            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9067            if made != 0 {
9068                let error = std::io::Error::last_os_error();
9069                if error.raw_os_error() != Some(libc::EEXIST) {
9070                    return Err(error.into());
9071                }
9072            }
9073        }
9074        directory = open_dir_at(directory.as_raw_fd(), &name, &path.display().to_string())?;
9075    }
9076    Ok(directory)
9077}
9078
9079#[cfg(unix)]
9080fn open_or_create_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9081    open_dir_path_nofollow(path, true)
9082}
9083
9084#[cfg(unix)]
9085fn open_existing_dir_nofollow(path: &Path) -> LinkResult<std::fs::File> {
9086    open_dir_path_nofollow(path, false)
9087}
9088
9089#[cfg(unix)]
9090fn entry_is_dir_at(parent: std::os::fd::RawFd, name: &std::ffi::CStr) -> LinkResult<Option<bool>> {
9091    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9092    let result =
9093        unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) };
9094    if result == 0 {
9095        return Ok(Some((stat.st_mode & libc::S_IFMT) == libc::S_IFDIR));
9096    }
9097    let error = std::io::Error::last_os_error();
9098    if error.kind() == std::io::ErrorKind::NotFound {
9099        Ok(None)
9100    } else {
9101        Err(error.into())
9102    }
9103}
9104
9105#[cfg(unix)]
9106fn create_dir_exclusive_at(
9107    parent: std::os::fd::RawFd,
9108    name: &std::ffi::CStr,
9109    display: &str,
9110) -> LinkResult<std::fs::File> {
9111    let made = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
9112    if made != 0 {
9113        return Err(LinkError::UnsafePath {
9114            path: display.to_string(),
9115        });
9116    }
9117    open_dir_at(parent, name, display)
9118}
9119
9120#[cfg(unix)]
9121fn directory_entry_names(directory: &std::fs::File) -> LinkResult<Vec<std::ffi::CString>> {
9122    use std::os::fd::AsRawFd as _;
9123
9124    let duplicate = unsafe { libc::dup(directory.as_raw_fd()) };
9125    if duplicate < 0 {
9126        return Err(std::io::Error::last_os_error().into());
9127    }
9128    let stream = unsafe { libc::fdopendir(duplicate) };
9129    if stream.is_null() {
9130        let error = std::io::Error::last_os_error();
9131        unsafe {
9132            libc::close(duplicate);
9133        }
9134        return Err(error.into());
9135    }
9136    let mut names = Vec::new();
9137    loop {
9138        let entry = unsafe { libc::readdir(stream) };
9139        if entry.is_null() {
9140            break;
9141        }
9142        let raw = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) };
9143        if raw.to_bytes() != b"." && raw.to_bytes() != b".." {
9144            names.push(raw.to_owned());
9145        }
9146    }
9147    if unsafe { libc::closedir(stream) } != 0 {
9148        return Err(std::io::Error::last_os_error().into());
9149    }
9150    Ok(names)
9151}
9152
9153/// Remove an entry tree relative to a held directory capability. Symlinks are
9154/// unlinked, never traversed, even when an old mirror contains hostile names.
9155#[cfg(unix)]
9156fn remove_tree_at(
9157    parent: std::os::fd::RawFd,
9158    name: &std::ffi::CStr,
9159    display: &str,
9160) -> LinkResult<()> {
9161    use std::os::fd::AsRawFd as _;
9162
9163    match entry_is_dir_at(parent, name)? {
9164        None => return Ok(()),
9165        Some(false) => {
9166            if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } != 0 {
9167                return Err(std::io::Error::last_os_error().into());
9168            }
9169        }
9170        Some(true) => {
9171            let directory = open_dir_at(parent, name, display)?;
9172            for child in directory_entry_names(&directory)? {
9173                let child_display =
9174                    format!("{display}/{}", String::from_utf8_lossy(child.to_bytes()));
9175                remove_tree_at(directory.as_raw_fd(), &child, &child_display)?;
9176            }
9177            drop(directory);
9178            if unsafe { libc::unlinkat(parent, name.as_ptr(), libc::AT_REMOVEDIR) } != 0 {
9179                return Err(std::io::Error::last_os_error().into());
9180            }
9181        }
9182    }
9183    Ok(())
9184}
9185
9186/// Clone a live destination into a private sibling stage without following
9187/// any symlink. Regular files are streamed between held descriptors; symlinks
9188/// are reproduced as links, never opened. Special files are refused.
9189#[cfg(unix)]
9190fn clone_tree_contents(
9191    source: &std::fs::File,
9192    destination: &std::fs::File,
9193    display: &str,
9194) -> LinkResult<()> {
9195    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9196
9197    for name in directory_entry_names(source)? {
9198        let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9199        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
9200        if unsafe {
9201            libc::fstatat(
9202                source.as_raw_fd(),
9203                name.as_ptr(),
9204                &mut stat,
9205                libc::AT_SYMLINK_NOFOLLOW,
9206            )
9207        } != 0
9208        {
9209            return Err(std::io::Error::last_os_error().into());
9210        }
9211        match stat.st_mode & libc::S_IFMT {
9212            libc::S_IFDIR => {
9213                if unsafe {
9214                    libc::mkdirat(destination.as_raw_fd(), name.as_ptr(), stat.st_mode & 0o777)
9215                } != 0
9216                {
9217                    return Err(std::io::Error::last_os_error().into());
9218                }
9219                let source_child = open_dir_at(source.as_raw_fd(), &name, &child_display)?;
9220                let destination_child =
9221                    open_dir_at(destination.as_raw_fd(), &name, &child_display)?;
9222                clone_tree_contents(&source_child, &destination_child, &child_display)?;
9223                destination_child.sync_all()?;
9224            }
9225            libc::S_IFREG => {
9226                let source_fd = unsafe {
9227                    libc::openat(
9228                        source.as_raw_fd(),
9229                        name.as_ptr(),
9230                        libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9231                    )
9232                };
9233                if source_fd < 0 {
9234                    return Err(std::io::Error::last_os_error().into());
9235                }
9236                let destination_fd = unsafe {
9237                    libc::openat(
9238                        destination.as_raw_fd(),
9239                        name.as_ptr(),
9240                        libc::O_WRONLY
9241                            | libc::O_CREAT
9242                            | libc::O_EXCL
9243                            | libc::O_CLOEXEC
9244                            | libc::O_NOFOLLOW,
9245                        (stat.st_mode & 0o777) as libc::c_uint,
9246                    )
9247                };
9248                if destination_fd < 0 {
9249                    unsafe {
9250                        libc::close(source_fd);
9251                    }
9252                    return Err(std::io::Error::last_os_error().into());
9253                }
9254                let mut input = unsafe { std::fs::File::from_raw_fd(source_fd) };
9255                let mut output = unsafe { std::fs::File::from_raw_fd(destination_fd) };
9256                std::io::copy(&mut input, &mut output)?;
9257                output.sync_all()?;
9258            }
9259            libc::S_IFLNK => {
9260                let mut target = vec![0_u8; 4097];
9261                let length = unsafe {
9262                    libc::readlinkat(
9263                        source.as_raw_fd(),
9264                        name.as_ptr(),
9265                        target.as_mut_ptr().cast(),
9266                        target.len(),
9267                    )
9268                };
9269                if length < 0 || length as usize >= target.len() {
9270                    return Err(LinkError::UnsafePath {
9271                        path: child_display,
9272                    });
9273                }
9274                target.truncate(length as usize);
9275                let target = c_name(&target, &child_display)?;
9276                if unsafe {
9277                    libc::symlinkat(target.as_ptr(), destination.as_raw_fd(), name.as_ptr())
9278                } != 0
9279                {
9280                    return Err(std::io::Error::last_os_error().into());
9281                }
9282            }
9283            _ => {
9284                return Err(LinkError::UnsafePath {
9285                    path: child_display,
9286                });
9287            }
9288        }
9289    }
9290    destination.sync_all()?;
9291    Ok(())
9292}
9293
9294#[cfg(target_os = "linux")]
9295fn install_stage_at(
9296    parent: std::os::fd::RawFd,
9297    stage: &std::ffi::CStr,
9298    dest: &std::ffi::CStr,
9299    dest_exists: bool,
9300) -> LinkResult<()> {
9301    let flags = if dest_exists {
9302        libc::RENAME_EXCHANGE
9303    } else {
9304        libc::RENAME_NOREPLACE
9305    };
9306    // `libc::renameat2` is not exported for the musl targets we ship. Invoke
9307    // the kernel ABI directly, as `fsx::renameat_noreplace` does, so the same
9308    // atomic exchange/no-replace boundary compiles for glibc and musl.
9309    let result = unsafe {
9310        libc::syscall(
9311            libc::SYS_renameat2,
9312            parent,
9313            stage.as_ptr(),
9314            parent,
9315            dest.as_ptr(),
9316            flags,
9317        )
9318    };
9319    if result == 0 {
9320        Ok(())
9321    } else {
9322        Err(std::io::Error::last_os_error().into())
9323    }
9324}
9325
9326#[cfg(target_os = "macos")]
9327fn install_stage_at(
9328    parent: std::os::fd::RawFd,
9329    stage: &std::ffi::CStr,
9330    dest: &std::ffi::CStr,
9331    dest_exists: bool,
9332) -> LinkResult<()> {
9333    let flags = if dest_exists {
9334        libc::RENAME_SWAP
9335    } else {
9336        libc::RENAME_EXCL
9337    };
9338    let result =
9339        unsafe { libc::renameatx_np(parent, stage.as_ptr(), parent, dest.as_ptr(), flags) };
9340    if result == 0 {
9341        Ok(())
9342    } else {
9343        Err(std::io::Error::last_os_error().into())
9344    }
9345}
9346
9347#[cfg(unix)]
9348fn write_pull_entries_beneath_dir(
9349    root: &std::fs::File,
9350    entries: &[(String, Vec<u8>)],
9351) -> LinkResult<()> {
9352    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9353
9354    for (path, content) in entries {
9355        let components: Vec<&str> = path.split('/').collect();
9356        let (leaf, parents) = components
9357            .split_last()
9358            .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9359        let mut directory = root.try_clone()?;
9360        for component in parents {
9361            let name = c_name(component.as_bytes(), path)?;
9362            let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9363            if made != 0 {
9364                let error = std::io::Error::last_os_error();
9365                if error.raw_os_error() != Some(libc::EEXIST) {
9366                    return Err(error.into());
9367                }
9368            }
9369            directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9370        }
9371
9372        let leaf_name = c_name(leaf.as_bytes(), path)?;
9373        let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9374        let inspected = unsafe {
9375            libc::fstatat(
9376                directory.as_raw_fd(),
9377                leaf_name.as_ptr(),
9378                &mut existing,
9379                libc::AT_SYMLINK_NOFOLLOW,
9380            )
9381        };
9382        if inspected == 0 && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK {
9383            return Err(LinkError::UnsafePath { path: path.clone() });
9384        }
9385
9386        let nonce = std::time::SystemTime::now()
9387            .duration_since(std::time::UNIX_EPOCH)
9388            .unwrap_or_default()
9389            .as_nanos();
9390        let temp_name = format!(
9391            ".dbmd-pull-{}-{nonce}-{}",
9392            std::process::id(),
9393            content_sha256(format!("{path}\0{}", content.len()).as_bytes())
9394        );
9395        let temp = c_name(temp_name.as_bytes(), path)?;
9396        let fd = unsafe {
9397            libc::openat(
9398                directory.as_raw_fd(),
9399                temp.as_ptr(),
9400                libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9401                0o600,
9402            )
9403        };
9404        if fd < 0 {
9405            return Err(std::io::Error::last_os_error().into());
9406        }
9407        let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9408        if let Err(error) = file.write_all(content).and_then(|_| file.sync_all()) {
9409            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9410            return Err(error.into());
9411        }
9412        drop(file);
9413        let renamed = unsafe {
9414            libc::renameat(
9415                directory.as_raw_fd(),
9416                temp.as_ptr(),
9417                directory.as_raw_fd(),
9418                leaf_name.as_ptr(),
9419            )
9420        };
9421        if renamed != 0 {
9422            let error = std::io::Error::last_os_error();
9423            let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9424            return Err(error.into());
9425        }
9426        directory.sync_all()?;
9427    }
9428    root.sync_all()?;
9429    Ok(())
9430}
9431
9432#[cfg(unix)]
9433fn write_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9434    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9435
9436    let path = &entry.path;
9437    let components: Vec<&str> = path.split('/').collect();
9438    let (leaf, parents) = components
9439        .split_last()
9440        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9441    let mut directory = root.try_clone()?;
9442    for component in parents {
9443        let name = c_name(component.as_bytes(), path)?;
9444        let made = unsafe { libc::mkdirat(directory.as_raw_fd(), name.as_ptr(), 0o700) };
9445        if made != 0 {
9446            let error = std::io::Error::last_os_error();
9447            if error.raw_os_error() != Some(libc::EEXIST) {
9448                return Err(error.into());
9449            }
9450        }
9451        directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9452    }
9453    let leaf_name = c_name(leaf.as_bytes(), path)?;
9454    let mut existing: libc::stat = unsafe { std::mem::zeroed() };
9455    if unsafe {
9456        libc::fstatat(
9457            directory.as_raw_fd(),
9458            leaf_name.as_ptr(),
9459            &mut existing,
9460            libc::AT_SYMLINK_NOFOLLOW,
9461        )
9462    } == 0
9463        && (existing.st_mode & libc::S_IFMT) == libc::S_IFLNK
9464    {
9465        return Err(LinkError::UnsafePath { path: path.clone() });
9466    }
9467    let nonce = SystemTime::now()
9468        .duration_since(UNIX_EPOCH)
9469        .unwrap_or_default()
9470        .as_nanos();
9471    let temp_name = format!(
9472        ".dbmd-pull-{}-{nonce}-{}",
9473        std::process::id(),
9474        content_sha256(path.as_bytes())
9475    );
9476    let temp = c_name(temp_name.as_bytes(), path)?;
9477    let fd = unsafe {
9478        libc::openat(
9479            directory.as_raw_fd(),
9480            temp.as_ptr(),
9481            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9482            0o600,
9483        )
9484    };
9485    if fd < 0 {
9486        return Err(std::io::Error::last_os_error().into());
9487    }
9488    let mut input = crate::fsx::open_regular_nofollow(&entry.source)?;
9489    let mut output = unsafe { std::fs::File::from_raw_fd(fd) };
9490    let mut digest = Sha256::new();
9491    let mut total = 0_u64;
9492    let mut buffer = [0_u8; 64 * 1024];
9493    let copied = (|| -> std::io::Result<()> {
9494        loop {
9495            let read = input.read(&mut buffer)?;
9496            if read == 0 {
9497                break;
9498            }
9499            total = total.saturating_add(read as u64);
9500            if total > entry.bytes {
9501                return Err(std::io::Error::new(
9502                    std::io::ErrorKind::InvalidData,
9503                    "staged sync source grew beyond its verified length",
9504                ));
9505            }
9506            digest.update(&buffer[..read]);
9507            output.write_all(&buffer[..read])?;
9508        }
9509        Ok(())
9510    })();
9511    if let Err(error) = copied {
9512        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9513        return Err(error.into());
9514    }
9515    drop(output);
9516    if total != entry.bytes || format!("{:x}", digest.finalize()) != entry.sha256 {
9517        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9518        return Err(invalid_feed(
9519            "private staged sync source failed final integrity verification",
9520        ));
9521    }
9522    if unsafe {
9523        libc::renameat(
9524            directory.as_raw_fd(),
9525            temp.as_ptr(),
9526            directory.as_raw_fd(),
9527            leaf_name.as_ptr(),
9528        )
9529    } != 0
9530    {
9531        let error = std::io::Error::last_os_error();
9532        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
9533        return Err(error.into());
9534    }
9535    Ok(())
9536}
9537
9538#[cfg(unix)]
9539fn sync_pull_source_beneath_dir(root: &std::fs::File, entry: &V2StagedFile) -> LinkResult<()> {
9540    use std::os::fd::{AsRawFd as _, FromRawFd as _};
9541
9542    let path = &entry.path;
9543    let components: Vec<&str> = path.split('/').collect();
9544    let (leaf, parents) = components
9545        .split_last()
9546        .ok_or_else(|| LinkError::UnsafePath { path: path.clone() })?;
9547    let mut directory = root.try_clone()?;
9548    for component in parents {
9549        directory = open_dir_at(
9550            directory.as_raw_fd(),
9551            &c_name(component.as_bytes(), path)?,
9552            path,
9553        )?;
9554    }
9555    let leaf = c_name(leaf.as_bytes(), path)?;
9556    let fd = unsafe {
9557        libc::openat(
9558            directory.as_raw_fd(),
9559            leaf.as_ptr(),
9560            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
9561        )
9562    };
9563    if fd < 0 {
9564        return Err(std::io::Error::last_os_error().into());
9565    }
9566    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
9567    if file.metadata()?.len() != entry.bytes || content_sha256_reader(&mut file)? != entry.sha256 {
9568        return Err(invalid_feed(
9569            "private pull stage changed before its durability barrier",
9570        ));
9571    }
9572    file.sync_all()?;
9573    Ok(())
9574}
9575
9576#[cfg(unix)]
9577fn run_pull_source_workers(
9578    root: &std::fs::File,
9579    entries: &[V2StagedFile],
9580    operation: fn(&std::fs::File, &V2StagedFile) -> LinkResult<()>,
9581) -> LinkResult<()> {
9582    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
9583
9584    let next = AtomicUsize::new(0);
9585    let failed = AtomicBool::new(false);
9586    let worker_count = entries.len().min(V2_PULL_INSTALL_WORKERS);
9587    let mut first_error = None;
9588    std::thread::scope(|scope| {
9589        let (sender, receiver) = std::sync::mpsc::channel();
9590        for _ in 0..worker_count {
9591            let sender = sender.clone();
9592            let next = &next;
9593            let failed = &failed;
9594            scope.spawn(move || {
9595                while !failed.load(Ordering::Acquire) {
9596                    let index = next.fetch_add(1, Ordering::Relaxed);
9597                    let Some(entry) = entries.get(index) else {
9598                        break;
9599                    };
9600                    let result = operation(root, entry);
9601                    if result.is_err() {
9602                        failed.store(true, Ordering::Release);
9603                    }
9604                    if sender.send(result).is_err() {
9605                        break;
9606                    }
9607                }
9608            });
9609        }
9610        drop(sender);
9611        for result in receiver {
9612            if let Err(error) = result {
9613                if first_error.is_none() {
9614                    first_error = Some(error);
9615                }
9616            }
9617        }
9618    });
9619    if let Some(error) = first_error {
9620        return Err(error);
9621    }
9622    if next.load(Ordering::Relaxed) < entries.len() {
9623        return Err(invalid_feed(
9624            "a bounded pull worker stopped before reporting every file",
9625        ));
9626    }
9627    Ok(())
9628}
9629
9630#[cfg(unix)]
9631fn sync_pull_directory_tree(root: &std::fs::File, display: &str) -> LinkResult<()> {
9632    use std::os::fd::AsRawFd as _;
9633
9634    for name in directory_entry_names(root)? {
9635        if entry_is_dir_at(root.as_raw_fd(), &name)? == Some(true) {
9636            let child_display = format!("{display}/{}", String::from_utf8_lossy(name.to_bytes()));
9637            let child = open_dir_at(root.as_raw_fd(), &name, &child_display)?;
9638            sync_pull_directory_tree(&child, &child_display)?;
9639        }
9640    }
9641    root.sync_all()?;
9642    Ok(())
9643}
9644
9645#[cfg(unix)]
9646fn write_pull_sources_beneath_dir(
9647    root: &std::fs::File,
9648    entries: &[V2StagedFile],
9649) -> LinkResult<()> {
9650    // The stage is still private and unreachable as the checkout here. Write
9651    // and verify every leaf first so the filesystem can coalesce dirty data,
9652    // then re-open/re-hash/fsync leaves through the held root capability. Only
9653    // after every file barrier succeeds do we flush each directory once. This
9654    // preserves the same crash durability as file-fsync + directory-fsync per
9655    // path without serializing 50,000 redundant directory barriers.
9656    run_pull_source_workers(root, entries, write_pull_source_beneath_dir)?;
9657    run_pull_source_workers(root, entries, sync_pull_source_beneath_dir)?;
9658    sync_pull_directory_tree(root, "v2 pull stage")
9659}
9660
9661#[cfg(unix)]
9662fn remove_pull_paths_beneath_dir(root: &std::fs::File, paths: &[String]) -> LinkResult<()> {
9663    use std::os::fd::AsRawFd as _;
9664    for path in paths {
9665        if !safe_store_rel_path(path) {
9666            return Err(LinkError::UnsafePath { path: path.clone() });
9667        }
9668        let components = path.split('/').collect::<Vec<_>>();
9669        let Some((leaf, parents)) = components.split_last() else {
9670            return Err(LinkError::UnsafePath { path: path.clone() });
9671        };
9672        let mut directory = root.try_clone()?;
9673        let mut missing = false;
9674        for component in parents {
9675            let name = c_name(component.as_bytes(), path)?;
9676            match entry_is_dir_at(directory.as_raw_fd(), &name)? {
9677                None => {
9678                    missing = true;
9679                    break;
9680                }
9681                Some(false) => return Err(LinkError::UnsafePath { path: path.clone() }),
9682                Some(true) => {
9683                    directory = open_dir_at(directory.as_raw_fd(), &name, path)?;
9684                }
9685            }
9686        }
9687        if missing {
9688            continue;
9689        }
9690        let leaf = c_name(leaf.as_bytes(), path)?;
9691        match entry_is_dir_at(directory.as_raw_fd(), &leaf)? {
9692            None => {}
9693            Some(true) => return Err(LinkError::UnsafePath { path: path.clone() }),
9694            Some(false) => {
9695                if unsafe { libc::unlinkat(directory.as_raw_fd(), leaf.as_ptr(), 0) } != 0 {
9696                    return Err(std::io::Error::last_os_error().into());
9697                }
9698                directory.sync_all()?;
9699            }
9700        }
9701    }
9702    Ok(())
9703}
9704
9705#[cfg(unix)]
9706fn install_pulled_delta(
9707    dest: &Path,
9708    entries: &[(String, Vec<u8>)],
9709    deleted: &[String],
9710    rebuild_indexes: bool,
9711) -> LinkResult<()> {
9712    use ring::rand::SecureRandom as _;
9713    use std::os::fd::AsRawFd as _;
9714    use std::os::unix::ffi::OsStrExt as _;
9715
9716    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9717    let name = dest
9718        .file_name()
9719        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9720        .ok_or_else(|| LinkError::UnsafePath {
9721            path: dest.display().to_string(),
9722        })?;
9723    let parent_dir = open_or_create_dir_nofollow(parent)?;
9724    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9725    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9726        None => false,
9727        Some(true) => true,
9728        Some(false) => {
9729            return Err(LinkError::UnsafePath {
9730                path: dest.display().to_string(),
9731            });
9732        }
9733    };
9734
9735    let mut nonce = [0_u8; 16];
9736    ring::rand::SystemRandom::new()
9737        .fill(&mut nonce)
9738        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9739    let stage_label = format!(
9740        ".{}.dbmd-pull-stage-{}",
9741        name.to_string_lossy(),
9742        URL_SAFE_NO_PAD.encode(nonce)
9743    );
9744    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9745    let stage_dir = create_dir_exclusive_at(
9746        parent_dir.as_raw_fd(),
9747        &stage_name,
9748        &dest.display().to_string(),
9749    )?;
9750
9751    let prepared = (|| -> LinkResult<()> {
9752        if dest_exists {
9753            let live = open_dir_at(
9754                parent_dir.as_raw_fd(),
9755                &dest_name,
9756                &dest.display().to_string(),
9757            )?;
9758            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9759        }
9760        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9761        write_pull_entries_beneath_dir(&stage_dir, entries)?;
9762        if rebuild_indexes {
9763            let stage_store =
9764                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9765                    .map_err(|error| LinkError::InvalidPack {
9766                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
9767                    })?;
9768            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9769                LinkError::InvalidPack {
9770                    message: format!("could not materialize v2 local catalogs: {error}"),
9771                }
9772            })?;
9773        }
9774        stage_dir.sync_all()?;
9775        Ok(())
9776    })();
9777    if let Err(error) = prepared {
9778        let _ = remove_tree_at(
9779            parent_dir.as_raw_fd(),
9780            &stage_name,
9781            &dest.display().to_string(),
9782        );
9783        return Err(error);
9784    }
9785
9786    if let Err(error) =
9787        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9788    {
9789        let _ = remove_tree_at(
9790            parent_dir.as_raw_fd(),
9791            &stage_name,
9792            &dest.display().to_string(),
9793        );
9794        return Err(error);
9795    }
9796    parent_dir.sync_all()?;
9797    if dest_exists {
9798        // Commit already happened atomically. Cleanup is best-effort so a
9799        // failure cannot be reported as a failed pull after the live tree
9800        // changed; a crash may leave only this private old-tree sibling.
9801        let _ = remove_tree_at(
9802            parent_dir.as_raw_fd(),
9803            &stage_name,
9804            &dest.display().to_string(),
9805        );
9806        let _ = parent_dir.sync_all();
9807    }
9808    Ok(())
9809}
9810
9811#[cfg(unix)]
9812fn install_pulled_delta_sources(
9813    dest: &Path,
9814    entries: &[V2StagedFile],
9815    deleted: &[String],
9816    rebuild_indexes: bool,
9817    _previous: Option<&V2SyncBaseline>,
9818    _next: &V2VerifiedHead,
9819) -> LinkResult<()> {
9820    use ring::rand::SecureRandom as _;
9821    use std::os::fd::AsRawFd as _;
9822    use std::os::unix::ffi::OsStrExt as _;
9823
9824    // An established checkout is updated only at the changed coordinates
9825    // through the durable pull journal shared with Windows. Initial clones
9826    // still use a private complete stage and one atomic directory install.
9827    if let Ok(store) = Store::open_strict(dest) {
9828        return install_established_v2_delta(
9829            store,
9830            entries,
9831            deleted,
9832            rebuild_indexes,
9833            _previous,
9834            _next,
9835        );
9836    }
9837
9838    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
9839    let name = dest
9840        .file_name()
9841        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
9842        .ok_or_else(|| LinkError::UnsafePath {
9843            path: dest.display().to_string(),
9844        })?;
9845    let parent_dir = open_or_create_dir_nofollow(parent)?;
9846    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
9847    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
9848        None => false,
9849        Some(true) => true,
9850        Some(false) => {
9851            return Err(LinkError::UnsafePath {
9852                path: dest.display().to_string(),
9853            })
9854        }
9855    };
9856    let mut nonce = [0_u8; 16];
9857    ring::rand::SystemRandom::new()
9858        .fill(&mut nonce)
9859        .map_err(|_| invalid_feed("could not mint a pull staging name"))?;
9860    let stage_label = format!(
9861        ".{}.dbmd-pull-stage-{}",
9862        name.to_string_lossy(),
9863        URL_SAFE_NO_PAD.encode(nonce)
9864    );
9865    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
9866    let stage_dir = create_dir_exclusive_at(
9867        parent_dir.as_raw_fd(),
9868        &stage_name,
9869        &dest.display().to_string(),
9870    )?;
9871    let prepared = (|| -> LinkResult<()> {
9872        if dest_exists {
9873            let live = open_dir_at(
9874                parent_dir.as_raw_fd(),
9875                &dest_name,
9876                &dest.display().to_string(),
9877            )?;
9878            clone_tree_contents(&live, &stage_dir, &dest.display().to_string())?;
9879        }
9880        remove_pull_paths_beneath_dir(&stage_dir, deleted)?;
9881        write_pull_sources_beneath_dir(&stage_dir, entries)?;
9882        if rebuild_indexes {
9883            let stage_store =
9884                Store::from_held_root_strict(&parent.join(&stage_label), stage_dir.try_clone()?)
9885                    .map_err(|error| LinkError::InvalidPack {
9886                        message: format!("v2 staging tree is not a valid db.md store: {error}"),
9887                    })?;
9888            crate::index::Index::rebuild_all(&stage_store).map_err(|error| {
9889                LinkError::InvalidPack {
9890                    message: format!("could not materialize v2 local catalogs: {error}"),
9891                }
9892            })?;
9893        }
9894        stage_dir.sync_all()?;
9895        Ok(())
9896    })();
9897    if let Err(error) = prepared {
9898        let _ = remove_tree_at(
9899            parent_dir.as_raw_fd(),
9900            &stage_name,
9901            &dest.display().to_string(),
9902        );
9903        return Err(error);
9904    }
9905    if let Err(error) =
9906        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
9907    {
9908        let _ = remove_tree_at(
9909            parent_dir.as_raw_fd(),
9910            &stage_name,
9911            &dest.display().to_string(),
9912        );
9913        return Err(error);
9914    }
9915    parent_dir.sync_all()?;
9916    if dest_exists {
9917        let _ = remove_tree_at(
9918            parent_dir.as_raw_fd(),
9919            &stage_name,
9920            &dest.display().to_string(),
9921        );
9922        let _ = parent_dir.sync_all();
9923    }
9924    Ok(())
9925}
9926
9927#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9928struct V2PullCoordinate {
9929    head_seq: Option<u64>,
9930    commit_hash: Option<String>,
9931    view_kind: Option<String>,
9932    view_revision: Option<String>,
9933}
9934
9935#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9936struct V2PullFileCoordinate {
9937    sha256: String,
9938    bytes: u64,
9939}
9940
9941#[derive(Debug, Clone, Deserialize, Serialize)]
9942struct V2PullJournalEntry {
9943    path: String,
9944    old: Option<V2PullFileCoordinate>,
9945    new: Option<V2PullFileCoordinate>,
9946    backup: Option<String>,
9947}
9948
9949#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
9950#[serde(rename_all = "snake_case")]
9951enum V2PullPhase {
9952    Preparing,
9953    Ready,
9954}
9955
9956#[derive(Debug, Clone, Deserialize, Serialize)]
9957struct V2PullJournal {
9958    v: u8,
9959    phase: V2PullPhase,
9960    brain: String,
9961    previous: V2PullCoordinate,
9962    next: V2PullCoordinate,
9963    backup_dir: String,
9964    entries: Vec<V2PullJournalEntry>,
9965}
9966
9967const V2_PULL_JOURNAL: &str = ".dbmd/pull-journal.json";
9968
9969fn v2_pull_baseline_coordinate(baseline: Option<&V2SyncBaseline>) -> V2PullCoordinate {
9970    V2PullCoordinate {
9971        head_seq: baseline.and_then(|value| value.head_seq),
9972        commit_hash: baseline.and_then(|value| value.commit_hash.clone()),
9973        view_kind: baseline.and_then(|value| value.view_kind.clone()),
9974        view_revision: baseline.and_then(|value| value.view_revision.clone()),
9975    }
9976}
9977
9978fn v2_pull_head_coordinate(head: &V2VerifiedHead) -> V2PullCoordinate {
9979    V2PullCoordinate {
9980        head_seq: head.pointer.as_ref().map(|value| value.seq),
9981        commit_hash: head.pointer.as_ref().map(|value| value.commit_hash.clone()),
9982        view_kind: Some(head.view_kind.clone()),
9983        view_revision: Some(head.view_revision.clone()),
9984    }
9985}
9986
9987fn v2_pull_file_coordinate(
9988    store: &Store,
9989    path: &str,
9990    limit: u64,
9991) -> LinkResult<Option<V2PullFileCoordinate>> {
9992    let file = match store.open_regular(Path::new(path)) {
9993        Ok(file) => file,
9994        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
9995        Err(error) => return Err(error.into()),
9996    };
9997    let bytes = file.metadata()?.len();
9998    if bytes > limit || bytes > MAX_STORE_BYTES {
9999        return Err(invalid_feed(
10000            "pull transaction file exceeds its declared bound",
10001        ));
10002    }
10003    Ok(Some(V2PullFileCoordinate {
10004        sha256: content_sha256_reader(file)?,
10005        bytes,
10006    }))
10007}
10008
10009fn v2_pull_journal_bytes(journal: &V2PullJournal) -> LinkResult<Vec<u8>> {
10010    let mut bytes = serde_json::to_vec_pretty(journal)
10011        .map_err(|_| invalid_feed("could not serialize v2 pull journal"))?;
10012    bytes.push(b'\n');
10013    Ok(bytes)
10014}
10015
10016fn validate_v2_pull_journal(journal: &V2PullJournal) -> LinkResult<()> {
10017    let backup_prefix = ".dbmd/pull-backup-";
10018    let suffix = journal
10019        .backup_dir
10020        .strip_prefix(backup_prefix)
10021        .ok_or_else(|| invalid_feed("v2 pull journal backup address is invalid"))?;
10022    let mut paths = std::collections::BTreeSet::new();
10023    if journal.v != 1
10024        || !crate::ulid::is_ulid(&journal.brain)
10025        || !crate::ulid::is_ulid(suffix)
10026        || journal.entries.is_empty()
10027        || journal.entries.len() > MAX_PUSH_FILES + 4
10028        || journal.previous == journal.next
10029    {
10030        return Err(invalid_feed("v2 pull journal failed validation"));
10031    }
10032    for (index, entry) in journal.entries.iter().enumerate() {
10033        if !safe_store_rel_path(&entry.path)
10034            || entry.path == V2_PULL_JOURNAL
10035            || entry.path.starts_with(backup_prefix)
10036            || !paths.insert(entry.path.clone())
10037            || (entry.old.is_none() && entry.new.is_none())
10038            || entry
10039                .old
10040                .iter()
10041                .chain(entry.new.iter())
10042                .any(|value| !is_sha256(&value.sha256) || value.bytes > MAX_STORE_BYTES)
10043            || entry.backup.as_deref()
10044                != entry
10045                    .old
10046                    .as_ref()
10047                    .map(|_| format!("{index:08x}"))
10048                    .as_deref()
10049        {
10050            return Err(invalid_feed("v2 pull journal entry failed validation"));
10051        }
10052    }
10053    Ok(())
10054}
10055
10056fn load_v2_pull_journal(store: &Store) -> LinkResult<Option<V2PullJournal>> {
10057    #[cfg(unix)]
10058    {
10059        use std::os::unix::fs::PermissionsExt as _;
10060        match store.regular_metadata(Path::new(V2_PULL_JOURNAL)) {
10061            Ok(metadata) if metadata.permissions().mode() & 0o077 != 0 => {
10062                return Err(invalid_feed(
10063                    "v2 pull journal is accessible to group/other; set mode 0600",
10064                ));
10065            }
10066            Ok(_) => {}
10067            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10068            Err(error) => return Err(error.into()),
10069        }
10070    }
10071    let bytes = match store.read_bounded(Path::new(V2_PULL_JOURNAL), 64 * 1024 * 1024) {
10072        Ok(bytes) => bytes,
10073        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
10074        Err(error) => return Err(error.into()),
10075    };
10076    let journal: V2PullJournal =
10077        serde_json::from_slice(&bytes).map_err(|_| invalid_feed("v2 pull journal is corrupt"))?;
10078    validate_v2_pull_journal(&journal)?;
10079    Ok(Some(journal))
10080}
10081
10082fn cleanup_v2_pull_journal(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10083    // The journal is the only recovery authority. Remove it first only after
10084    // rollback or baseline commit is durable; an orphan private backup is safe
10085    // and can be pruned, while a journal without its backups is not recoverable.
10086    match store.remove_file(Path::new(V2_PULL_JOURNAL)) {
10087        Ok(()) => {}
10088        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
10089        Err(error) => return Err(error.into()),
10090    }
10091    match store.remove_private_tree(Path::new(&journal.backup_dir)) {
10092        Ok(()) => Ok(()),
10093        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
10094        Err(error) => Err(error.into()),
10095    }
10096}
10097
10098fn prune_orphan_v2_pull_backups(store: &Store) -> LinkResult<()> {
10099    let names = match store.directory_names(Path::new(".dbmd")) {
10100        Ok(names) => names,
10101        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
10102        Err(error) => return Err(error.into()),
10103    };
10104    for name in names {
10105        let Some(name) = name.to_str() else {
10106            continue;
10107        };
10108        let Some(suffix) = name.strip_prefix("pull-backup-") else {
10109            continue;
10110        };
10111        if crate::ulid::is_ulid(suffix) {
10112            store.remove_private_tree(&Path::new(".dbmd").join(name))?;
10113        }
10114    }
10115    Ok(())
10116}
10117
10118fn rollback_v2_pull(store: &Store, journal: &V2PullJournal) -> LinkResult<()> {
10119    // Validate every live coordinate and every backup before the first restore.
10120    for entry in &journal.entries {
10121        let limit = entry
10122            .old
10123            .as_ref()
10124            .into_iter()
10125            .chain(entry.new.iter())
10126            .map(|value| value.bytes)
10127            .max()
10128            .unwrap_or(0);
10129        let current = v2_pull_file_coordinate(store, &entry.path, limit)?;
10130        if current != entry.old && current != entry.new {
10131            return Err(LinkError::InvalidPack {
10132                message: format!(
10133                    "cannot recover interrupted pull because `{}` changed afterward",
10134                    entry.path
10135                ),
10136            });
10137        }
10138        if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10139            let path = Path::new(&journal.backup_dir).join(backup);
10140            let file = store.open_regular(&path)?;
10141            if file.metadata()?.len() != old.bytes || content_sha256_reader(file)? != old.sha256 {
10142                return Err(invalid_feed("v2 pull recovery backup failed verification"));
10143            }
10144        }
10145    }
10146    for entry in journal.entries.iter().rev() {
10147        match (&entry.old, &entry.backup) {
10148            (Some(old), Some(backup)) => {
10149                let bytes =
10150                    store.read_bounded(&Path::new(&journal.backup_dir).join(backup), old.bytes)?;
10151                store.write_atomic(Path::new(&entry.path), &bytes)?;
10152            }
10153            (None, None) if store.regular_file_exists(Path::new(&entry.path))? => {
10154                store.remove_file(Path::new(&entry.path))?;
10155            }
10156            (None, None) => {}
10157            _ => return Err(invalid_feed("v2 pull recovery entry is inconsistent")),
10158        }
10159    }
10160    crate::index::Index::rebuild_all(store).map_err(|error| LinkError::InvalidPack {
10161        message: format!("could not rebuild catalogs after pull recovery: {error}"),
10162    })?;
10163    cleanup_v2_pull_journal(store, journal)
10164}
10165
10166fn recover_v2_pull(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<()> {
10167    let Ok(store) = Store::open_strict(dest) else {
10168        return Ok(());
10169    };
10170    if let Some(journal) = load_v2_pull_journal(&store)? {
10171        if journal.brain != brain {
10172            return Err(invalid_feed("v2 pull journal belongs to another brain"));
10173        }
10174        if journal.phase == V2PullPhase::Preparing {
10175            cleanup_v2_pull_journal(&store, &journal)?;
10176        } else {
10177            let baseline = load_v2_baseline(cfg, brain, dest)?;
10178            let current = v2_pull_baseline_coordinate(baseline.as_ref());
10179            if current == journal.next {
10180                cleanup_v2_pull_journal(&store, &journal)?;
10181            } else {
10182                if current != journal.previous {
10183                    return Err(invalid_feed(
10184                        "cannot recover interrupted pull because its baseline changed afterward",
10185                    ));
10186                }
10187                rollback_v2_pull(&store, &journal)?;
10188            }
10189        }
10190    }
10191    // Cleanup removes the journal first after the state is durably old or new.
10192    // A hard kill in the tiny interval before deleting its backup directory can
10193    // therefore leave only an inert, private orphan. The per-brain operation
10194    // lock is held by the caller, so no live pull can own one here.
10195    prune_orphan_v2_pull_backups(&store)
10196}
10197
10198fn complete_v2_pull(dest: &Path) -> LinkResult<()> {
10199    let store = Store::open_strict(dest).map_err(|error| LinkError::InvalidPack {
10200        message: format!("installed v2 checkout is not a valid db.md store: {error}"),
10201    })?;
10202    if let Some(journal) = load_v2_pull_journal(&store)? {
10203        cleanup_v2_pull_journal(&store, &journal)?;
10204    }
10205    Ok(())
10206}
10207
10208#[cfg(windows)]
10209fn install_windows_initial_sources(
10210    dest: &Path,
10211    entries: &[V2StagedFile],
10212    rebuild_indexes: bool,
10213) -> LinkResult<()> {
10214    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
10215    let name = dest.file_name().ok_or_else(|| LinkError::UnsafePath {
10216        path: dest.display().to_string(),
10217    })?;
10218    let parent_capability = crate::fsx::open_or_create_directory_nofollow(parent)?;
10219    if crate::fsx::directory_exists_beneath(&parent_capability, Path::new(name))? {
10220        return Err(LinkError::UnsafePath {
10221            path: dest.display().to_string(),
10222        });
10223    }
10224    let stage_name = format!(
10225        ".{}.dbmd-pull-stage-{}",
10226        name.to_string_lossy(),
10227        crate::ulid::mint()
10228    );
10229    let stage_path = parent.join(&stage_name);
10230    let stage_capability =
10231        crate::fsx::open_directory_beneath(&parent_capability, Path::new(&stage_name), true)?;
10232    let stage = Store::from_root_and_config(&stage_path, crate::Config::default())?;
10233    let prepared = (|| -> LinkResult<()> {
10234        for entry in entries {
10235            let bytes = crate::fsx::read_bounded_nofollow(&entry.source, entry.bytes)?;
10236            if bytes.len() as u64 != entry.bytes || content_sha256(&bytes) != entry.sha256 {
10237                return Err(invalid_feed(
10238                    "private staged sync source failed final integrity verification",
10239                ));
10240            }
10241            stage.write_atomic(Path::new(&entry.path), &bytes)?;
10242        }
10243        let strict = Store::from_held_root_strict(&stage_path, stage_capability.try_clone()?)
10244            .map_err(|error| LinkError::InvalidPack {
10245                message: format!("v2 staging tree is not a valid db.md store: {error}"),
10246            })?;
10247        if rebuild_indexes {
10248            crate::index::Index::rebuild_all(&strict).map_err(|error| LinkError::InvalidPack {
10249                message: format!("could not materialize v2 local catalogs: {error}"),
10250            })?;
10251        }
10252        Ok(())
10253    })();
10254    if let Err(error) = prepared {
10255        let _ = crate::fsx::remove_tree_beneath(&parent_capability, Path::new(&stage_name));
10256        return Err(error);
10257    }
10258    crate::fsx::rename_directory_beneath(
10259        &parent_capability,
10260        Path::new(&stage_name),
10261        Path::new(name),
10262    )?;
10263    Ok(())
10264}
10265
10266fn install_established_v2_delta(
10267    store: Store,
10268    entries: &[V2StagedFile],
10269    deleted: &[String],
10270    rebuild_indexes: bool,
10271    previous: Option<&V2SyncBaseline>,
10272    next: &V2VerifiedHead,
10273) -> LinkResult<()> {
10274    if load_v2_pull_journal(&store)?.is_some() {
10275        return Err(invalid_feed(
10276            "an interrupted pull must be recovered before installing",
10277        ));
10278    }
10279    let mut sources = std::collections::BTreeMap::new();
10280    for entry in entries {
10281        if sources.insert(entry.path.clone(), entry).is_some() || deleted.contains(&entry.path) {
10282            return Err(invalid_feed("pull mutation repeats a path"));
10283        }
10284    }
10285    let mut paths = sources.keys().cloned().collect::<Vec<_>>();
10286    paths.extend(deleted.iter().cloned());
10287    paths.sort();
10288    paths.dedup();
10289    if paths.is_empty() {
10290        return Ok(());
10291    }
10292    let backup_dir = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
10293    let mut journal = V2PullJournal {
10294        v: 1,
10295        phase: V2PullPhase::Preparing,
10296        brain: next.brain_id.clone(),
10297        previous: v2_pull_baseline_coordinate(previous),
10298        next: v2_pull_head_coordinate(next),
10299        backup_dir: backup_dir.clone(),
10300        entries: Vec::with_capacity(paths.len()),
10301    };
10302    for path in &paths {
10303        let old = v2_pull_file_coordinate(&store, path, MAX_STORE_BYTES)?;
10304        let new = sources.get(path).map(|entry| V2PullFileCoordinate {
10305            sha256: entry.sha256.clone(),
10306            bytes: entry.bytes,
10307        });
10308        if old == new {
10309            continue;
10310        }
10311        let index = journal.entries.len();
10312        journal.entries.push(V2PullJournalEntry {
10313            path: path.clone(),
10314            backup: old.as_ref().map(|_| format!("{index:08x}")),
10315            old,
10316            new,
10317        });
10318    }
10319    if journal.entries.is_empty() {
10320        return Ok(());
10321    }
10322    let backup_bytes = journal.entries.iter().try_fold(0_u64, |total, entry| {
10323        entry
10324            .old
10325            .as_ref()
10326            .map_or(Some(total), |old| total.checked_add(old.bytes))
10327    });
10328    if backup_bytes.is_none_or(|bytes| bytes > MAX_STORE_BYTES) {
10329        return Err(LinkError::InvalidPack {
10330            message: "pull recovery preimages exceed the 512 MB transaction limit".to_string(),
10331        });
10332    }
10333    validate_v2_pull_journal(&journal)?;
10334    store.write_private_atomic_new(
10335        Path::new(V2_PULL_JOURNAL),
10336        &v2_pull_journal_bytes(&journal)?,
10337    )?;
10338    let prepared = (|| -> LinkResult<()> {
10339        store.create_private_dir_all(Path::new(&backup_dir))?;
10340        for entry in &journal.entries {
10341            if let (Some(old), Some(backup)) = (&entry.old, &entry.backup) {
10342                let bytes = store.read_bounded(Path::new(&entry.path), old.bytes)?;
10343                if content_sha256(&bytes) != old.sha256 {
10344                    return Err(invalid_feed("live pull source changed during backup"));
10345                }
10346                store.write_private_atomic_new(&Path::new(&backup_dir).join(backup), &bytes)?;
10347            }
10348        }
10349        journal.phase = V2PullPhase::Ready;
10350        store.write_private_atomic(
10351            Path::new(V2_PULL_JOURNAL),
10352            &v2_pull_journal_bytes(&journal)?,
10353        )?;
10354        Ok(())
10355    })();
10356    if let Err(error) = prepared {
10357        let cleanup = cleanup_v2_pull_journal(&store, &journal);
10358        return match cleanup {
10359            Ok(()) => Err(error),
10360            Err(cleanup) => Err(LinkError::InvalidPack {
10361                message: format!("{error}; recovery metadata cleanup also failed: {cleanup}"),
10362            }),
10363        };
10364    }
10365    let installed = (|| -> LinkResult<()> {
10366        for entry in &journal.entries {
10367            if v2_pull_file_coordinate(&store, &entry.path, MAX_STORE_BYTES)? != entry.old {
10368                return Err(LinkError::InvalidPack {
10369                    message: format!("local path `{}` changed during pull", entry.path),
10370                });
10371            }
10372            if let Some(source) = sources.get(&entry.path) {
10373                let bytes = crate::fsx::read_bounded_nofollow(&source.source, source.bytes)?;
10374                if bytes.len() as u64 != source.bytes || content_sha256(&bytes) != source.sha256 {
10375                    return Err(invalid_feed(
10376                        "private staged sync source failed final integrity verification",
10377                    ));
10378                }
10379                store.write_atomic(Path::new(&entry.path), &bytes)?;
10380            } else if entry.old.is_some() {
10381                store.remove_file(Path::new(&entry.path))?;
10382            }
10383        }
10384        if rebuild_indexes {
10385            crate::index::Index::rebuild_all(&store).map_err(|error| LinkError::InvalidPack {
10386                message: format!("could not materialize v2 local catalogs: {error}"),
10387            })?;
10388        }
10389        Ok(())
10390    })();
10391    if let Err(error) = installed {
10392        return match rollback_v2_pull(&store, &journal) {
10393            Ok(()) => Err(error),
10394            Err(rollback) => Err(LinkError::InvalidPack {
10395                message: format!("{error}; durable pull rollback also failed: {rollback}"),
10396            }),
10397        };
10398    }
10399    Ok(())
10400}
10401
10402#[cfg(windows)]
10403fn install_pulled_delta_sources(
10404    dest: &Path,
10405    entries: &[V2StagedFile],
10406    deleted: &[String],
10407    rebuild_indexes: bool,
10408    previous: Option<&V2SyncBaseline>,
10409    next: &V2VerifiedHead,
10410) -> LinkResult<()> {
10411    match Store::open_strict(dest) {
10412        Ok(store) => {
10413            install_established_v2_delta(store, entries, deleted, rebuild_indexes, previous, next)
10414        }
10415        Err(_) => install_windows_initial_sources(dest, entries, rebuild_indexes),
10416    }
10417}
10418
10419#[cfg(not(any(unix, windows)))]
10420fn install_pulled_delta_sources(
10421    _dest: &Path,
10422    _entries: &[V2StagedFile],
10423    _deleted: &[String],
10424    _rebuild_indexes: bool,
10425    _previous: Option<&V2SyncBaseline>,
10426    _next: &V2VerifiedHead,
10427) -> LinkResult<()> {
10428    Err(LinkError::UnsupportedPlatform {
10429        operation: "atomic v2 pull install",
10430    })
10431}
10432
10433#[cfg(unix)]
10434fn install_pulled_snapshot(dest: &Path, entries: &[(String, Vec<u8>)]) -> LinkResult<()> {
10435    install_pulled_delta(dest, entries, &[], false)
10436}
10437
10438#[cfg(not(windows))]
10439fn is_safe_slug(slug: &str) -> bool {
10440    !slug.is_empty()
10441        && slug.len() <= 63
10442        && !slug.starts_with('-')
10443        && !slug.ends_with('-')
10444        && slug
10445            .bytes()
10446            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
10447}
10448
10449fn le_u16(bytes: &[u8], at: usize) -> Option<u16> {
10450    Some(u16::from_le_bytes(bytes.get(at..at + 2)?.try_into().ok()?))
10451}
10452
10453fn le_u32(bytes: &[u8], at: usize) -> Option<u32> {
10454    Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?))
10455}
10456
10457fn le_u64(bytes: &[u8], at: usize) -> Option<u64> {
10458    Some(u64::from_le_bytes(bytes.get(at..at + 8)?.try_into().ok()?))
10459}
10460
10461fn preflight_zip_central_directory(
10462    bytes: &[u8],
10463    offset: usize,
10464    size: usize,
10465    count: u64,
10466) -> LinkResult<()> {
10467    const CENTRAL_ENTRY_SIG: &[u8; 4] = b"PK\x01\x02";
10468    let end = offset
10469        .checked_add(size)
10470        .filter(|end| *end <= bytes.len())
10471        .ok_or_else(|| LinkError::InvalidPack {
10472            message: "ZIP central directory is out of bounds".to_string(),
10473        })?;
10474    let mut cursor = offset;
10475    for _ in 0..count {
10476        if bytes.get(cursor..cursor + 4) != Some(CENTRAL_ENTRY_SIG.as_slice()) {
10477            return Err(LinkError::InvalidPack {
10478                message: "ZIP central directory entry count is inconsistent".to_string(),
10479            });
10480        }
10481        if le_u16(bytes, cursor + 34) != Some(0) {
10482            return Err(LinkError::InvalidPack {
10483                message: "multi-disk ZIP archives are not supported".to_string(),
10484            });
10485        }
10486        let variable = [28, 30, 32].into_iter().try_fold(0usize, |total, at| {
10487            total.checked_add(le_u16(bytes, cursor + at)? as usize)
10488        });
10489        cursor = cursor
10490            .checked_add(46)
10491            .and_then(|fixed| fixed.checked_add(variable?))
10492            .filter(|cursor| *cursor <= end)
10493            .ok_or_else(|| LinkError::InvalidPack {
10494                message: "ZIP central directory entry is truncated".to_string(),
10495            })?;
10496    }
10497    if cursor != end {
10498        return Err(LinkError::InvalidPack {
10499            message: "ZIP central directory size is inconsistent".to_string(),
10500        });
10501    }
10502    Ok(())
10503}
10504
10505/// Read only the bounded ZIP trailer before `ZipArchive::new` allocates one
10506/// metadata object per central-directory entry. Supports ordinary EOCD and the
10507/// Zip64 locator/record emitted for >65,535-entry archives.
10508fn preflight_zip_entry_count(bytes: &[u8], max_entries: usize) -> LinkResult<()> {
10509    const EOCD_SIG: &[u8; 4] = b"PK\x05\x06";
10510    const ZIP64_LOCATOR_SIG: &[u8; 4] = b"PK\x06\x07";
10511    const ZIP64_EOCD_SIG: &[u8; 4] = b"PK\x06\x06";
10512    let search_start = bytes.len().saturating_sub(22 + u16::MAX as usize);
10513    let eocd = bytes[search_start..]
10514        .windows(4)
10515        .rposition(|window| window == EOCD_SIG)
10516        .map(|offset| search_start + offset)
10517        .ok_or_else(|| LinkError::InvalidPack {
10518            message: "ZIP has no end-of-central-directory record".to_string(),
10519        })?;
10520    let invalid_end = || LinkError::InvalidPack {
10521        message: "ZIP has an invalid end-of-central-directory structure".to_string(),
10522    };
10523    let comment_len = le_u16(bytes, eocd + 20).ok_or_else(invalid_end)? as usize;
10524    if eocd
10525        .checked_add(22)
10526        .and_then(|end| end.checked_add(comment_len))
10527        != Some(bytes.len())
10528    {
10529        // Do not fall back to an earlier signature. ZipArchive does, and a
10530        // fake low-count EOCD appended after a real Zip64 entry-count bomb
10531        // would otherwise bypass this allocation preflight.
10532        return Err(invalid_end());
10533    }
10534    let disk = le_u16(bytes, eocd + 4);
10535    let central_disk = le_u16(bytes, eocd + 6);
10536    if disk != Some(0) || central_disk != Some(0) {
10537        return Err(LinkError::InvalidPack {
10538            message: "multi-disk ZIP archives are not supported".to_string(),
10539        });
10540    }
10541    let entries_on_disk = le_u16(bytes, eocd + 8).ok_or_else(invalid_end)?;
10542    let ordinary = le_u16(bytes, eocd + 10).ok_or_else(invalid_end)?;
10543    if entries_on_disk != ordinary {
10544        return Err(LinkError::InvalidPack {
10545            message: "multi-disk ZIP archives are not supported".to_string(),
10546        });
10547    }
10548    let zip64_locator = eocd
10549        .checked_sub(20)
10550        .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_LOCATOR_SIG.as_slice()));
10551    let (count, central_offset, central_size) = if ordinary != u16::MAX || zip64_locator.is_none() {
10552        let central_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)? as usize;
10553        let central_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)? as usize;
10554        if central_offset
10555            .checked_add(central_size)
10556            .filter(|end| *end == eocd)
10557            .is_none()
10558        {
10559            return Err(invalid_end());
10560        }
10561        (ordinary as u64, central_offset, central_size)
10562    } else {
10563        let Some(locator) = zip64_locator else {
10564            return Err(invalid_end());
10565        };
10566        if le_u32(bytes, locator + 4) != Some(0) || le_u32(bytes, locator + 16) != Some(1) {
10567            return Err(LinkError::InvalidPack {
10568                message: "multi-disk ZIP64 archives are not supported".to_string(),
10569            });
10570        }
10571        let record = le_u64(bytes, locator + 8)
10572            .and_then(|offset| usize::try_from(offset).ok())
10573            .filter(|at| bytes.get(*at..*at + 4) == Some(ZIP64_EOCD_SIG.as_slice()))
10574            .ok_or_else(|| LinkError::InvalidPack {
10575                message: "ZIP64 archive has an invalid end record".to_string(),
10576            })?;
10577        let record_size = le_u64(bytes, record + 4)
10578            .and_then(|size| usize::try_from(size).ok())
10579            .filter(|size| *size >= 44)
10580            .ok_or_else(invalid_end)?;
10581        if record
10582            .checked_add(12)
10583            .and_then(|end| end.checked_add(record_size))
10584            != Some(locator)
10585            || le_u32(bytes, record + 16) != Some(0)
10586            || le_u32(bytes, record + 20) != Some(0)
10587        {
10588            return Err(invalid_end());
10589        }
10590        let zip64_on_disk = le_u64(bytes, record + 24).ok_or_else(invalid_end)?;
10591        let zip64_total = le_u64(bytes, record + 32).ok_or_else(invalid_end)?;
10592        let central_size = le_u64(bytes, record + 40)
10593            .and_then(|size| usize::try_from(size).ok())
10594            .ok_or_else(invalid_end)?;
10595        let central_offset = le_u64(bytes, record + 48)
10596            .and_then(|offset| usize::try_from(offset).ok())
10597            .ok_or_else(invalid_end)?;
10598        if zip64_on_disk != zip64_total
10599            || central_offset
10600                .checked_add(central_size)
10601                .filter(|end| *end == record)
10602                .is_none()
10603        {
10604            return Err(invalid_end());
10605        }
10606        let legacy_size = le_u32(bytes, eocd + 12).ok_or_else(invalid_end)?;
10607        let legacy_offset = le_u32(bytes, eocd + 16).ok_or_else(invalid_end)?;
10608        if (legacy_size != u32::MAX && legacy_size as usize != central_size)
10609            || (legacy_offset != u32::MAX && legacy_offset as usize != central_offset)
10610        {
10611            return Err(invalid_end());
10612        }
10613        (zip64_total, central_offset, central_size)
10614    };
10615    if count == 0 || count > max_entries as u64 {
10616        return Err(LinkError::InvalidPack {
10617            message: format!("invalid file count {count}"),
10618        });
10619    }
10620    preflight_zip_central_directory(bytes, central_offset, central_size, count)?;
10621    Ok(())
10622}
10623
10624fn parse_store_pack(bytes: Vec<u8>) -> LinkResult<Vec<(String, Vec<u8>)>> {
10625    preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)?;
10626    let mut archive =
10627        zip::ZipArchive::new(Cursor::new(bytes)).map_err(|err| LinkError::InvalidPack {
10628            message: format!("ZIP parse failed: {err}"),
10629        })?;
10630    if archive.is_empty() || archive.len() > MAX_PUSH_FILES {
10631        return Err(LinkError::InvalidPack {
10632            message: format!("invalid file count {}", archive.len()),
10633        });
10634    }
10635    let mut total = 0u64;
10636    let mut seen = std::collections::HashSet::new();
10637    let mut entries = Vec::with_capacity(archive.len());
10638    for index in 0..archive.len() {
10639        let mut file = archive
10640            .by_index(index)
10641            .map_err(|err| LinkError::InvalidPack {
10642                message: format!("ZIP entry failed: {err}"),
10643            })?;
10644        if file.is_dir() {
10645            continue;
10646        }
10647        let path = file.name().to_string();
10648        if file.enclosed_name().is_none() || !safe_store_rel_path(&path) {
10649            return Err(LinkError::UnsafePath { path });
10650        }
10651        if file
10652            .unix_mode()
10653            .is_some_and(|mode| !matches!(mode & 0o170000, 0 | 0o100000))
10654        {
10655            return Err(LinkError::InvalidPack {
10656                message: format!("non-file entry `{path}`"),
10657            });
10658        }
10659        if !seen.insert(path.clone()) {
10660            return Err(LinkError::InvalidPack {
10661                message: format!("duplicate path `{path}`"),
10662            });
10663        }
10664        let remaining = MAX_STORE_BYTES.saturating_sub(total);
10665        if file.size() > remaining {
10666            return Err(LinkError::InvalidPack {
10667                message: "expanded content exceeds the 512 MB limit".to_string(),
10668            });
10669        }
10670        let mut content = Vec::new();
10671        (&mut file)
10672            .take(remaining + 1)
10673            .read_to_end(&mut content)
10674            .map_err(|err| LinkError::InvalidPack {
10675                message: format!("could not decompress `{path}`: {err}"),
10676            })?;
10677        if content.len() as u64 > remaining {
10678            return Err(LinkError::InvalidPack {
10679                message: "expanded content exceeds the 512 MB limit".to_string(),
10680            });
10681        }
10682        if content.len() as u64 != file.size() {
10683            return Err(LinkError::InvalidPack {
10684                message: format!("length mismatch for `{path}`"),
10685            });
10686        }
10687        total += content.len() as u64;
10688        entries.push((path, content));
10689    }
10690    if entries.is_empty() {
10691        return Err(LinkError::InvalidPack {
10692            message: "pack contains no files".to_string(),
10693        });
10694    }
10695    Ok(entries)
10696}
10697
10698fn verify_snapshot_manifest(entries: &[(String, Vec<u8>)], signed: &[FeedFile]) -> LinkResult<()> {
10699    let mut expected = std::collections::BTreeMap::new();
10700    for file in signed {
10701        if !safe_store_rel_path(&file.path) {
10702            return Err(LinkError::UnsafePath {
10703                path: file.path.clone(),
10704            });
10705        }
10706        if !is_sha256(&file.sha256)
10707            || expected
10708                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
10709                .is_some()
10710        {
10711            return Err(invalid_feed(
10712                "signed snapshot manifest contains an invalid or duplicate file",
10713            ));
10714        }
10715    }
10716    if expected.len() != entries.len() {
10717        return Err(invalid_feed(
10718            "downloaded pack file set differs from the signed snapshot manifest",
10719        ));
10720    }
10721    for (path, bytes) in entries {
10722        let Some((sha256, declared_bytes)) = expected.get(path.as_str()) else {
10723            return Err(invalid_feed(format!(
10724                "downloaded pack contains unsigned path `{path}`"
10725            )));
10726        };
10727        if *declared_bytes != bytes.len() as u64
10728            || *sha256 != format!("{:x}", Sha256::digest(bytes))
10729        {
10730            return Err(invalid_feed(format!(
10731                "downloaded file `{path}` differs from its signed manifest"
10732            )));
10733        }
10734    }
10735    Ok(())
10736}
10737
10738/// Collect the files a push sends: the store's owned text — `DB.md`,
10739/// `assets.jsonl` when present, and every content `.md` under `records/` and
10740/// `sources/` (the store walk, which already excludes hidden dirs like
10741/// `.dbmd/`, the `log/` archive, and derived `index.*` catalogs; the hub
10742/// derives its own index, and local history stays local). Returns
10743/// `(store-relative path, content)` pairs, path-sorted.
10744pub fn collect_push_files(store: &Store) -> LinkResult<Vec<(String, String)>> {
10745    require_hardened_filesystem("sync push")?;
10746    preflight_push_ownership(store)?;
10747    let mut out: Vec<(String, String)> = Vec::new();
10748    let mut total = 0u64;
10749
10750    let mut read_text = |rel: &str| -> LinkResult<String> {
10751        let bytes = store.read_bounded(Path::new(rel), MAX_STORE_BYTES.saturating_sub(total))?;
10752        total = total
10753            .checked_add(bytes.len() as u64)
10754            .ok_or_else(|| LinkError::PushTooLarge {
10755                detail: "uncompressed byte count overflow".to_string(),
10756            })?;
10757        if total > MAX_STORE_BYTES {
10758            return Err(LinkError::PushTooLarge {
10759                detail: format!("{total} uncompressed bytes"),
10760            });
10761        }
10762        String::from_utf8(bytes).map_err(|_| LinkError::NotUtf8 {
10763            path: rel.to_string(),
10764        })
10765    };
10766
10767    out.push(("DB.md".to_string(), read_text("DB.md")?));
10768    if store
10769        .regular_file_exists(Path::new("assets.jsonl"))
10770        .unwrap_or(false)
10771    {
10772        out.push(("assets.jsonl".to_string(), read_text("assets.jsonl")?));
10773    }
10774
10775    for rel in store.walk()? {
10776        let rel_str = rel.to_string_lossy().replace('\\', "/");
10777        if !safe_store_rel_path(&rel_str) {
10778            // A locally-legal name outside the hub's portable charset cannot
10779            // travel this wire; refusing beats silently dropping it.
10780            return Err(LinkError::UnsafePath { path: rel_str });
10781        }
10782        let content = read_text(&rel_str)?;
10783        out.push((rel_str, content));
10784    }
10785
10786    out.sort_by(|a, b| a.0.cmp(&b.0));
10787    Ok(out)
10788}
10789
10790/// Refuse to build a destructive whole-store snapshot from an ambiguous local
10791/// tree. Ordinary read-only walks safely prune foreign paths, but a push that
10792/// silently omitted them could delete the hosted copies of those paths.
10793fn preflight_push_ownership(store: &Store) -> LinkResult<()> {
10794    if let Some(nested) = store.nested_store_roots()?.into_iter().next() {
10795        return Err(LinkError::from(std::io::Error::new(
10796            std::io::ErrorKind::PermissionDenied,
10797            format!("cannot push: nested db.md store at {}", nested.display()),
10798        )));
10799    }
10800
10801    if let Some(symlink) = store.unowned_symlinks()?.into_iter().next() {
10802        return Err(LinkError::from(std::io::Error::new(
10803            std::io::ErrorKind::PermissionDenied,
10804            format!(
10805                "cannot push: {} is a symlink outside the store ownership model",
10806                symlink.display()
10807            ),
10808        )));
10809    }
10810    Ok(())
10811}
10812
10813/// Push `files` to `brain` as a whole-store snapshot — the hub's push
10814/// semantics: the hosted copy becomes exactly this set (pull first if the
10815/// hosted side may have records the local copy lacks). Client-side caps
10816/// mirror the hub's JSON-path limits so an oversized push fails before the
10817/// upload.
10818pub fn sync_push(cfg: &HubConfig, brain: &str, files: &[(String, String)]) -> LinkResult<Value> {
10819    require_safe_ref(brain)?;
10820    let remote = verified_remote_head(cfg, brain, false)?;
10821    if files.len() > MAX_PUSH_FILES {
10822        return Err(LinkError::PushTooLarge {
10823            detail: format!("{} files", files.len()),
10824        });
10825    }
10826    let raw_total: u64 = files.iter().map(|(_, content)| content.len() as u64).sum();
10827    if raw_total > MAX_STORE_BYTES {
10828        return Err(LinkError::PushTooLarge {
10829            detail: format!("{raw_total} uncompressed bytes"),
10830        });
10831    }
10832
10833    // Self-custody (a brain key is configured): the JSON fast path is
10834    // hub-signed by construction, so every push goes through the pack flow
10835    // with a locally signed entry — the hub verifies and can never sign.
10836    if cfg.brain_key.is_none() {
10837        let body = json!({
10838            "files": files
10839                .iter()
10840                .map(|(p, c)| json!({ "path": p, "content": c }))
10841                .collect::<Vec<_>>(),
10842        });
10843        if body.to_string().len() <= MAX_PUSH_BYTES {
10844            let path = format!("/api/hub/brains/{brain}/push");
10845            let pushed = ensure_ok(
10846                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
10847                "sync push",
10848            )?;
10849            return Ok(pushed);
10850        }
10851    }
10852
10853    let pack = build_store_pack(files)?;
10854    if pack.len() as u64 > MAX_PACK_BYTES {
10855        return Err(LinkError::PushTooLarge {
10856            detail: format!("{} pack bytes", pack.len()),
10857        });
10858    }
10859    let sha256 = format!("{:x}", Sha256::digest(&pack));
10860    let mut meta = json!({ "sha256": sha256, "bytes": pack.len() });
10861    if let Some(key) = &cfg.brain_key {
10862        if !remote.head.verified {
10863            return Err(invalid_feed(
10864                "self-custody push requires a fully verified, unscoped feed head",
10865            ));
10866        }
10867        let identity = remote
10868            .identity
10869            .as_ref()
10870            .ok_or_else(|| invalid_feed("verified brain has no current identity"))?;
10871        let current_multikey = format!("ed25519:{}", identity.fingerprint);
10872        if key.multikey != current_multikey || key.public_key_spki != identity.public_key_spki {
10873            return Err(invalid_feed(
10874                "configured brain key is not the verified current brain identity",
10875            ));
10876        }
10877        // The verified state pins seq + prev; a concurrent writer surfaces as
10878        // the hub's 422 on commit (re-run to retry against the new head).
10879        let next_seq = remote
10880            .head
10881            .seq
10882            .checked_add(1)
10883            .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
10884        let mut manifest: Vec<WireFeedFile> = files
10885            .iter()
10886            .map(|(path, content)| WireFeedFile {
10887                path: path.clone(),
10888                sha256: format!("{:x}", Sha256::digest(content.as_bytes())),
10889                bytes: content.len() as u64,
10890            })
10891            .collect();
10892        manifest.sort_by(|a, b| a.path.cmp(&b.path));
10893        let ts = crate::now()
10894            .with_timezone(&chrono::Utc)
10895            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
10896            .to_string();
10897        let entry = self_custody_entry(
10898            key,
10899            next_seq,
10900            ts,
10901            &sha256,
10902            &manifest,
10903            remote.head.feed_hash.as_deref(),
10904        )?;
10905        meta["entry"] = Value::String(entry);
10906    }
10907    let presigned = ensure_ok(
10908        request(
10909            cfg,
10910            "POST",
10911            &format!("/api/hub/brains/{brain}/packs/presign"),
10912            Some(&meta),
10913            Auth::Required,
10914        )?,
10915        "prepare pack upload",
10916    )?;
10917    let url = presigned
10918        .get("url")
10919        .and_then(Value::as_str)
10920        .ok_or_else(|| LinkError::InvalidPack {
10921            message: "the hub returned no upload URL".to_string(),
10922        })?;
10923    put_presigned(
10924        cfg,
10925        url,
10926        presigned.get("headers").unwrap_or(&Value::Null),
10927        &pack,
10928    )?;
10929    let committed = ensure_ok(
10930        request(
10931            cfg,
10932            "POST",
10933            &format!("/api/hub/brains/{brain}/packs/commit"),
10934            Some(&meta),
10935            Auth::Required,
10936        )?,
10937        "commit pack",
10938    )?;
10939    Ok(committed)
10940}
10941
10942fn build_store_pack(files: &[(String, String)]) -> LinkResult<Vec<u8>> {
10943    const LOCAL_HEADER: u32 = 0x0403_4b50;
10944    const CENTRAL_HEADER: u32 = 0x0201_4b50;
10945    const END_OF_CENTRAL_DIRECTORY: u32 = 0x0605_4b50;
10946    const VERSION_20: u16 = 20;
10947    const MADE_BY_UNIX_20: u16 = (3 << 8) | VERSION_20;
10948    const UTF8_FLAG: u16 = 1 << 11;
10949    const STORED: u16 = 0;
10950    const DOS_TIME_MIDNIGHT: u16 = 0;
10951    const DOS_DATE_1980_01_01: u16 = (1 << 5) | 1;
10952    const UNIX_REGULAR_0600: u32 = 0o100600 << 16;
10953
10954    struct CentralEntry<'a> {
10955        name: &'a [u8],
10956        crc32: u32,
10957        size: u32,
10958        local_offset: u32,
10959    }
10960
10961    fn push_u16(out: &mut Vec<u8>, value: u16) {
10962        out.extend_from_slice(&value.to_le_bytes());
10963    }
10964
10965    fn push_u32(out: &mut Vec<u8>, value: u32) {
10966        out.extend_from_slice(&value.to_le_bytes());
10967    }
10968
10969    if files.is_empty() {
10970        return Err(LinkError::InvalidPack {
10971            message: "cannot create an empty snapshot pack".to_string(),
10972        });
10973    }
10974    if files.len() > u16::MAX as usize {
10975        return Err(LinkError::PushTooLarge {
10976            detail: format!(
10977                "{} files (canonical ZIP32 packs cap at {})",
10978                files.len(),
10979                u16::MAX
10980            ),
10981        });
10982    }
10983
10984    let mut sorted: Vec<_> = files.iter().collect();
10985    sorted.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
10986    let mut previous: Option<&str> = None;
10987    for (path, content) in &sorted {
10988        if !safe_store_rel_path(path) {
10989            return Err(LinkError::UnsafePath {
10990                path: (*path).clone(),
10991            });
10992        }
10993        if previous == Some(path.as_str()) {
10994            return Err(LinkError::InvalidPack {
10995                message: format!("duplicate path `{path}`"),
10996            });
10997        }
10998        previous = Some(path.as_str());
10999        u32::try_from(content.len()).map_err(|_| LinkError::PushTooLarge {
11000            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11001        })?;
11002    }
11003
11004    let mut out = Vec::new();
11005    let mut central = Vec::with_capacity(sorted.len());
11006    for (path, content) in sorted {
11007        let name = path.as_bytes();
11008        let name_len = u16::try_from(name.len()).map_err(|_| LinkError::InvalidPack {
11009            message: format!("ZIP entry name is too long: `{path}`"),
11010        })?;
11011        let bytes = content.as_bytes();
11012        let size = u32::try_from(bytes.len()).map_err(|_| LinkError::PushTooLarge {
11013            detail: format!("file `{path}` exceeds the ZIP32 per-file limit"),
11014        })?;
11015        let local_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11016            detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11017        })?;
11018        let crc32 = crc32fast::hash(bytes);
11019
11020        // Canonical local header: sizes and CRC are known up front. Bit 3 is
11021        // deliberately clear, so there is no trailing data descriptor.
11022        push_u32(&mut out, LOCAL_HEADER);
11023        push_u16(&mut out, VERSION_20);
11024        push_u16(&mut out, UTF8_FLAG);
11025        push_u16(&mut out, STORED);
11026        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11027        push_u16(&mut out, DOS_DATE_1980_01_01);
11028        push_u32(&mut out, crc32);
11029        push_u32(&mut out, size);
11030        push_u32(&mut out, size);
11031        push_u16(&mut out, name_len);
11032        push_u16(&mut out, 0); // no local extra data
11033        out.extend_from_slice(name);
11034        out.extend_from_slice(bytes);
11035
11036        central.push(CentralEntry {
11037            name,
11038            crc32,
11039            size,
11040            local_offset,
11041        });
11042    }
11043
11044    let central_offset = u32::try_from(out.len()).map_err(|_| LinkError::PushTooLarge {
11045        detail: "canonical ZIP32 pack exceeds its offset limit".to_string(),
11046    })?;
11047    for entry in &central {
11048        push_u32(&mut out, CENTRAL_HEADER);
11049        push_u16(&mut out, MADE_BY_UNIX_20);
11050        push_u16(&mut out, VERSION_20);
11051        push_u16(&mut out, UTF8_FLAG);
11052        push_u16(&mut out, STORED);
11053        push_u16(&mut out, DOS_TIME_MIDNIGHT);
11054        push_u16(&mut out, DOS_DATE_1980_01_01);
11055        push_u32(&mut out, entry.crc32);
11056        push_u32(&mut out, entry.size);
11057        push_u32(&mut out, entry.size);
11058        push_u16(&mut out, entry.name.len() as u16);
11059        push_u16(&mut out, 0); // no central extra data
11060        push_u16(&mut out, 0); // no file comment
11061        push_u16(&mut out, 0); // disk number
11062        push_u16(&mut out, 0); // internal attributes
11063        push_u32(&mut out, UNIX_REGULAR_0600);
11064        push_u32(&mut out, entry.local_offset);
11065        out.extend_from_slice(entry.name);
11066    }
11067    let central_size = u32::try_from(out.len())
11068        .ok()
11069        .and_then(|end| end.checked_sub(central_offset))
11070        .ok_or_else(|| LinkError::PushTooLarge {
11071            detail: "canonical ZIP32 central directory exceeds its limit".to_string(),
11072        })?;
11073    let entry_count = central.len() as u16;
11074
11075    push_u32(&mut out, END_OF_CENTRAL_DIRECTORY);
11076    push_u16(&mut out, 0); // this disk
11077    push_u16(&mut out, 0); // central directory disk
11078    push_u16(&mut out, entry_count);
11079    push_u16(&mut out, entry_count);
11080    push_u32(&mut out, central_size);
11081    push_u32(&mut out, central_offset);
11082    push_u16(&mut out, 0); // no archive comment
11083
11084    if out.len() > u32::MAX as usize {
11085        return Err(LinkError::PushTooLarge {
11086            detail: "canonical ZIP32 pack exceeds 4 GiB".to_string(),
11087        });
11088    }
11089    Ok(out)
11090}
11091
11092// ─────────────────────────────────────────────────────────────────────────────
11093// grant — issue / list / revoke capabilities (owner-side)
11094// ─────────────────────────────────────────────────────────────────────────────
11095
11096/// The two capabilities a v0 hub enforces.
11097#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11098pub enum Capability {
11099    /// Read the granted slice.
11100    Read,
11101    /// Read and push (whole-store; a path-scoped grant is read-only).
11102    Write,
11103}
11104
11105impl Capability {
11106    /// The wire form.
11107    pub fn as_str(self) -> &'static str {
11108        match self {
11109            Capability::Read => "read",
11110            Capability::Write => "write",
11111        }
11112    }
11113}
11114
11115/// Issue (or refresh) a grant on `brain` to `grantee` — a hub principal named
11116/// by email in v0 (the protocol's near-term simplification; key-named
11117/// grantees arrive with the signing layer). `scope` is a store-path prefix
11118/// (the hub's enforcement unit); `until` an ISO 8601 expiry, absent = until
11119/// revoked.
11120pub fn grant_issue(
11121    cfg: &HubConfig,
11122    brain: &str,
11123    grantee: &str,
11124    can: Capability,
11125    scope: Option<&str>,
11126    until: Option<&str>,
11127) -> LinkResult<Value> {
11128    require_safe_ref(brain)?;
11129    // Grantee shape decides the axis: a base64url Ed25519 SPKI is a bare
11130    // multikey holder (link.md §6 cross-party keys — no hub account; the
11131    // printed `publicKeySpki` from `dbmd key generate`); anything else is a
11132    // hub principal named by email.
11133    let is_key_grantee = URL_SAFE_NO_PAD
11134        .decode(grantee)
11135        .map(|der| der.len() == 44 && der.starts_with(&ED25519_SPKI_PREFIX))
11136        .unwrap_or(false);
11137    if let Some(head) = v2_verified_head(cfg, brain)? {
11138        if is_key_grantee {
11139            let scope = scope.unwrap_or("");
11140            let preset = match can {
11141                Capability::Read => "viewer",
11142                Capability::Write => "editor",
11143            };
11144            let entropy = format!(
11145                "{}\0{}\0{}\0{}\0{}\0{}\0{}",
11146                normalized_origin(&cfg.hub)?,
11147                head.brain_id,
11148                head.control_revision,
11149                grantee,
11150                preset,
11151                scope,
11152                until.unwrap_or("")
11153            );
11154            let mut body = json!({
11155                "context": "external",
11156                "expected_control_revision": head.control_revision,
11157                "mutation_id": format!("dbmd-grant-{}", content_sha256(entropy.as_bytes())),
11158                "preset": preset,
11159                "principal_kind": "key",
11160                "public_key": grantee,
11161                "scope": scope,
11162                "scope_kind": "prefix",
11163            });
11164            if let Some(value) = until {
11165                body["expires_at"] = json!(value);
11166            }
11167            let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11168            let response = ensure_ok(
11169                request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11170                "v2 grant issue",
11171            )?;
11172            let expected_fingerprint = identity_fingerprint(grantee)?;
11173            if response.get("v").and_then(Value::as_u64) != Some(2)
11174                || response
11175                    .get("id")
11176                    .and_then(Value::as_str)
11177                    .is_none_or(|id| !crate::ulid::is_ulid(id))
11178                || response.get("principal_kind").and_then(Value::as_str) != Some("key")
11179                || response.get("principal_id").and_then(Value::as_str)
11180                    != Some(expected_fingerprint.as_str())
11181                || response
11182                    .get("control_revision")
11183                    .and_then(Value::as_str)
11184                    .is_none_or(|value| !is_sha256(value))
11185            {
11186                return Err(invalid_feed(
11187                    "v2 grant issue response is not authority-bound",
11188                ));
11189            }
11190            return Ok(response);
11191        }
11192        // Account invitations remain a product workflow. The compatibility
11193        // endpoint resolves email/account identity, then commits a native v2
11194        // grant with the same control-plane recovery boundary. The v2 head
11195        // above is already the authenticated authority checkpoint; never
11196        // fall through to the retired v1 feed verifier for this request.
11197        let mut body = json!({ "email": grantee, "capability": can.as_str() });
11198        if let Some(value) = scope {
11199            body["scopePrefix"] = json!(value);
11200        }
11201        if let Some(value) = until {
11202            body["expiresAt"] = json!(value);
11203        }
11204        let path = format!("/api/hub/brains/{}/grants", head.brain_id);
11205        return ensure_ok(
11206            request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11207            "account grant issue",
11208        );
11209    }
11210    let _ = verified_remote_head(cfg, brain, false)?;
11211    let mut body = if is_key_grantee {
11212        json!({ "keySpki": grantee, "capability": can.as_str() })
11213    } else {
11214        json!({ "email": grantee, "capability": can.as_str() })
11215    };
11216    if let Some(s) = scope {
11217        body["scopePrefix"] = json!(s);
11218    }
11219    if let Some(u) = until {
11220        body["expiresAt"] = json!(u);
11221    }
11222    let path = format!("/api/hub/brains/{brain}/grants");
11223    ensure_ok(
11224        request(cfg, "POST", &path, Some(&body), Auth::Required)?,
11225        "grant issue",
11226    )
11227}
11228
11229/// List the active grants (and pending invites) on `brain`. Owner-side.
11230pub fn grant_list(cfg: &HubConfig, brain: &str) -> LinkResult<Value> {
11231    require_safe_ref(brain)?;
11232    if let Some(head) = v2_verified_head(cfg, brain)? {
11233        let path = format!("/api/hub/brains/{}/v2/grants", head.brain_id);
11234        let response = ensure_ok(
11235            request(cfg, "GET", &path, None, Auth::Required)?,
11236            "v2 grant list",
11237        )?;
11238        if response.get("v").and_then(Value::as_u64) != Some(2)
11239            || response.get("control_revision").and_then(Value::as_str)
11240                != Some(head.control_revision.as_str())
11241            || !response.get("grants").is_some_and(Value::is_array)
11242        {
11243            return Err(invalid_feed(
11244                "v2 grant list is not bound to the verified authority",
11245            ));
11246        }
11247        return Ok(response);
11248    }
11249    let _ = verified_remote_head(cfg, brain, false)?;
11250    let path = format!("/api/hub/brains/{brain}/grants");
11251    ensure_ok(
11252        request(cfg, "GET", &path, None, Auth::Required)?,
11253        "grant list",
11254    )
11255}
11256
11257/// Revoke a grant (or cancel a pending invite) by id. Owner-side; revocation
11258/// is soft on the hub (the audit trail survives).
11259pub fn grant_revoke(cfg: &HubConfig, brain: &str, grant_id: &str) -> LinkResult<Value> {
11260    require_safe_ref(brain)?;
11261    require_safe_grant_id(grant_id)?;
11262    if let Some(head) = v2_verified_head(cfg, brain)? {
11263        let entropy = format!(
11264            "{}\0{}\0{}\0{}",
11265            normalized_origin(&cfg.hub)?,
11266            head.brain_id,
11267            head.control_revision,
11268            grant_id
11269        );
11270        let body = json!({
11271            "expected_control_revision": head.control_revision,
11272            "mutation_id": format!("dbmd-revoke-{}", content_sha256(entropy.as_bytes())),
11273        });
11274        let path = format!("/api/hub/brains/{}/v2/grants/{grant_id}", head.brain_id);
11275        let response = ensure_ok(
11276            request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11277            "v2 grant revoke",
11278        )?;
11279        if response.get("v").and_then(Value::as_u64) != Some(2)
11280            || response.get("id").and_then(Value::as_str) != Some(grant_id)
11281            || response.get("revoked").and_then(Value::as_bool) != Some(true)
11282            || response
11283                .get("control_revision")
11284                .and_then(Value::as_str)
11285                .is_none_or(|value| !is_sha256(value))
11286        {
11287            return Err(invalid_feed(
11288                "v2 grant revocation response is not authority-bound",
11289            ));
11290        }
11291        return Ok(response);
11292    }
11293    let _ = verified_remote_head(cfg, brain, false)?;
11294    let path = format!("/api/hub/brains/{brain}/grants/{grant_id}");
11295    ensure_ok(
11296        request(cfg, "DELETE", &path, None, Auth::Required)?,
11297        "grant revoke",
11298    )
11299}
11300
11301// ─────────────────────────────────────────────────────────────────────────────
11302// permissioned v2 proposals — inspect, accept, reject
11303// ─────────────────────────────────────────────────────────────────────────────
11304
11305#[derive(Debug)]
11306struct VerifiedV2Proposal {
11307    value: Value,
11308    changes: Value,
11309    blobs: Vec<(String, u64, String)>,
11310}
11311
11312fn require_proposal_id(id: &str) -> LinkResult<()> {
11313    if crate::ulid::is_ulid(id) {
11314        Ok(())
11315    } else {
11316        Err(invalid_feed("proposal id is not a lowercase ULID"))
11317    }
11318}
11319
11320fn verified_v2_proposal(
11321    cfg: &HubConfig,
11322    head: &V2VerifiedHead,
11323    proposal_id: &str,
11324) -> LinkResult<VerifiedV2Proposal> {
11325    require_proposal_id(proposal_id)?;
11326    if head.view_kind != "full" {
11327        return Err(invalid_feed(
11328            "proposal review requires a full readable view",
11329        ));
11330    }
11331    let path = format!(
11332        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11333        head.brain_id
11334    );
11335    let value = ensure_ok(
11336        request_capped(
11337            cfg,
11338            "GET",
11339            &path,
11340            None,
11341            Auth::Required,
11342            MAX_FEED_RESPONSE_BYTES,
11343        )?,
11344        "v2 proposal",
11345    )?;
11346    verify_v2_proposal_value(head, proposal_id, value)
11347}
11348
11349fn verify_v2_proposal_value(
11350    head: &V2VerifiedHead,
11351    proposal_id: &str,
11352    value: Value,
11353) -> LinkResult<VerifiedV2Proposal> {
11354    if value.get("v").and_then(Value::as_u64) != Some(2) {
11355        return Err(invalid_feed("proposal response has an invalid version"));
11356    }
11357    let proposal = value
11358        .get("proposal")
11359        .and_then(Value::as_object)
11360        .ok_or_else(|| invalid_feed("proposal response has no proposal"))?;
11361    if proposal.get("id").and_then(Value::as_str) != Some(proposal_id) {
11362        return Err(invalid_feed("proposal response changed its id"));
11363    }
11364    let payload_hash = proposal
11365        .get("payload_sha256")
11366        .and_then(Value::as_str)
11367        .filter(|hash| is_sha256(hash))
11368        .ok_or_else(|| invalid_feed("proposal has no payload address"))?;
11369    let clear_hash = proposal
11370        .get("clear_sha256")
11371        .and_then(Value::as_str)
11372        .filter(|hash| is_sha256(hash))
11373        .ok_or_else(|| invalid_feed("proposal has no clear payload digest"))?;
11374    let submission_hash = proposal
11375        .get("submission_claim_sha256")
11376        .and_then(Value::as_str)
11377        .filter(|hash| is_sha256(hash))
11378        .ok_or_else(|| invalid_feed("proposal has no submission claim address"))?;
11379    let submission = STANDARD
11380        .decode(
11381            proposal
11382                .get("submission_claim_base64")
11383                .and_then(Value::as_str)
11384                .ok_or_else(|| invalid_feed("proposal has no submission claim"))?,
11385        )
11386        .map_err(|_| invalid_feed("proposal submission claim is not base64"))?;
11387    let submission_value: Value = serde_json::from_slice(&submission)
11388        .map_err(|_| invalid_feed("proposal submission claim is not JSON"))?;
11389    if crate::linkmd_v2::canonical_bytes(&submission_value)
11390        .map_err(|error| invalid_feed(error.to_string()))?
11391        != submission
11392        || crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &submission)
11393            .map_err(|error| invalid_feed(error.to_string()))?
11394            != submission_hash
11395    {
11396        return Err(invalid_feed(
11397            "proposal submission claim is not canonical or addressed",
11398        ));
11399    }
11400    let envelope = submission_value
11401        .as_object()
11402        .ok_or_else(|| invalid_feed("proposal submission claim is not an object"))?;
11403    let claim = envelope
11404        .get("claim")
11405        .ok_or_else(|| invalid_feed("proposal submission claim body is missing"))?;
11406    let claim_object = claim
11407        .as_object()
11408        .ok_or_else(|| invalid_feed("proposal submission claim body is not an object"))?;
11409    let actor_root = claim_object
11410        .get("actor_root")
11411        .and_then(Value::as_object)
11412        .ok_or_else(|| invalid_feed("proposal submission actor root is missing"))?;
11413    let public_key = envelope
11414        .get("public_key")
11415        .and_then(Value::as_str)
11416        .ok_or_else(|| invalid_feed("proposal submission signer is missing"))?;
11417    let fingerprint = envelope
11418        .get("fingerprint")
11419        .and_then(Value::as_str)
11420        .ok_or_else(|| invalid_feed("proposal submission fingerprint is missing"))?;
11421    let signature = envelope
11422        .get("sig")
11423        .and_then(Value::as_str)
11424        .ok_or_else(|| invalid_feed("proposal submission signature is missing"))?;
11425    let claim_bytes = crate::linkmd_v2::canonical_bytes(claim)
11426        .map_err(|error| invalid_feed(error.to_string()))?;
11427    let der = verify_v2_spki_signature(public_key, &claim_bytes, signature)?;
11428    let signer = format!("{fingerprint}:{public_key}");
11429    let actor_class = actor_root.get("actor_class").and_then(Value::as_str);
11430    let grants = actor_root.get("grants").and_then(Value::as_array);
11431    let grants_are_canonical = grants.is_some_and(|items| {
11432        let mut prior: Option<&str> = None;
11433        items.iter().all(|item| {
11434            let Some(grant) = item.as_str() else {
11435                return false;
11436            };
11437            if !crate::ulid::is_ulid(grant) || prior.is_some_and(|value| value >= grant) {
11438                return false;
11439            }
11440            prior = Some(grant);
11441            true
11442        })
11443    });
11444    let optional_actor_field = |name: &str| {
11445        actor_root.get(name).is_some_and(|value| {
11446            value.is_null() || value.as_str().is_some_and(|text| !text.is_empty())
11447        })
11448    };
11449    let submitted_at = claim_object.get("submitted_at").and_then(Value::as_str);
11450    if claim_object.get("v").and_then(Value::as_u64) != Some(2)
11451        || format!("{:x}", Sha256::digest(&der)) != fingerprint
11452        || head
11453            .trust
11454            .hub_signer
11455            .as_ref()
11456            .is_some_and(|known| known != &signer)
11457        || !matches!(
11458            actor_class,
11459            Some(
11460                "user"
11461                    | "owned_agent"
11462                    | "foreign_key"
11463                    | "curation"
11464                    | "inbox"
11465                    | "restore"
11466                    | "migration"
11467                    | "operator_recovery"
11468            )
11469        )
11470        || actor_root
11471            .get("principal")
11472            .and_then(Value::as_str)
11473            .is_none_or(|value| value.is_empty())
11474        || actor_root
11475            .get("credential")
11476            .and_then(Value::as_str)
11477            .is_none_or(|value| value.is_empty())
11478        || !optional_actor_field("organization")
11479        || !optional_actor_field("role")
11480        || !grants_are_canonical
11481        || claim.get("brain").and_then(Value::as_str) != Some(head.brain_id.as_str())
11482        || claim.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
11483        || !claim_object
11484            .get("mutation_id")
11485            .and_then(Value::as_str)
11486            .is_some_and(|value| {
11487                !value.is_empty()
11488                    && value.len() <= 128
11489                    && value.chars().enumerate().all(|(index, char)| {
11490                        char.is_ascii_alphanumeric()
11491                            || (index > 0 && matches!(char, '.' | '_' | ':' | '-'))
11492                    })
11493            })
11494        || claim.get("payload_sha256").and_then(Value::as_str) != Some(payload_hash)
11495        || claim.get("clear_sha256").and_then(Value::as_str) != Some(clear_hash)
11496        || !claim_object
11497            .get("control_revision")
11498            .and_then(Value::as_str)
11499            .is_some_and(is_sha256)
11500        || submitted_at.is_none_or(|value| {
11501            chrono::DateTime::parse_from_rfc3339(value).is_err()
11502                || proposal.get("submitted_at").and_then(Value::as_str) != Some(value)
11503        })
11504        || !proposal
11505            .get("state")
11506            .and_then(Value::as_str)
11507            .is_some_and(|value| matches!(value, "pending" | "accepted" | "rejected" | "expired"))
11508        || proposal
11509            .get("expires_at")
11510            .and_then(Value::as_str)
11511            .is_none_or(|value| chrono::DateTime::parse_from_rfc3339(value).is_err())
11512        || proposal
11513            .get("proposer")
11514            .and_then(Value::as_object)
11515            .and_then(|value| value.get("class"))
11516            .and_then(Value::as_str)
11517            != actor_class
11518    {
11519        return Err(invalid_feed(
11520            "proposal submission claim does not bind the verified proposal",
11521        ));
11522    }
11523    let changes_b64 = proposal
11524        .get("changes_base64")
11525        .and_then(Value::as_str)
11526        .ok_or_else(|| invalid_feed("proposal has no changeset"))?;
11527    let changes_bytes = STANDARD
11528        .decode(changes_b64)
11529        .map_err(|_| invalid_feed("proposal changeset is not base64"))?;
11530    let changes: Value = serde_json::from_slice(&changes_bytes)
11531        .map_err(|_| invalid_feed("proposal changeset is not JSON"))?;
11532    if crate::linkmd_v2::canonical_bytes(&changes)
11533        .map_err(|error| invalid_feed(error.to_string()))?
11534        != changes_bytes
11535        || changes.get("v").and_then(Value::as_u64) != Some(2)
11536        || !changes.get("operations").is_some_and(Value::is_array)
11537    {
11538        return Err(invalid_feed("proposal changeset is not canonical v2"));
11539    }
11540    let blob_values = proposal
11541        .get("blobs")
11542        .and_then(Value::as_array)
11543        .ok_or_else(|| invalid_feed("proposal has no blob declarations"))?;
11544    let mut blobs = Vec::with_capacity(blob_values.len());
11545    let mut descriptor_blobs = Vec::with_capacity(blob_values.len());
11546    let mut prior_hash: Option<String> = None;
11547    for item in blob_values {
11548        let hash = item
11549            .get("sha256")
11550            .and_then(Value::as_str)
11551            .filter(|hash| is_sha256(hash))
11552            .ok_or_else(|| invalid_feed("proposal blob has no address"))?;
11553        let bytes = item
11554            .get("bytes")
11555            .and_then(Value::as_u64)
11556            .filter(|bytes| *bytes <= MAX_STORE_BYTES)
11557            .ok_or_else(|| invalid_feed("proposal blob has an invalid size"))?;
11558        if prior_hash.as_deref().is_some_and(|prior| prior >= hash) {
11559            return Err(invalid_feed(
11560                "proposal blob declarations are not unique and sorted",
11561            ));
11562        }
11563        prior_hash = Some(hash.to_string());
11564        let endpoint = item
11565            .get("endpoint")
11566            .and_then(Value::as_str)
11567            .ok_or_else(|| invalid_feed("proposal blob has no endpoint"))?;
11568        let expected_endpoint = format!(
11569            "/api/hub/brains/{}/v2/proposals/{proposal_id}/blob?sha256={hash}",
11570            head.brain_id
11571        );
11572        if endpoint != expected_endpoint {
11573            return Err(invalid_feed("proposal blob endpoint is not origin-bound"));
11574        }
11575        descriptor_blobs.push(json!({ "bytes": bytes, "sha256": hash }));
11576        blobs.push((hash.to_string(), bytes, endpoint.to_string()));
11577    }
11578    let descriptor = json!({
11579        "base": proposal.get("base").cloned().unwrap_or(Value::Null),
11580        "blobs": descriptor_blobs,
11581        "changes_base64": changes_b64,
11582        "rebase": proposal.get("rebase").cloned().unwrap_or(Value::Null),
11583        "v": 2,
11584    });
11585    let descriptor_bytes = crate::linkmd_v2::canonical_bytes(&descriptor)
11586        .map_err(|error| invalid_feed(error.to_string()))?;
11587    if content_sha256(&descriptor_bytes) != clear_hash {
11588        return Err(invalid_feed(
11589            "proposal clear payload differs from its signed submission claim",
11590        ));
11591    }
11592    Ok(VerifiedV2Proposal {
11593        value,
11594        changes,
11595        blobs,
11596    })
11597}
11598
11599pub fn proposal_list(
11600    cfg: &HubConfig,
11601    brain: &str,
11602    state: &str,
11603    after: Option<&str>,
11604    limit: usize,
11605) -> LinkResult<Value> {
11606    require_safe_ref(brain)?;
11607    if !matches!(state, "pending" | "accepted" | "rejected" | "expired") {
11608        return Err(invalid_feed("proposal state is invalid"));
11609    }
11610    if after.is_some_and(|value| !crate::ulid::is_ulid(value)) {
11611        return Err(invalid_feed("proposal cursor is invalid"));
11612    }
11613    let head = v2_verified_head(cfg, brain)?
11614        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11615    let path = format!(
11616        "/api/hub/brains/{}/v2/proposals?state={state}&limit={}{}",
11617        head.brain_id,
11618        limit.clamp(1, 100),
11619        after.map_or_else(String::new, |value| format!("&after={value}"))
11620    );
11621    ensure_ok(
11622        request_capped(
11623            cfg,
11624            "GET",
11625            &path,
11626            None,
11627            Auth::Required,
11628            MAX_FEED_RESPONSE_BYTES,
11629        )?,
11630        "v2 proposal list",
11631    )
11632}
11633
11634pub fn proposal_show(cfg: &HubConfig, brain: &str, proposal_id: &str) -> LinkResult<Value> {
11635    require_safe_ref(brain)?;
11636    let head = v2_verified_head(cfg, brain)?
11637        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11638    Ok(verified_v2_proposal(cfg, &head, proposal_id)?.value)
11639}
11640
11641pub fn proposal_reject(
11642    cfg: &HubConfig,
11643    brain: &str,
11644    proposal_id: &str,
11645    mutation_id: &str,
11646    reason: &str,
11647) -> LinkResult<Value> {
11648    require_safe_ref(brain)?;
11649    require_proposal_id(proposal_id)?;
11650    let head = v2_verified_head(cfg, brain)?
11651        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11652    let _ = verified_v2_proposal(cfg, &head, proposal_id)?;
11653    let body = json!({
11654        "mutation_id": mutation_id,
11655        "control_revision": head.control_revision,
11656        "reason": reason,
11657    });
11658    let path = format!(
11659        "/api/hub/brains/{}/v2/proposals/{proposal_id}",
11660        head.brain_id
11661    );
11662    ensure_ok(
11663        request(cfg, "DELETE", &path, Some(&body), Auth::Required)?,
11664        "v2 proposal rejection",
11665    )
11666}
11667
11668pub fn proposal_accept_exact(
11669    cfg: &HubConfig,
11670    brain: &str,
11671    proposal_id: &str,
11672    mutation_id: &str,
11673    reason: &str,
11674) -> LinkResult<Value> {
11675    require_safe_ref(brain)?;
11676    require_proposal_id(proposal_id)?;
11677    let head = v2_verified_head(cfg, brain)?
11678        .ok_or_else(|| invalid_feed("brain does not advertise link.md v2"))?;
11679    let proposal = verified_v2_proposal(cfg, &head, proposal_id)?;
11680    let operations = proposal
11681        .changes
11682        .get("operations")
11683        .and_then(Value::as_array)
11684        .cloned()
11685        .ok_or_else(|| invalid_feed("proposal changeset has no operations"))?;
11686    if operations.is_empty() || operations.len() > MAX_PUSH_FILES {
11687        return Err(invalid_feed("proposal operation count is invalid"));
11688    }
11689    let mut downloaded = std::collections::BTreeMap::new();
11690    for (hash, bytes, endpoint) in &proposal.blobs {
11691        let body = ensure_raw_ok(
11692            request_raw(cfg, "GET", endpoint, None, Auth::Required, *bytes)?,
11693            "v2 proposal blob",
11694        )?;
11695        if body.len() as u64 != *bytes || content_sha256(&body) != *hash {
11696            return Err(invalid_feed("proposal blob does not match its declaration"));
11697        }
11698        downloaded.insert(hash.clone(), body);
11699    }
11700    let remote = files_for_v2_view(
11701        &head,
11702        v2_manifest(cfg, &head.brain_id, head.pointer.as_ref())?,
11703    );
11704    let remote_assets = v2_asset_manifest(cfg, &head.brain_id, head.pointer.as_ref())?;
11705    let mut expected_candidate = remote.clone();
11706    let mut expected_candidate_assets = remote_assets;
11707    for operation in &operations {
11708        let op = operation
11709            .get("op")
11710            .and_then(Value::as_str)
11711            .ok_or_else(|| invalid_feed("proposal operation has no kind"))?;
11712        match op {
11713            "put" | "restore" => {
11714                let path = operation
11715                    .get("path")
11716                    .and_then(Value::as_str)
11717                    .ok_or_else(|| invalid_feed("proposal write has no path"))?;
11718                crate::linkmd_v2::normalize_path(path)
11719                    .map_err(|error| invalid_feed(error.to_string()))?;
11720                let hash = operation
11721                    .get("blob")
11722                    .and_then(Value::as_str)
11723                    .filter(|hash| is_sha256(hash))
11724                    .ok_or_else(|| invalid_feed("proposal write has no blob"))?;
11725                let bytes = operation
11726                    .get("bytes")
11727                    .and_then(Value::as_u64)
11728                    .ok_or_else(|| invalid_feed("proposal write has no size"))?;
11729                expected_candidate.insert(
11730                    path.to_string(),
11731                    V2BaselineFile {
11732                        sha256: hash.to_string(),
11733                        bytes,
11734                        proof: None,
11735                    },
11736                );
11737            }
11738            "delete" | "withdraw_from_hosting" => {
11739                let path = operation
11740                    .get("path")
11741                    .and_then(Value::as_str)
11742                    .ok_or_else(|| invalid_feed("proposal removal has no path"))?;
11743                crate::linkmd_v2::normalize_path(path)
11744                    .map_err(|error| invalid_feed(error.to_string()))?;
11745                expected_candidate.remove(path);
11746            }
11747            "rename" => {
11748                let from = operation
11749                    .get("from")
11750                    .and_then(Value::as_str)
11751                    .ok_or_else(|| invalid_feed("proposal rename has no source"))?;
11752                let to = operation
11753                    .get("to")
11754                    .and_then(Value::as_str)
11755                    .ok_or_else(|| invalid_feed("proposal rename has no destination"))?;
11756                crate::linkmd_v2::normalize_path(from)
11757                    .and_then(|_| crate::linkmd_v2::normalize_path(to))
11758                    .map_err(|error| invalid_feed(error.to_string()))?;
11759                let hash = operation
11760                    .get("blob")
11761                    .and_then(Value::as_str)
11762                    .filter(|hash| is_sha256(hash))
11763                    .ok_or_else(|| invalid_feed("proposal rename has no blob"))?;
11764                let bytes = operation
11765                    .get("bytes")
11766                    .and_then(Value::as_u64)
11767                    .ok_or_else(|| invalid_feed("proposal rename has no size"))?;
11768                expected_candidate.remove(from);
11769                expected_candidate.insert(
11770                    to.to_string(),
11771                    V2BaselineFile {
11772                        sha256: hash.to_string(),
11773                        bytes,
11774                        proof: None,
11775                    },
11776                );
11777            }
11778            "asset_delete" => {
11779                let path = operation
11780                    .get("path")
11781                    .and_then(Value::as_str)
11782                    .ok_or_else(|| invalid_feed("proposal asset delete has no path"))?;
11783                expected_candidate_assets.remove(path);
11784            }
11785            "asset_withdraw" => {
11786                let path = operation
11787                    .get("path")
11788                    .and_then(Value::as_str)
11789                    .ok_or_else(|| invalid_feed("proposal asset withdrawal has no path"))?;
11790                let asset = expected_candidate_assets
11791                    .get_mut(path)
11792                    .ok_or_else(|| invalid_feed("proposal withdraws an unknown asset"))?;
11793                asset.disposition = "withheld".to_string();
11794                asset.leaf_hash.clear();
11795            }
11796            "asset_put" | "asset_resume" => {
11797                let path = operation
11798                    .get("path")
11799                    .and_then(Value::as_str)
11800                    .ok_or_else(|| invalid_feed("proposal asset write has no path"))?;
11801                let asset = operation
11802                    .get("asset")
11803                    .and_then(Value::as_object)
11804                    .ok_or_else(|| invalid_feed("proposal asset write has no value"))?;
11805                let blob_sha256 = asset
11806                    .get("blob_sha256")
11807                    .and_then(Value::as_str)
11808                    .filter(|hash| is_sha256(hash))
11809                    .ok_or_else(|| invalid_feed("proposal asset has no blob hash"))?;
11810                let bytes = asset
11811                    .get("bytes")
11812                    .and_then(Value::as_u64)
11813                    .ok_or_else(|| invalid_feed("proposal asset has no byte count"))?;
11814                let media_type = asset
11815                    .get("media_type")
11816                    .and_then(Value::as_str)
11817                    .ok_or_else(|| invalid_feed("proposal asset has no media type"))?;
11818                let wrappers = asset
11819                    .get("wrappers")
11820                    .and_then(Value::as_array)
11821                    .ok_or_else(|| invalid_feed("proposal asset has no wrappers"))?
11822                    .iter()
11823                    .map(|wrapper| {
11824                        wrapper
11825                            .as_str()
11826                            .map(str::to_string)
11827                            .ok_or_else(|| invalid_feed("proposal asset wrapper is invalid"))
11828                    })
11829                    .collect::<LinkResult<Vec<_>>>()?;
11830                let required = asset
11831                    .get("required")
11832                    .and_then(Value::as_bool)
11833                    .ok_or_else(|| invalid_feed("proposal asset required flag is invalid"))?;
11834                let disposition = asset
11835                    .get("disposition")
11836                    .and_then(Value::as_str)
11837                    .filter(|value| matches!(*value, "hosted" | "withheld"))
11838                    .ok_or_else(|| invalid_feed("proposal asset disposition is invalid"))?;
11839                expected_candidate_assets.insert(
11840                    path.to_string(),
11841                    V2BaselineAsset {
11842                        blob_sha256: blob_sha256.to_string(),
11843                        bytes,
11844                        media_type: media_type.to_string(),
11845                        wrappers,
11846                        required,
11847                        disposition: disposition.to_string(),
11848                        leaf_hash: String::new(),
11849                    },
11850                );
11851            }
11852            _ => return Err(invalid_feed("proposal operation kind is unsupported")),
11853        }
11854    }
11855    let base = head.pointer.as_ref().map(|pointer| {
11856        json!({
11857            "seq": pointer.seq,
11858            "commit_hash": pointer.commit_hash,
11859            "content_root": pointer.content_root,
11860            "asset_root": pointer.asset_root,
11861        })
11862    });
11863    let mut body = json!({
11864        "mutation_id": mutation_id,
11865        "base": base,
11866        "rebase": "strict",
11867        "reason": reason,
11868        "operations": operations,
11869        "blobs": downloaded
11870            .iter()
11871            .map(|(sha256, bytes)| json!({
11872                "sha256": sha256,
11873                "bytes": bytes.len(),
11874                "content_base64": STANDARD.encode(bytes),
11875            }))
11876            .collect::<Vec<_>>(),
11877        "proposal_id": proposal_id,
11878        "proposal_mode": "exact",
11879    });
11880    let changed_bytes = downloaded.values().try_fold(0_usize, |total, bytes| {
11881        total
11882            .checked_add(bytes.len())
11883            .ok_or_else(|| LinkError::PushTooLarge {
11884                detail: "proposal changed-byte total overflow".to_string(),
11885            })
11886    })?;
11887    if changed_bytes > 3 * 1024 * 1024 || body.to_string().len() > MAX_PUSH_BYTES - 64 * 1024 {
11888        let mut coordinates_by_hash: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
11889        for operation in &operations {
11890            let Some(kind) = operation.get("op").and_then(Value::as_str) else {
11891                return Err(invalid_feed("proposal upload operation has no kind"));
11892            };
11893            let hash = match kind {
11894                "put" | "restore" | "rename" => operation.get("blob").and_then(Value::as_str),
11895                "asset_put" | "asset_resume" => operation
11896                    .get("asset")
11897                    .and_then(|asset| asset.get("blob_sha256"))
11898                    .and_then(Value::as_str),
11899                _ => None,
11900            };
11901            let Some(hash) = hash else { continue };
11902            let coordinates = coordinates_by_hash.entry(hash.to_string()).or_default();
11903            if kind == "rename" {
11904                for field in ["from", "to"] {
11905                    coordinates.insert(
11906                        operation
11907                            .get(field)
11908                            .and_then(Value::as_str)
11909                            .ok_or_else(|| invalid_feed("proposal rename has no coordinate"))?
11910                            .to_string(),
11911                    );
11912                }
11913            } else {
11914                let path = operation
11915                    .get("path")
11916                    .and_then(Value::as_str)
11917                    .ok_or_else(|| invalid_feed("proposal upload has no coordinate"))?;
11918                coordinates.insert(if kind.starts_with("asset_") {
11919                    format!("assets/{path}")
11920                } else {
11921                    path.to_string()
11922                });
11923            }
11924        }
11925        let declarations = downloaded
11926            .iter()
11927            .map(|(sha256, bytes)| {
11928                json!({
11929                    "sha256": sha256,
11930                    "bytes": bytes.len(),
11931                    "coordinates": coordinates_by_hash
11932                        .get(sha256)
11933                        .into_iter()
11934                        .flatten()
11935                        .collect::<Vec<_>>(),
11936                })
11937            })
11938            .collect::<Vec<_>>();
11939        let mut items: Vec<Value> = Vec::with_capacity(downloaded.len());
11940        for batch in batch_upload_declarations(declarations) {
11941            let reserved = reserve_upload_window(
11942                cfg,
11943                &format!("/api/hub/brains/{}/v2/uploads", head.brain_id),
11944                &json!({ "blobs": batch }),
11945                "prepare proposal blob transport",
11946            )?;
11947            let reserved_items = reserved
11948                .get("uploads")
11949                .and_then(Value::as_array)
11950                .ok_or_else(|| invalid_feed("proposal upload reservation has no items"))?;
11951            items.extend(reserved_items.iter().cloned());
11952        }
11953        if items.len() != downloaded.len() {
11954            return Err(invalid_feed("proposal upload reservation changed the set"));
11955        }
11956        let mut references = Vec::with_capacity(items.len());
11957        for item in items {
11958            let hash = item
11959                .get("sha256")
11960                .and_then(Value::as_str)
11961                .ok_or_else(|| invalid_feed("proposal upload reservation has no hash"))?;
11962            let bytes = downloaded
11963                .get(hash)
11964                .ok_or_else(|| invalid_feed("proposal upload reservation introduced a blob"))?;
11965            let reservation_id = item
11966                .get("reservation_id")
11967                .and_then(Value::as_str)
11968                .filter(|id| crate::ulid::is_ulid(id))
11969                .ok_or_else(|| invalid_feed("proposal upload reservation has no id"))?;
11970            let expected_coordinates = coordinates_by_hash
11971                .get(hash)
11972                .ok_or_else(|| invalid_feed("proposal upload has no coordinate binding"))?;
11973            let returned_coordinates = item
11974                .get("coordinates")
11975                .and_then(Value::as_array)
11976                .ok_or_else(|| invalid_feed("proposal upload reservation has no coordinates"))?;
11977            if returned_coordinates.len() != expected_coordinates.len()
11978                || returned_coordinates
11979                    .iter()
11980                    .zip(expected_coordinates)
11981                    .any(|(actual, expected)| actual.as_str() != Some(expected.as_str()))
11982            {
11983                return Err(invalid_feed(
11984                    "proposal upload reservation changed its coordinates",
11985                ));
11986            }
11987            match item.get("status").and_then(Value::as_str) {
11988                Some("upload") => put_presigned(
11989                    cfg,
11990                    item.get("url")
11991                        .and_then(Value::as_str)
11992                        .ok_or_else(|| invalid_feed("proposal upload has no URL"))?,
11993                    item.get("headers").unwrap_or(&Value::Null),
11994                    bytes,
11995                )?,
11996                Some("already_present") => {}
11997                _ => return Err(invalid_feed("proposal upload status is invalid")),
11998            }
11999            references.push(json!({
12000                "sha256": hash,
12001                "bytes": bytes.len(),
12002                "reservation_id": reservation_id,
12003            }));
12004        }
12005        body["blobs"] = Value::Array(references);
12006    }
12007    // A proposal can be queued through the staged lane, so acceptance has to be
12008    // able to restate it. Capping only this side would make a large proposal
12009    // permanently unacceptable.
12010    stage_oversized_v2_change(cfg, &head.brain_id, &operations, &mut body)?;
12011    let path = format!("/api/hub/brains/{}/v2/commits", head.brain_id);
12012    let mut result = ensure_ok(
12013        request_patient(cfg, "POST", &path, Some(&body), Auth::Required)?,
12014        "exact proposal acceptance",
12015    )?;
12016    let mut candidate_hub_signer = None;
12017    if result.get("code").and_then(Value::as_str) == Some("brain_signature_required") {
12018        let request_id = result
12019            .get("request_id")
12020            .and_then(Value::as_str)
12021            .ok_or_else(|| invalid_feed("proposal acceptance has no request id"))?
12022            .to_string();
12023        let challenge = result
12024            .get("signing_challenge")
12025            .ok_or_else(|| invalid_feed("proposal acceptance has no signing challenge"))?;
12026        let (challenge_id, signature, actor_signer) = sign_verified_v2_candidate(
12027            cfg,
12028            &head,
12029            &expected_candidate,
12030            &expected_candidate_assets,
12031            mutation_id,
12032            &v2_signed_request_view(&body, &operations),
12033            challenge,
12034        )?;
12035        body["signing_challenge_id"] = Value::String(challenge_id);
12036        body["signature_base64url"] = Value::String(signature);
12037        candidate_hub_signer = Some(actor_signer);
12038        result = ensure_ok(
12039            request_with_request_id(cfg, "POST", &path, Some(&body), Auth::Required, &request_id)?,
12040            "signed exact proposal acceptance",
12041        )?;
12042    }
12043    let refreshed = v2_verified_head(cfg, brain)?
12044        .ok_or_else(|| invalid_feed("v2 head disappeared after proposal acceptance"))?;
12045    if candidate_hub_signer
12046        .as_ref()
12047        .is_some_and(|expected| refreshed.trust.hub_signer.as_ref() != Some(expected))
12048        || refreshed
12049            .pointer
12050            .as_ref()
12051            .map(|pointer| pointer.commit_hash.as_str())
12052            != result.get("commit_hash").and_then(Value::as_str)
12053        || result.get("proposal_id").and_then(Value::as_str) != Some(proposal_id)
12054        || result.get("proposal_state").and_then(Value::as_str) != Some("accepted")
12055    {
12056        return Err(LinkError::RemoteAdvancedDuringSync);
12057    }
12058    accept_v2_head(cfg, &refreshed)?;
12059    Ok(result)
12060}
12061
12062// ─────────────────────────────────────────────────────────────────────────────
12063// propose — write without trust: evidence into the owner's inbox
12064// ─────────────────────────────────────────────────────────────────────────────
12065
12066/// Submit `body` to the published site `handle`, addressed to its app page
12067/// `app` (a page that declares the `write-inbox` capability). Deliberately
12068/// unauthenticated — this is the cross-party door; the submission lands as
12069/// *evidence* in the owner's `sources/inbox/`, never as truth, and the
12070/// owner's curator accepts or rejects it. Returns the hub's `{id, path}`
12071/// receipt.
12072pub fn propose(cfg: &HubConfig, handle: &str, app: &str, body: &str) -> LinkResult<Value> {
12073    require_valid_handle(handle)?;
12074    if body.len() as u64 > MAX_PROPOSE_BYTES {
12075        return Err(LinkError::ProposeTooLarge {
12076            bytes: body.len() as u64,
12077        });
12078    }
12079    let payload = json!({ "app": app, "body": body });
12080    // A ULID-shaped target is a bare brain address (link.md §7.4's
12081    // generalization): the brain inbox door, open on public brains, where a
12082    // configured credential earns a bigger actor-class budget. Anything else
12083    // is a published-site handle: that door is unauthenticated by design.
12084    let (path, auth) = if crate::ulid::is_ulid(handle) {
12085        (format!("/api/hub/brains/{handle}/inbox"), Auth::Optional)
12086    } else {
12087        (format!("/api/hub/sites/{handle}/inbox"), Auth::None)
12088    };
12089    ensure_ok(
12090        request(cfg, "POST", &path, Some(&payload), auth)?,
12091        "propose",
12092    )
12093}
12094
12095// ─────────────────────────────────────────────────────────────────────────────
12096// subscribe — follow feed-head movement
12097// ─────────────────────────────────────────────────────────────────────────────
12098
12099/// One observation of a brain's feed head.
12100#[derive(Debug, serde::Serialize)]
12101pub struct Head {
12102    /// The brain id.
12103    pub brain: String,
12104    /// The hub's durable feed cursor — advances on every accepted write.
12105    pub seq: u64,
12106    /// The hub's `updatedAt` for the brain, when present.
12107    #[serde(rename = "updatedAt", skip_serializing_if = "Option::is_none")]
12108    pub updated_at: Option<String>,
12109    /// SHA-256 of the exact signed head entry.
12110    #[serde(rename = "feedHash", skip_serializing_if = "Option::is_none")]
12111    pub feed_hash: Option<String>,
12112    /// Whether the head entry's content hash, identity, and Ed25519 signature
12113    /// were verified locally. Path-scoped grants get head movement only.
12114    pub verified: bool,
12115}
12116
12117struct BoundedVecVisitor<T, const MAX: usize> {
12118    label: &'static str,
12119    marker: std::marker::PhantomData<T>,
12120}
12121
12122impl<'de, T, const MAX: usize> serde::de::Visitor<'de> for BoundedVecVisitor<T, MAX>
12123where
12124    T: Deserialize<'de>,
12125{
12126    type Value = Vec<T>;
12127
12128    fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12129        write!(formatter, "at most {MAX} {}", self.label)
12130    }
12131
12132    fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
12133    where
12134        A: serde::de::SeqAccess<'de>,
12135    {
12136        if sequence.size_hint().is_some_and(|size| size > MAX) {
12137            return Err(serde::de::Error::custom(format!(
12138                "{} exceeds the {MAX}-item limit",
12139                self.label
12140            )));
12141        }
12142        let mut values = Vec::with_capacity(sequence.size_hint().unwrap_or(0).min(MAX));
12143        while let Some(value) = sequence.next_element()? {
12144            if values.len() == MAX {
12145                return Err(serde::de::Error::custom(format!(
12146                    "{} exceeds the {MAX}-item limit",
12147                    self.label
12148                )));
12149            }
12150            values.push(value);
12151        }
12152        Ok(values)
12153    }
12154}
12155
12156fn deserialize_bounded_vec<'de, D, T, const MAX: usize>(
12157    deserializer: D,
12158    label: &'static str,
12159) -> Result<Vec<T>, D::Error>
12160where
12161    D: serde::Deserializer<'de>,
12162    T: Deserialize<'de>,
12163{
12164    deserializer.deserialize_seq(BoundedVecVisitor::<T, MAX> {
12165        label,
12166        marker: std::marker::PhantomData,
12167    })
12168}
12169
12170fn deserialize_feed_files<'de, D>(deserializer: D) -> Result<Vec<FeedFile>, D::Error>
12171where
12172    D: serde::Deserializer<'de>,
12173{
12174    deserialize_bounded_vec::<D, FeedFile, MAX_PUSH_FILES>(deserializer, "feed files")
12175}
12176
12177fn deserialize_removed_paths<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12178where
12179    D: serde::Deserializer<'de>,
12180{
12181    deserialize_bounded_vec::<D, String, MAX_PUSH_FILES>(deserializer, "removed paths")
12182}
12183
12184fn deserialize_previous_identities<'de, D>(
12185    deserializer: D,
12186) -> Result<Vec<PreviousIdentity>, D::Error>
12187where
12188    D: serde::Deserializer<'de>,
12189{
12190    deserialize_bounded_vec::<D, PreviousIdentity, MAX_IDENTITY_ROTATIONS>(
12191        deserializer,
12192        "previous identities",
12193    )
12194}
12195
12196fn deserialize_rotations<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
12197where
12198    D: serde::Deserializer<'de>,
12199{
12200    deserialize_bounded_vec::<D, String, MAX_IDENTITY_ROTATIONS>(
12201        deserializer,
12202        "rotation statements",
12203    )
12204}
12205
12206fn deserialize_feed_items<'de, D>(deserializer: D) -> Result<Vec<FeedItem>, D::Error>
12207where
12208    D: serde::Deserializer<'de>,
12209{
12210    deserialize_bounded_vec::<D, FeedItem, FEED_PAGE_LIMIT>(deserializer, "feed entries")
12211}
12212
12213#[derive(Debug, Clone, Deserialize, Serialize)]
12214struct FeedFile {
12215    path: String,
12216    sha256: String,
12217    bytes: u64,
12218}
12219
12220#[cfg(test)]
12221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12222enum V1DisclosureError {
12223    DuplicateFile,
12224    DuplicateRemoved,
12225    PushManifestMismatch,
12226    EditMissingChange,
12227    EditFalseFile,
12228    RemovedMismatch,
12229}
12230
12231/// Validate wire-profile-v1 `files`/`removed` disclosure semantics against
12232/// the exact previous and resulting pack manifests. `edit` accepts the
12233/// historical superset form while still requiring every actual changed path.
12234#[cfg(test)]
12235fn verify_v1_manifest_disclosure(
12236    kind: &str,
12237    previous: &[FeedFile],
12238    resulting: &[FeedFile],
12239    files: &[FeedFile],
12240    removed: &[String],
12241) -> Result<(), V1DisclosureError> {
12242    fn as_map(
12243        files: &[FeedFile],
12244    ) -> Result<std::collections::BTreeMap<&str, (&str, u64)>, V1DisclosureError> {
12245        let mut result = std::collections::BTreeMap::new();
12246        for file in files {
12247            if result
12248                .insert(file.path.as_str(), (file.sha256.as_str(), file.bytes))
12249                .is_some()
12250            {
12251                return Err(V1DisclosureError::DuplicateFile);
12252            }
12253        }
12254        Ok(result)
12255    }
12256    let previous = as_map(previous)?;
12257    let resulting = as_map(resulting)?;
12258    let disclosed = as_map(files)?;
12259    let removed_set: std::collections::BTreeSet<&str> =
12260        removed.iter().map(String::as_str).collect();
12261    if removed_set.len() != removed.len() {
12262        return Err(V1DisclosureError::DuplicateRemoved);
12263    }
12264    let expected_removed: std::collections::BTreeSet<&str> = previous
12265        .keys()
12266        .copied()
12267        .filter(|path| !resulting.contains_key(path))
12268        .collect();
12269    if removed_set != expected_removed {
12270        return Err(V1DisclosureError::RemovedMismatch);
12271    }
12272    if kind == "push" {
12273        return if disclosed == resulting {
12274            Ok(())
12275        } else {
12276            Err(V1DisclosureError::PushManifestMismatch)
12277        };
12278    }
12279    if kind != "edit" {
12280        return Err(V1DisclosureError::EditFalseFile);
12281    }
12282    if disclosed
12283        .iter()
12284        .any(|(path, value)| resulting.get(path) != Some(value))
12285    {
12286        return Err(V1DisclosureError::EditFalseFile);
12287    }
12288    for (path, value) in &resulting {
12289        if previous.get(path) != Some(value) && !disclosed.contains_key(path) {
12290            return Err(V1DisclosureError::EditMissingChange);
12291        }
12292    }
12293    Ok(())
12294}
12295
12296#[derive(Debug, Clone, Deserialize, Serialize)]
12297struct FeedEntry {
12298    v: u8,
12299    seq: u64,
12300    ts: String,
12301    brain: String,
12302    public_key: String,
12303    kind: String,
12304    op: String,
12305    pack_sha256: String,
12306    #[serde(deserialize_with = "deserialize_feed_files")]
12307    files: Vec<FeedFile>,
12308    #[serde(deserialize_with = "deserialize_removed_paths")]
12309    removed: Vec<String>,
12310    prev_entry_hash: Option<String>,
12311    sig: String,
12312}
12313
12314#[derive(Serialize)]
12315struct UnsignedFeedEntry<'a> {
12316    v: u8,
12317    seq: u64,
12318    ts: &'a str,
12319    brain: &'a str,
12320    public_key: &'a str,
12321    kind: &'a str,
12322    op: &'a str,
12323    pack_sha256: &'a str,
12324    files: &'a [FeedFile],
12325    removed: &'a [String],
12326    prev_entry_hash: &'a Option<String>,
12327}
12328
12329#[derive(Debug, Clone, Deserialize, Serialize)]
12330struct FeedItem {
12331    hash: String,
12332    entry: FeedEntry,
12333}
12334
12335#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12336struct FeedIdentity {
12337    fingerprint: String,
12338    #[serde(rename = "publicKeySpki")]
12339    public_key_spki: String,
12340    /// Rotation history (link.md §9.1): identities this brain previously
12341    /// signed as. Entries verify against current OR previous — rotation
12342    /// never invalidates history.
12343    #[serde(default, deserialize_with = "deserialize_previous_identities")]
12344    previous: Vec<PreviousIdentity>,
12345    /// Exact normative rotation statements, oldest first. A list of previous
12346    /// public keys without these old-key signatures is not a trust chain.
12347    #[serde(default, deserialize_with = "deserialize_rotations")]
12348    rotations: Vec<String>,
12349}
12350
12351#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
12352struct PreviousIdentity {
12353    fingerprint: String,
12354    #[serde(rename = "publicKeySpki")]
12355    public_key_spki: String,
12356}
12357
12358#[derive(Debug, Deserialize)]
12359struct FeedResponse {
12360    #[serde(rename = "headSeq")]
12361    head_seq: u64,
12362    #[serde(rename = "feedHash")]
12363    feed_hash: Option<String>,
12364    identity: Option<FeedIdentity>,
12365    #[serde(deserialize_with = "deserialize_feed_items")]
12366    entries: Vec<FeedItem>,
12367    #[serde(rename = "scopeLimited")]
12368    scope_limited: bool,
12369}
12370
12371#[derive(Debug, Deserialize, Serialize)]
12372#[serde(deny_unknown_fields)]
12373struct RotationStatement {
12374    v: u8,
12375    op: String,
12376    brain: String,
12377    public_key: String,
12378    new_brain: String,
12379    new_public_key: String,
12380    prior_head_seq: u64,
12381    prior_feed_hash: Option<String>,
12382    ts: String,
12383    sig: String,
12384}
12385
12386#[derive(Debug, Clone, Deserialize, Serialize)]
12387struct TrustState {
12388    v: u8,
12389    origin: String,
12390    /// The exact caller-visible ref whose resolution this checkpoint pins.
12391    /// Slugs/handles are mutable names at the hub; once observed, they may not
12392    /// silently resolve to a different canonical brain.
12393    #[serde(default)]
12394    requested: String,
12395    /// Canonical hub brain id returned for `requested`.
12396    brain: String,
12397    /// Federation home origin when this ref was learned from a registry.
12398    /// Once observed, the registry cannot silently relocate the same handle.
12399    #[serde(default, skip_serializing_if = "Option::is_none")]
12400    home: Option<String>,
12401    anchor: String,
12402    current: String,
12403    #[serde(rename = "headSeq")]
12404    head_seq: u64,
12405    #[serde(rename = "feedHash")]
12406    feed_hash: Option<String>,
12407    /// Exact accepted old-key-signed rotation statements, oldest first. v2
12408    /// checkpoints require this vector to be an immutable prefix of every
12409    /// subsequently served identity chain.
12410    #[serde(default)]
12411    rotations: Vec<String>,
12412    /// Hub pointer-signing identity pinned on first v2 observation. This is
12413    /// separate from the brain key, which signs commits.
12414    #[serde(default, skip_serializing_if = "Option::is_none")]
12415    hub_signer: Option<String>,
12416    /// Explicit transport profile. Older accepted non-empty v2 checkpoints
12417    /// are recognized by their pinned hub signer during one-way migration.
12418    #[serde(default, skip_serializing_if = "Option::is_none")]
12419    protocol_profile: Option<String>,
12420}
12421
12422fn accepted_as_v2(state: &TrustState) -> bool {
12423    state.protocol_profile.as_deref() == Some("link-v2") || state.hub_signer.is_some()
12424}
12425
12426fn has_accepted_v2_ref(cfg: &HubConfig, requested: &str) -> LinkResult<bool> {
12427    let directory = open_trust_dir(cfg)?;
12428    if load_trust_in(cfg, &directory, requested)?.is_some_and(|state| accepted_as_v2(&state)) {
12429        return Ok(true);
12430    }
12431    let Some(alias) = load_alias_in(cfg, &directory, requested)? else {
12432        return Ok(false);
12433    };
12434    Ok(load_trust_in(cfg, &directory, &alias.brain)?.is_some_and(|state| accepted_as_v2(&state)))
12435}
12436
12437#[derive(Debug, Clone, Deserialize, Serialize)]
12438struct AliasBinding {
12439    v: u8,
12440    origin: String,
12441    requested: String,
12442    brain: String,
12443    #[serde(default, skip_serializing_if = "Option::is_none")]
12444    home: Option<String>,
12445}
12446
12447struct VerifiedRemote {
12448    head: Head,
12449    identity: Option<FeedIdentity>,
12450    head_entry: Option<FeedItem>,
12451    /// Present when the caller requested and verified the complete chain.
12452    entries: Vec<FeedItem>,
12453    anchor: Option<String>,
12454}
12455
12456fn invalid_feed(message: impl Into<String>) -> LinkError {
12457    LinkError::InvalidFeed {
12458        message: message.into(),
12459    }
12460}
12461
12462fn is_sha256(value: &str) -> bool {
12463    value.len() == 64
12464        && value
12465            .bytes()
12466            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
12467}
12468
12469fn identity_fingerprint(public_key_spki: &str) -> LinkResult<String> {
12470    let der = URL_SAFE_NO_PAD
12471        .decode(public_key_spki)
12472        .map_err(|_| invalid_feed("identity public key is not base64url"))?;
12473    if der.len() != ED25519_SPKI_PREFIX.len() + 32 || !der.starts_with(&ED25519_SPKI_PREFIX) {
12474        return Err(invalid_feed(
12475            "identity public key is not a valid Ed25519 SPKI",
12476        ));
12477    }
12478    Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(&der)))
12479}
12480
12481/// Verify the hub's current identity and every old-key-signed rotation that
12482/// leads from the original TOFU anchor to it. `previous` alone is metadata,
12483/// never authority.
12484fn verify_identity_chain(
12485    identity: &FeedIdentity,
12486    pinned: Option<&TrustState>,
12487) -> LinkResult<String> {
12488    if identity.previous.len() > MAX_IDENTITY_ROTATIONS
12489        || identity.rotations.len() > MAX_IDENTITY_ROTATIONS
12490    {
12491        return Err(invalid_feed(
12492            "identity rotation history exceeds the client cap",
12493        ));
12494    }
12495    if identity.fingerprint != identity_fingerprint(&identity.public_key_spki)? {
12496        return Err(invalid_feed(
12497            "current identity fingerprint does not match its public key",
12498        ));
12499    }
12500    for previous in &identity.previous {
12501        if previous.fingerprint != identity_fingerprint(&previous.public_key_spki)? {
12502            return Err(invalid_feed(
12503                "previous identity fingerprint does not match its public key",
12504            ));
12505        }
12506    }
12507    if identity.rotations.len() != identity.previous.len() {
12508        return Err(invalid_feed(
12509            "identity history is missing an old-key-signed rotation statement",
12510        ));
12511    }
12512
12513    // The HTTP identity lists prior identities newest first. Rotation
12514    // statements are chronological, so verification walks the reversed list
12515    // and ends at the current identity.
12516    let mut chain: Vec<(&str, &str)> = identity
12517        .previous
12518        .iter()
12519        .rev()
12520        .map(|p| (p.fingerprint.as_str(), p.public_key_spki.as_str()))
12521        .collect();
12522    chain.push((&identity.fingerprint, &identity.public_key_spki));
12523
12524    for (index, raw) in identity.rotations.iter().enumerate() {
12525        let statement: RotationStatement = serde_json::from_str(raw)
12526            .map_err(|_| invalid_feed("rotation statement did not parse exactly"))?;
12527        let (old_fingerprint, old_spki) = chain[index];
12528        let (new_fingerprint, new_spki) = chain[index + 1];
12529        if statement.v != 1
12530            || statement.op != "rotate"
12531            || statement.brain != format!("ed25519:{old_fingerprint}")
12532            || statement.public_key != old_spki
12533            || statement.new_brain != format!("ed25519:{new_fingerprint}")
12534            || statement.new_public_key != new_spki
12535            || (statement.prior_head_seq == 0 && statement.prior_feed_hash.is_some())
12536            || (statement.prior_head_seq > 0
12537                && statement
12538                    .prior_feed_hash
12539                    .as_deref()
12540                    .is_none_or(|hash| !is_sha256(hash)))
12541        {
12542            return Err(invalid_feed(
12543                "rotation statement does not connect adjacent identities",
12544            ));
12545        }
12546        let unsigned = serde_json::to_string(&UnsignedRotation {
12547            v: statement.v,
12548            op: &statement.op,
12549            brain: &statement.brain,
12550            public_key: &statement.public_key,
12551            new_brain: &statement.new_brain,
12552            new_public_key: &statement.new_public_key,
12553            prior_head_seq: statement.prior_head_seq,
12554            prior_feed_hash: statement.prior_feed_hash.as_deref(),
12555            ts: statement.ts.clone(),
12556        })
12557        .map_err(|_| invalid_feed("could not canonicalize rotation statement"))?;
12558        let exact = format!(
12559            "{},\"sig\":\"{}\"}}",
12560            &unsigned[..unsigned.len() - 1],
12561            statement.sig
12562        );
12563        if exact != *raw {
12564            return Err(invalid_feed(
12565                "rotation statement is not in normative serialization",
12566            ));
12567        }
12568        let der = URL_SAFE_NO_PAD
12569            .decode(old_spki)
12570            .map_err(|_| invalid_feed("rotation public key is not base64url"))?;
12571        let signature = URL_SAFE_NO_PAD
12572            .decode(&statement.sig)
12573            .map_err(|_| invalid_feed("rotation signature is not base64url"))?;
12574        UnparsedPublicKey::new(&ED25519, &der[ED25519_SPKI_PREFIX.len()..])
12575            .verify(unsigned.as_bytes(), &signature)
12576            .map_err(|_| invalid_feed("rotation signature verification failed"))?;
12577        if index > 0 {
12578            let prior: RotationStatement = serde_json::from_str(&identity.rotations[index - 1])
12579                .map_err(|_| invalid_feed("prior rotation statement did not parse"))?;
12580            if statement.prior_head_seq < prior.prior_head_seq {
12581                return Err(invalid_feed("rotation feed boundaries move backward"));
12582            }
12583        }
12584    }
12585
12586    let anchor = format!("ed25519:{}", chain[0].0);
12587    let current = format!("ed25519:{}", identity.fingerprint);
12588    if let Some(pin) = pinned {
12589        if pin.anchor != anchor {
12590            return Err(invalid_feed(
12591                "served identity chain does not descend from the pinned anchor",
12592            ));
12593        }
12594        if !chain
12595            .iter()
12596            .any(|(fingerprint, _)| pin.current == format!("ed25519:{fingerprint}"))
12597        {
12598            return Err(invalid_feed(
12599                "served identity chain forked away from the last pinned identity",
12600            ));
12601        }
12602        if pin.current == current && pin.anchor != current && identity.rotations.is_empty() {
12603            return Err(invalid_feed("served identity discarded its rotation chain"));
12604        }
12605        if pin.v >= 2
12606            && (identity.rotations.len() < pin.rotations.len()
12607                || identity.rotations[..pin.rotations.len()] != pin.rotations)
12608        {
12609            return Err(invalid_feed(
12610                "served identity rewrote the locally accepted rotation history",
12611            ));
12612        }
12613    }
12614    Ok(anchor)
12615}
12616
12617fn verify_rotation_feed_boundaries(
12618    identity: &FeedIdentity,
12619    pinned: Option<&TrustState>,
12620    observed: &[FeedItem],
12621    advertised_seq: u64,
12622) -> LinkResult<()> {
12623    let mut chain: Vec<String> = identity
12624        .previous
12625        .iter()
12626        .rev()
12627        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12628        .collect();
12629    chain.push(format!("ed25519:{}", identity.fingerprint));
12630    let pinned_index = pinned.and_then(|pin| chain.iter().position(|key| key == &pin.current));
12631
12632    for (index, raw) in identity.rotations.iter().enumerate() {
12633        let rotation: RotationStatement = serde_json::from_str(raw)
12634            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
12635        if rotation.prior_head_seq > advertised_seq {
12636            return Err(invalid_feed(
12637                "rotation claims a feed boundary beyond the advertised head",
12638            ));
12639        }
12640        if let (Some(pin), Some(pin_index)) = (pinned, pinned_index) {
12641            if index >= pin_index && rotation.prior_head_seq < pin.head_seq {
12642                return Err(invalid_feed(
12643                    "newly disclosed rotation predates the local feed checkpoint",
12644                ));
12645            }
12646        }
12647        let actual = if rotation.prior_head_seq == 0 {
12648            None
12649        } else if pinned.is_some_and(|pin| pin.head_seq == rotation.prior_head_seq) {
12650            pinned.and_then(|pin| pin.feed_hash.as_deref())
12651        } else {
12652            observed
12653                .iter()
12654                .find(|item| item.entry.seq == rotation.prior_head_seq)
12655                .map(|item| item.hash.as_str())
12656        };
12657        if let Some(actual) = actual {
12658            if rotation.prior_feed_hash.as_deref() != Some(actual) {
12659                return Err(invalid_feed(
12660                    "rotation statement does not commit the verified feed boundary",
12661                ));
12662            }
12663        } else if rotation.prior_head_seq == 0 {
12664            // The empty-feed boundary is represented by (0, null); there is
12665            // no entry hash to find in `observed`.
12666        } else if pinned.is_some_and(|pin| {
12667            pinned_index.is_some_and(|pin_index| index >= pin_index)
12668                || rotation.prior_head_seq >= pin.head_seq
12669        }) {
12670            return Err(invalid_feed(
12671                "rotation feed boundary was not present in the verified chain",
12672            ));
12673        }
12674    }
12675    Ok(())
12676}
12677
12678/// Once a client has checkpointed identity K, identities retired before K may
12679/// verify old history but can never regain authority over a later sequence.
12680/// This is also the safe migration rule for legacy v1 checkpoints that did not
12681/// persist the exact accepted rotation statements.
12682fn reject_retired_signer_after_checkpoint(
12683    identity: &FeedIdentity,
12684    pinned: Option<&TrustState>,
12685    item: &FeedItem,
12686) -> LinkResult<()> {
12687    let Some(pin) = pinned else {
12688        return Ok(());
12689    };
12690    if item.entry.seq <= pin.head_seq {
12691        return Ok(());
12692    }
12693    let mut chain: Vec<String> = identity
12694        .previous
12695        .iter()
12696        .rev()
12697        .map(|previous| format!("ed25519:{}", previous.fingerprint))
12698        .collect();
12699    chain.push(format!("ed25519:{}", identity.fingerprint));
12700    let pinned_index = chain
12701        .iter()
12702        .position(|key| key == &pin.current)
12703        .ok_or_else(|| invalid_feed("pinned identity is absent from the served chain"))?;
12704    let signer_index = chain
12705        .iter()
12706        .position(|key| key == &item.entry.brain)
12707        .ok_or_else(|| invalid_feed("feed signer is absent from the served identity chain"))?;
12708    if signer_index < pinned_index {
12709        return Err(invalid_feed(
12710            "a retired identity attempted to sign after the local checkpoint",
12711        ));
12712    }
12713    Ok(())
12714}
12715
12716fn trust_file_name(cfg: &HubConfig, brain: &str) -> LinkResult<String> {
12717    let origin = normalized_origin(&cfg.hub)?;
12718    let key = format!(
12719        "{:x}",
12720        Sha256::digest(format!("{origin}\0{brain}").as_bytes())
12721    );
12722    Ok(format!("{key}.json"))
12723}
12724
12725fn alias_file_name(cfg: &HubConfig, alias: &str) -> LinkResult<String> {
12726    let origin = normalized_origin(&cfg.hub)?;
12727    let key = format!(
12728        "{:x}",
12729        Sha256::digest(format!("{origin}\0alias\0{alias}").as_bytes())
12730    );
12731    Ok(format!("alias-{key}.json"))
12732}
12733
12734#[cfg(any(unix, windows))]
12735struct TrustLock {
12736    _file: std::fs::File,
12737}
12738
12739#[cfg(unix)]
12740fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12741    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12742
12743    let lock_string = format!(".{state_name}.lock");
12744    let lock_name = c_name(lock_string.as_bytes(), &lock_string)?;
12745    let fd = unsafe {
12746        libc::openat(
12747            directory.as_raw_fd(),
12748            lock_name.as_ptr(),
12749            libc::O_RDWR | libc::O_CREAT | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12750            0o600,
12751        )
12752    };
12753    if fd < 0 {
12754        return Err(std::io::Error::last_os_error().into());
12755    }
12756    let file = unsafe { std::fs::File::from_raw_fd(fd) };
12757    if !file.metadata()?.is_file() {
12758        return Err(LinkError::UnsafePath { path: lock_string });
12759    }
12760    if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
12761        return Err(std::io::Error::last_os_error().into());
12762    }
12763    Ok(TrustLock { _file: file })
12764}
12765
12766#[cfg(windows)]
12767fn lock_trust_name(directory: &std::fs::File, state_name: &str) -> LinkResult<TrustLock> {
12768    let lock_name = format!(".{state_name}.lock");
12769    let file = crate::fsx::lock_exclusive_beneath(directory, Path::new(&lock_name))?;
12770    Ok(TrustLock { _file: file })
12771}
12772
12773#[cfg(any(unix, windows))]
12774fn lock_trust_many(
12775    cfg: &HubConfig,
12776    directory: &std::fs::File,
12777    refs: &[&str],
12778) -> LinkResult<Vec<TrustLock>> {
12779    let mut names = refs
12780        .iter()
12781        .map(|reference| trust_file_name(cfg, reference))
12782        .collect::<LinkResult<Vec<_>>>()?;
12783    names.sort();
12784    names.dedup();
12785    names
12786        .iter()
12787        .map(|name| lock_trust_name(directory, name))
12788        .collect()
12789}
12790
12791#[cfg(not(any(unix, windows)))]
12792fn lock_trust_many(
12793    _cfg: &HubConfig,
12794    _directory: &TrustDirectory,
12795    _refs: &[&str],
12796) -> LinkResult<Vec<()>> {
12797    Err(LinkError::UnsupportedPlatform {
12798        operation: "verified link.md state",
12799    })
12800}
12801
12802#[cfg(any(unix, windows))]
12803type TrustDirectory = std::fs::File;
12804
12805#[cfg(not(any(unix, windows)))]
12806struct TrustDirectory;
12807
12808#[cfg(unix)]
12809fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12810    use std::os::fd::AsRawFd as _;
12811
12812    let directory = open_or_create_dir_nofollow(&cfg.state_dir.join("trust"))?;
12813    if unsafe { libc::fchmod(directory.as_raw_fd(), 0o700) } != 0 {
12814        return Err(std::io::Error::last_os_error().into());
12815    }
12816    directory.sync_all()?;
12817    Ok(directory)
12818}
12819
12820#[cfg(windows)]
12821fn open_trust_dir(cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12822    let marker = cfg.state_dir.join("trust").join(".directory");
12823    crate::fsx::write_atomic(&marker, b"link.md trust directory\n")?;
12824    Ok(crate::fsx::open_directory_nofollow(
12825        marker.parent().expect("trust marker has a parent"),
12826    )?)
12827}
12828
12829#[cfg(not(any(unix, windows)))]
12830fn open_trust_dir(_cfg: &HubConfig) -> LinkResult<TrustDirectory> {
12831    Err(LinkError::UnsupportedPlatform {
12832        operation: "verified link.md state",
12833    })
12834}
12835
12836#[cfg(unix)]
12837fn load_trust_in(
12838    cfg: &HubConfig,
12839    directory: &TrustDirectory,
12840    requested: &str,
12841) -> LinkResult<Option<TrustState>> {
12842    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12843
12844    let name_string = trust_file_name(cfg, requested)?;
12845    let name = c_name(name_string.as_bytes(), &name_string)?;
12846    let fd = unsafe {
12847        libc::openat(
12848            directory.as_raw_fd(),
12849            name.as_ptr(),
12850            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12851        )
12852    };
12853    if fd < 0 {
12854        let error = std::io::Error::last_os_error();
12855        if error.kind() == std::io::ErrorKind::NotFound {
12856            return Ok(None);
12857        }
12858        return Err(LinkError::UnsafePath { path: name_string });
12859    }
12860    let file = unsafe { std::fs::File::from_raw_fd(fd) };
12861    if !file.metadata()?.is_file() {
12862        return Err(LinkError::UnsafePath { path: name_string });
12863    }
12864    let mut bytes = Vec::new();
12865    file.take(1024 * 1024 + 1).read_to_end(&mut bytes)?;
12866    if bytes.len() > 1024 * 1024 {
12867        return Err(invalid_feed("local identity/feed checkpoint is oversized"));
12868    }
12869    let mut state: TrustState = serde_json::from_slice(&bytes)
12870        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12871    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12872        return Err(invalid_feed(
12873            "local identity/feed checkpoint does not match this hub and brain",
12874        ));
12875    }
12876    if state.v == 1 {
12877        // Legacy files were keyed by canonical brain id. They can migrate only
12878        // when the caller used that exact id; old slug lookups had no durable
12879        // alias binding and therefore cannot be guessed safely.
12880        if state.brain != requested {
12881            return Err(invalid_feed(
12882                "legacy checkpoint is not bound to the requested brain id",
12883            ));
12884        }
12885        state.requested = requested.to_string();
12886    } else if state.requested != requested {
12887        return Err(invalid_feed(
12888            "local identity/feed checkpoint is bound to a different requested ref",
12889        ));
12890    }
12891    Ok(Some(state))
12892}
12893
12894#[cfg(windows)]
12895fn load_trust_in(
12896    cfg: &HubConfig,
12897    directory: &TrustDirectory,
12898    requested: &str,
12899) -> LinkResult<Option<TrustState>> {
12900    let name = trust_file_name(cfg, requested)?;
12901    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
12902    let bytes = match reader.read(Path::new(&name), 1024 * 1024) {
12903        Ok(bytes) => bytes,
12904        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
12905        Err(_) => return Err(LinkError::UnsafePath { path: name }),
12906    };
12907    let mut state: TrustState = serde_json::from_slice(&bytes)
12908        .map_err(|_| invalid_feed("local identity/feed checkpoint is corrupt"))?;
12909    if !matches!(state.v, 1 | 2) || state.origin != normalized_origin(&cfg.hub)? {
12910        return Err(invalid_feed(
12911            "local identity/feed checkpoint does not match this hub and brain",
12912        ));
12913    }
12914    if state.v == 1 {
12915        if state.brain != requested {
12916            return Err(invalid_feed(
12917                "legacy checkpoint is not bound to the requested brain id",
12918            ));
12919        }
12920        state.requested = requested.to_string();
12921    } else if state.requested != requested {
12922        return Err(invalid_feed(
12923            "local identity/feed checkpoint is bound to a different requested ref",
12924        ));
12925    }
12926    Ok(Some(state))
12927}
12928
12929#[cfg(not(any(unix, windows)))]
12930fn load_trust_in(
12931    _cfg: &HubConfig,
12932    _directory: &TrustDirectory,
12933    _brain: &str,
12934) -> LinkResult<Option<TrustState>> {
12935    Err(LinkError::UnsupportedPlatform {
12936        operation: "verified link.md state",
12937    })
12938}
12939
12940#[cfg(all(test, any(unix, windows)))]
12941fn load_trust(cfg: &HubConfig, requested: &str) -> LinkResult<Option<TrustState>> {
12942    let directory = open_trust_dir(cfg)?;
12943    load_trust_in(cfg, &directory, requested)
12944}
12945
12946#[cfg(unix)]
12947fn save_trust_in(
12948    cfg: &HubConfig,
12949    directory: &TrustDirectory,
12950    state: &TrustState,
12951) -> LinkResult<()> {
12952    use std::os::fd::{AsRawFd as _, FromRawFd as _};
12953
12954    let name_string = trust_file_name(cfg, &state.requested)?;
12955    let name = c_name(name_string.as_bytes(), &name_string)?;
12956    let mut bytes = serde_json::to_vec(state)
12957        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
12958    bytes.push(b'\n');
12959
12960    let nonce = std::time::SystemTime::now()
12961        .duration_since(std::time::UNIX_EPOCH)
12962        .unwrap_or_default()
12963        .as_nanos();
12964    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
12965    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
12966    let fd = unsafe {
12967        libc::openat(
12968            directory.as_raw_fd(),
12969            temp.as_ptr(),
12970            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
12971            0o600,
12972        )
12973    };
12974    if fd < 0 {
12975        return Err(std::io::Error::last_os_error().into());
12976    }
12977    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
12978    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
12979        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12980        return Err(error.into());
12981    }
12982    drop(file);
12983    if unsafe {
12984        libc::renameat(
12985            directory.as_raw_fd(),
12986            temp.as_ptr(),
12987            directory.as_raw_fd(),
12988            name.as_ptr(),
12989        )
12990    } != 0
12991    {
12992        let error = std::io::Error::last_os_error();
12993        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
12994        return Err(error.into());
12995    }
12996    directory.sync_all()?;
12997    Ok(())
12998}
12999
13000#[cfg(windows)]
13001fn save_trust_in(
13002    cfg: &HubConfig,
13003    directory: &TrustDirectory,
13004    state: &TrustState,
13005) -> LinkResult<()> {
13006    let name = trust_file_name(cfg, &state.requested)?;
13007    let mut bytes = serde_json::to_vec(state)
13008        .map_err(|_| invalid_feed("could not serialize local trust checkpoint"))?;
13009    bytes.push(b'\n');
13010    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13011    Ok(())
13012}
13013
13014#[cfg(not(any(unix, windows)))]
13015fn save_trust_in(
13016    _cfg: &HubConfig,
13017    _directory: &TrustDirectory,
13018    _state: &TrustState,
13019) -> LinkResult<()> {
13020    Err(LinkError::UnsupportedPlatform {
13021        operation: "verified link.md state",
13022    })
13023}
13024
13025#[cfg(unix)]
13026fn load_alias_in(
13027    cfg: &HubConfig,
13028    directory: &TrustDirectory,
13029    requested: &str,
13030) -> LinkResult<Option<AliasBinding>> {
13031    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13032
13033    let name_string = alias_file_name(cfg, requested)?;
13034    let name = c_name(name_string.as_bytes(), &name_string)?;
13035    let fd = unsafe {
13036        libc::openat(
13037            directory.as_raw_fd(),
13038            name.as_ptr(),
13039            libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13040        )
13041    };
13042    if fd < 0 {
13043        let error = std::io::Error::last_os_error();
13044        if error.kind() == std::io::ErrorKind::NotFound {
13045            return Ok(None);
13046        }
13047        return Err(LinkError::UnsafePath { path: name_string });
13048    }
13049    let file = unsafe { std::fs::File::from_raw_fd(fd) };
13050    if !file.metadata()?.is_file() {
13051        return Err(LinkError::UnsafePath { path: name_string });
13052    }
13053    let mut bytes = Vec::new();
13054    file.take(64 * 1024 + 1).read_to_end(&mut bytes)?;
13055    if bytes.len() > 64 * 1024 {
13056        return Err(invalid_feed("local alias binding is oversized"));
13057    }
13058    let alias: AliasBinding = serde_json::from_slice(&bytes)
13059        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13060    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13061    {
13062        return Err(invalid_feed(
13063            "local alias binding does not match this hub and requested ref",
13064        ));
13065    }
13066    Ok(Some(alias))
13067}
13068
13069#[cfg(windows)]
13070fn load_alias_in(
13071    cfg: &HubConfig,
13072    directory: &TrustDirectory,
13073    requested: &str,
13074) -> LinkResult<Option<AliasBinding>> {
13075    let name = alias_file_name(cfg, requested)?;
13076    let mut reader = crate::fsx::BoundedDirReader::from_root(directory)?;
13077    let bytes = match reader.read(Path::new(&name), 64 * 1024) {
13078        Ok(bytes) => bytes,
13079        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
13080        Err(_) => return Err(LinkError::UnsafePath { path: name }),
13081    };
13082    let alias: AliasBinding = serde_json::from_slice(&bytes)
13083        .map_err(|_| invalid_feed("local alias binding is corrupt"))?;
13084    if alias.v != 1 || alias.origin != normalized_origin(&cfg.hub)? || alias.requested != requested
13085    {
13086        return Err(invalid_feed(
13087            "local alias binding does not match this hub and requested ref",
13088        ));
13089    }
13090    Ok(Some(alias))
13091}
13092
13093#[cfg(not(any(unix, windows)))]
13094fn load_alias_in(
13095    _cfg: &HubConfig,
13096    _directory: &TrustDirectory,
13097    _requested: &str,
13098) -> LinkResult<Option<AliasBinding>> {
13099    Err(LinkError::UnsupportedPlatform {
13100        operation: "verified link.md state",
13101    })
13102}
13103
13104#[cfg(unix)]
13105fn save_alias_in(
13106    cfg: &HubConfig,
13107    directory: &TrustDirectory,
13108    alias: &AliasBinding,
13109) -> LinkResult<()> {
13110    use std::os::fd::{AsRawFd as _, FromRawFd as _};
13111
13112    let name_string = alias_file_name(cfg, &alias.requested)?;
13113    let name = c_name(name_string.as_bytes(), &name_string)?;
13114    let mut bytes = serde_json::to_vec(alias)
13115        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13116    bytes.push(b'\n');
13117    let nonce = std::time::SystemTime::now()
13118        .duration_since(std::time::UNIX_EPOCH)
13119        .unwrap_or_default()
13120        .as_nanos();
13121    let temp_string = format!(".{name_string}.tmp.{}-{nonce}", std::process::id());
13122    let temp = c_name(temp_string.as_bytes(), &temp_string)?;
13123    let fd = unsafe {
13124        libc::openat(
13125            directory.as_raw_fd(),
13126            temp.as_ptr(),
13127            libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13128            0o600,
13129        )
13130    };
13131    if fd < 0 {
13132        return Err(std::io::Error::last_os_error().into());
13133    }
13134    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
13135    if let Err(error) = file.write_all(&bytes).and_then(|_| file.sync_all()) {
13136        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13137        return Err(error.into());
13138    }
13139    drop(file);
13140    if unsafe {
13141        libc::renameat(
13142            directory.as_raw_fd(),
13143            temp.as_ptr(),
13144            directory.as_raw_fd(),
13145            name.as_ptr(),
13146        )
13147    } != 0
13148    {
13149        let error = std::io::Error::last_os_error();
13150        let _ = unsafe { libc::unlinkat(directory.as_raw_fd(), temp.as_ptr(), 0) };
13151        return Err(error.into());
13152    }
13153    directory.sync_all()?;
13154    Ok(())
13155}
13156
13157#[cfg(windows)]
13158fn save_alias_in(
13159    cfg: &HubConfig,
13160    directory: &TrustDirectory,
13161    alias: &AliasBinding,
13162) -> LinkResult<()> {
13163    let name = alias_file_name(cfg, &alias.requested)?;
13164    let mut bytes = serde_json::to_vec(alias)
13165        .map_err(|_| invalid_feed("could not serialize local alias binding"))?;
13166    bytes.push(b'\n');
13167    crate::fsx::write_atomic_beneath(directory, Path::new(&name), &bytes, false, true)?;
13168    Ok(())
13169}
13170
13171#[cfg(not(any(unix, windows)))]
13172fn save_alias_in(
13173    _cfg: &HubConfig,
13174    _directory: &TrustDirectory,
13175    _alias: &AliasBinding,
13176) -> LinkResult<()> {
13177    Err(LinkError::UnsupportedPlatform {
13178        operation: "verified link.md state",
13179    })
13180}
13181
13182/// Load the canonical checkpoint shared by every spelling of one brain and
13183/// the separate alias binding. This also performs the one-way migration from
13184/// pre-v3 checkpoints that stored a full trust state under the alias itself.
13185/// Callers hold both alias and canonical locks before entering.
13186fn load_canonical_pin(
13187    cfg: &HubConfig,
13188    directory: &TrustDirectory,
13189    requested: &str,
13190    resolved_brain: &str,
13191) -> LinkResult<(Option<TrustState>, Option<AliasBinding>)> {
13192    let mut canonical = load_trust_in(cfg, directory, resolved_brain)?;
13193    if requested == resolved_brain {
13194        return Ok((canonical, None));
13195    }
13196
13197    let mut alias = load_alias_in(cfg, directory, requested)?;
13198    if let Some(binding) = &alias {
13199        if binding.brain != resolved_brain {
13200            return Err(LinkError::AliasRebindRequired {
13201                alias: requested.to_string(),
13202                from: binding.brain.clone(),
13203                to: resolved_brain.to_string(),
13204            });
13205        }
13206        return Ok((canonical, alias));
13207    }
13208
13209    // A v2 build stored the complete checkpoint under the requested slug.
13210    // Promote it to the canonical ULID key before creating the lightweight
13211    // alias binding. Never silently merge two independently advanced states.
13212    if let Some(legacy) = load_trust_in(cfg, directory, requested)? {
13213        if legacy.brain != resolved_brain {
13214            return Err(invalid_feed(
13215                "legacy alias checkpoint names a different canonical brain",
13216            ));
13217        }
13218        if let Some(existing) = &canonical {
13219            if existing.brain != legacy.brain
13220                || existing.anchor != legacy.anchor
13221                || existing.current != legacy.current
13222                || existing.head_seq != legacy.head_seq
13223                || existing.feed_hash != legacy.feed_hash
13224                || existing.rotations != legacy.rotations
13225            {
13226                return Err(invalid_feed(
13227                    "legacy alias checkpoint conflicts with the canonical checkpoint",
13228                ));
13229            }
13230        } else {
13231            let mut promoted = legacy.clone();
13232            promoted.requested = resolved_brain.to_string();
13233            promoted.home = None;
13234            save_trust_in(cfg, directory, &promoted)?;
13235            canonical = Some(promoted);
13236        }
13237        alias = Some(AliasBinding {
13238            v: 1,
13239            origin: normalized_origin(&cfg.hub)?,
13240            requested: requested.to_string(),
13241            brain: resolved_brain.to_string(),
13242            home: legacy.home,
13243        });
13244        save_alias_in(cfg, directory, alias.as_ref().expect("alias just created"))?;
13245    }
13246    Ok((canonical, alias))
13247}
13248
13249/// Explicitly replace one mutable alias binding after the operator reviews the
13250/// exact deleted/re-created brain ids. Canonical checkpoints are never removed
13251/// or rewritten, so old history remains pinned and the new brain establishes
13252/// its own independently verified trust chain.
13253pub fn rebind_v2_alias(cfg: &HubConfig, alias: &str, from: &str, to: &str) -> LinkResult<Value> {
13254    require_hardened_filesystem("verified alias rebind")?;
13255    require_safe_ref(alias)?;
13256    require_safe_ref(from)?;
13257    require_safe_ref(to)?;
13258    if crate::ulid::is_ulid(alias)
13259        || !crate::ulid::is_ulid(from)
13260        || !crate::ulid::is_ulid(to)
13261        || from == to
13262    {
13263        return Err(LinkError::InvalidPack {
13264            message:
13265                "alias rebind requires one non-ULID alias and two different exact canonical ULIDs"
13266                    .to_string(),
13267        });
13268    }
13269
13270    let verified = v2_verified_head(cfg, to)?.ok_or_else(|| LinkError::InvalidPack {
13271        message: "the proposed replacement is not a readable link.md v2 brain".to_string(),
13272    })?;
13273    accept_v2_head(cfg, &verified)?;
13274
13275    let alias_response = ensure_ok(
13276        request(
13277            cfg,
13278            "GET",
13279            &format!("/api/hub/brains/{alias}/v2/head"),
13280            None,
13281            Auth::Required,
13282        )?,
13283        "resolve alias for explicit rebind",
13284    )?;
13285    let resolved: V2HeadResponse = serde_json::from_value(alias_response)
13286        .map_err(|_| invalid_feed("alias rebind response has an invalid shape"))?;
13287    if resolved.v != 2 || resolved.brain_id != to {
13288        return Err(LinkError::RemoteAdvancedDuringSync);
13289    }
13290
13291    let directory = open_trust_dir(cfg)?;
13292    let _locks = lock_trust_many(cfg, &directory, &[alias, from, to])?;
13293    let binding = load_alias_in(cfg, &directory, alias)?.ok_or_else(|| LinkError::InvalidPack {
13294        message: "the requested alias has no existing local binding to replace".to_string(),
13295    })?;
13296    if binding.brain != from {
13297        return Err(LinkError::AliasRebindRequired {
13298            alias: alias.to_string(),
13299            from: binding.brain,
13300            to: to.to_string(),
13301        });
13302    }
13303    save_alias_in(
13304        cfg,
13305        &directory,
13306        &AliasBinding {
13307            v: 1,
13308            origin: normalized_origin(&cfg.hub)?,
13309            requested: alias.to_string(),
13310            brain: to.to_string(),
13311            home: binding.home,
13312        },
13313    )?;
13314    Ok(json!({
13315        "v": 2,
13316        "alias": alias,
13317        "from": from,
13318        "to": to,
13319        "outcome": "alias_rebound",
13320    }))
13321}
13322
13323fn save_canonical_pin_and_alias(
13324    cfg: &HubConfig,
13325    directory: &TrustDirectory,
13326    requested: &str,
13327    resolved_brain: &str,
13328    mut state: TrustState,
13329    existing_alias: Option<&AliasBinding>,
13330) -> LinkResult<()> {
13331    state.requested = resolved_brain.to_string();
13332    state.brain = resolved_brain.to_string();
13333    state.home = None;
13334    save_trust_in(cfg, directory, &state)?;
13335    if requested != resolved_brain {
13336        save_alias_in(
13337            cfg,
13338            directory,
13339            &AliasBinding {
13340                v: 1,
13341                origin: normalized_origin(&cfg.hub)?,
13342                requested: requested.to_string(),
13343                brain: resolved_brain.to_string(),
13344                home: existing_alias.and_then(|alias| alias.home.clone()),
13345            },
13346        )?;
13347    }
13348    Ok(())
13349}
13350
13351fn verify_feed_item(item: &FeedItem, identity: &FeedIdentity) -> LinkResult<()> {
13352    const ED25519_SPKI_PREFIX: &[u8] = &[
13353        0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
13354    ];
13355    let entry = &item.entry;
13356    let public_der = URL_SAFE_NO_PAD
13357        .decode(&entry.public_key)
13358        .map_err(|_| invalid_feed("public key is not base64url"))?;
13359    if public_der.len() != ED25519_SPKI_PREFIX.len() + 32
13360        || !public_der.starts_with(ED25519_SPKI_PREFIX)
13361    {
13362        return Err(invalid_feed("entry public key is not a valid Ed25519 SPKI"));
13363    }
13364    let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&public_der));
13365    if entry.brain != format!("ed25519:{fingerprint}") {
13366        return Err(invalid_feed(
13367            "brain fingerprint does not match its public key",
13368        ));
13369    }
13370    // Verify the complete rotation authority before using any previous key.
13371    let _ = verify_identity_chain(identity, None)?;
13372    let mut chain: Vec<(&str, &str)> = identity
13373        .previous
13374        .iter()
13375        .rev()
13376        .map(|previous| {
13377            (
13378                previous.fingerprint.as_str(),
13379                previous.public_key_spki.as_str(),
13380            )
13381        })
13382        .collect();
13383    chain.push((&identity.fingerprint, &identity.public_key_spki));
13384    let signer_index = chain.iter().position(|(known_fingerprint, spki)| {
13385        *known_fingerprint == fingerprint && *spki == entry.public_key
13386    });
13387    let Some(signer_index) = signer_index else {
13388        return Err(invalid_feed(
13389            "entry signer is not this brain's identity (current or rotated-from)",
13390        ));
13391    };
13392    let lower_boundary = if signer_index == 0 {
13393        None
13394    } else {
13395        let prior: RotationStatement = serde_json::from_str(&identity.rotations[signer_index - 1])
13396            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13397        Some(prior.prior_head_seq)
13398    };
13399    let upper_boundary = if signer_index == identity.rotations.len() {
13400        None
13401    } else {
13402        let next: RotationStatement = serde_json::from_str(&identity.rotations[signer_index])
13403            .map_err(|_| invalid_feed("rotation statement did not parse"))?;
13404        Some(next.prior_head_seq)
13405    };
13406    if lower_boundary.is_some_and(|boundary| entry.seq <= boundary)
13407        || upper_boundary.is_some_and(|boundary| entry.seq > boundary)
13408    {
13409        return Err(invalid_feed(
13410            "entry signer is outside its authenticated rotation epoch",
13411        ));
13412    }
13413    let unsigned = UnsignedFeedEntry {
13414        v: entry.v,
13415        seq: entry.seq,
13416        ts: &entry.ts,
13417        brain: &entry.brain,
13418        public_key: &entry.public_key,
13419        kind: &entry.kind,
13420        op: &entry.op,
13421        pack_sha256: &entry.pack_sha256,
13422        files: &entry.files,
13423        removed: &entry.removed,
13424        prev_entry_hash: &entry.prev_entry_hash,
13425    };
13426    let message =
13427        serde_json::to_vec(&unsigned).map_err(|_| invalid_feed("could not canonicalize entry"))?;
13428    let signature = URL_SAFE_NO_PAD
13429        .decode(&entry.sig)
13430        .map_err(|_| invalid_feed("signature is not base64url"))?;
13431    UnparsedPublicKey::new(&ED25519, &public_der[ED25519_SPKI_PREFIX.len()..])
13432        .verify(&message, &signature)
13433        .map_err(|_| invalid_feed("Ed25519 signature verification failed"))?;
13434
13435    let mut exact = serde_json::to_vec(entry).map_err(|_| invalid_feed("could not hash entry"))?;
13436    exact.push(b'\n');
13437    let actual_hash = format!("{:x}", Sha256::digest(&exact));
13438    if actual_hash != item.hash {
13439        return Err(invalid_feed("entry SHA-256 does not match"));
13440    }
13441    Ok(())
13442}
13443
13444// ─────────────────────────────────────────────────────────────────────────────
13445// key rotation — link.md §9.1: the new key, signed by the old one
13446// ─────────────────────────────────────────────────────────────────────────────
13447
13448/// The unsigned rotation statement in its normative field order.
13449#[derive(Serialize)]
13450struct UnsignedRotation<'a> {
13451    v: u8,
13452    op: &'a str,
13453    brain: &'a str,
13454    public_key: &'a str,
13455    new_brain: &'a str,
13456    new_public_key: &'a str,
13457    prior_head_seq: u64,
13458    prior_feed_hash: Option<&'a str>,
13459    ts: String,
13460}
13461
13462/// Durable intent for an in-flight key rotation. The hub's recovery contract
13463/// identifies an ambiguous retry by the exact statement bytes, so the
13464/// statement cannot be reconstructed from the key and feed boundary later:
13465/// its timestamp and signature would differ.
13466#[derive(Debug, Deserialize, Serialize)]
13467#[serde(deny_unknown_fields)]
13468struct RotationJournal {
13469    v: u8,
13470    origin: String,
13471    brain: String,
13472    old_brain: String,
13473    new_brain: String,
13474    prior_head_seq: u64,
13475    prior_feed_hash: Option<String>,
13476    statement: String,
13477}
13478
13479fn rotation_journal_path(key_path: &Path) -> PathBuf {
13480    let mut path = key_path.as_os_str().to_os_string();
13481    path.push(".rotation.json");
13482    PathBuf::from(path)
13483}
13484
13485fn read_rotation_journal(path: &Path) -> LinkResult<RotationJournal> {
13486    #[cfg(unix)]
13487    let file = {
13488        use std::os::fd::{AsRawFd as _, FromRawFd as _};
13489        use std::os::unix::ffi::OsStrExt as _;
13490        let parent = open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13491            .map_err(|error| {
13492                bad_agent_key(&format!("cannot open the rotation journal parent: {error}"))
13493            })?;
13494        let leaf_name = path
13495            .file_name()
13496            .ok_or_else(|| bad_agent_key("the rotation journal path has no file name"))?;
13497        let leaf = c_name(leaf_name.as_bytes(), &path.display().to_string())?;
13498        let fd = unsafe {
13499            libc::openat(
13500                parent.as_raw_fd(),
13501                leaf.as_ptr(),
13502                libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
13503            )
13504        };
13505        if fd < 0 {
13506            return Err(bad_agent_key(
13507                "the rotation journal must be an existing regular file without symlink ancestors",
13508            ));
13509        }
13510        unsafe { std::fs::File::from_raw_fd(fd) }
13511    };
13512    #[cfg(not(unix))]
13513    let file = std::fs::File::open(path)
13514        .map_err(|error| bad_agent_key(&format!("cannot read the rotation journal: {error}")))?;
13515    let metadata = file
13516        .metadata()
13517        .map_err(|error| bad_agent_key(&format!("cannot inspect the rotation journal: {error}")))?;
13518    if !metadata.is_file() || metadata.len() > MAX_REGISTRY_CARD_BYTES {
13519        return Err(bad_agent_key(
13520            "the rotation journal must be a bounded regular file",
13521        ));
13522    }
13523    #[cfg(unix)]
13524    {
13525        use std::os::unix::fs::PermissionsExt as _;
13526        if metadata.permissions().mode() & 0o077 != 0 {
13527            return Err(bad_agent_key(
13528                "the rotation journal is accessible to group/other; set mode 0600",
13529            ));
13530        }
13531    }
13532    serde_json::from_reader(file)
13533        .map_err(|_| bad_agent_key("the rotation journal is not valid exact JSON"))
13534}
13535
13536fn remove_rotation_journal(path: &Path) {
13537    #[cfg(unix)]
13538    {
13539        use std::os::fd::AsRawFd as _;
13540        use std::os::unix::ffi::OsStrExt as _;
13541        let Ok(parent) =
13542            open_existing_dir_nofollow(path.parent().unwrap_or_else(|| Path::new(".")))
13543        else {
13544            return;
13545        };
13546        let Some(leaf_name) = path.file_name() else {
13547            return;
13548        };
13549        let Ok(leaf) = c_name(leaf_name.as_bytes(), &path.display().to_string()) else {
13550            return;
13551        };
13552        if unsafe { libc::unlinkat(parent.as_raw_fd(), leaf.as_ptr(), 0) } == 0 {
13553            let _ = parent.sync_all();
13554        }
13555    }
13556    #[cfg(not(unix))]
13557    {
13558        let _ = std::fs::remove_file(path);
13559    }
13560}
13561
13562fn validate_rotation_journal(
13563    journal: &RotationJournal,
13564    cfg: &HubConfig,
13565    canonical_brain: &str,
13566    old_key: &AgentSigningKey,
13567    new_key: &AgentSigningKey,
13568    head: &Head,
13569) -> LinkResult<()> {
13570    if journal.v != 1
13571        || journal.origin != normalized_origin(&cfg.hub)?
13572        || journal.brain != canonical_brain
13573        || journal.old_brain != old_key.multikey
13574        || journal.new_brain != new_key.multikey
13575        || journal.prior_head_seq != head.seq
13576        || journal.prior_feed_hash != head.feed_hash
13577    {
13578        return Err(invalid_feed(
13579            "rotation journal does not match the verified key and feed boundary",
13580        ));
13581    }
13582    let statement: RotationStatement = serde_json::from_str(&journal.statement)
13583        .map_err(|_| invalid_feed("rotation journal statement did not parse exactly"))?;
13584    if statement.prior_head_seq != journal.prior_head_seq
13585        || statement.prior_feed_hash != journal.prior_feed_hash
13586        || statement.brain != old_key.multikey
13587        || statement.public_key != old_key.public_key_spki
13588        || statement.new_brain != new_key.multikey
13589        || statement.new_public_key != new_key.public_key_spki
13590    {
13591        return Err(invalid_feed(
13592            "rotation journal statement does not match its durable intent",
13593        ));
13594    }
13595    let identity = FeedIdentity {
13596        fingerprint: new_key.multikey.trim_start_matches("ed25519:").to_string(),
13597        public_key_spki: new_key.public_key_spki.clone(),
13598        previous: vec![PreviousIdentity {
13599            fingerprint: old_key.multikey.trim_start_matches("ed25519:").to_string(),
13600            public_key_spki: old_key.public_key_spki.clone(),
13601        }],
13602        rotations: vec![journal.statement.clone()],
13603    };
13604    verify_identity_chain(&identity, None)?;
13605    Ok(())
13606}
13607
13608/// What `dbmd key rotate` returns.
13609#[derive(Debug, Serialize)]
13610pub struct RotationReport {
13611    /// The brain id rotated.
13612    pub brain: String,
13613    /// The NEW identity the hub now serves.
13614    pub multikey: String,
13615    /// Where the new PKCS#8 secret landed (0600).
13616    #[serde(rename = "keyFile")]
13617    pub key_file: String,
13618    /// Prior identities (newest first) the feed still verifies against.
13619    pub previous: Vec<String>,
13620}
13621
13622/// Rotate a self-custodied brain's key: mint a fresh keypair, build the
13623/// §9.1 statement — the new key plus exact prior feed boundary, signed by the
13624/// OLD key in normative serialization — and send it to the hub. The new secret
13625/// is durably created at 0600 before the POST; an existing output is reused for
13626/// idempotent retry/reconciliation. The old key is left untouched.
13627pub fn rotate_brain_key(
13628    cfg: &HubConfig,
13629    brain: &str,
13630    old_key: &AgentSigningKey,
13631    out: &Path,
13632) -> LinkResult<RotationReport> {
13633    require_hardened_filesystem("key rotation")?;
13634    require_safe_ref(brain)?;
13635    // The new private key must be durable *before* the hub can accept its
13636    // public half. An existing file is the retry/reconciliation path after an
13637    // ambiguous network failure: reuse it, never generate another identity.
13638    let new_key = if out.exists() {
13639        load_signing_key(out)?
13640    } else {
13641        let rng = ring::rand::SystemRandom::new();
13642        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng)
13643            .map_err(|_| bad_agent_key("key generation failed"))?;
13644        let pair = agent_keypair(pkcs8.as_ref())?;
13645        let (public_key_spki, multikey) = public_identity_for(&pair);
13646        write_secret_new(
13647            out,
13648            format!("{}\n", URL_SAFE_NO_PAD.encode(pkcs8.as_ref())).as_bytes(),
13649        )?;
13650        AgentSigningKey {
13651            pkcs8: pkcs8.as_ref().to_vec(),
13652            multikey,
13653            public_key_spki,
13654        }
13655    };
13656    let new_spki = new_key.public_key_spki.clone();
13657    let new_multikey = new_key.multikey.clone();
13658    let journal_path = rotation_journal_path(out);
13659    let before_v2 = v2_verified_head(cfg, brain)?;
13660    let (canonical_brain, served_identity, observed_head, v2_profile) =
13661        if let Some(head) = before_v2 {
13662            let observed = Head {
13663                brain: head.brain_id.clone(),
13664                seq: head.pointer.as_ref().map_or(0, |pointer| pointer.seq),
13665                updated_at: head
13666                    .pointer
13667                    .as_ref()
13668                    .map(|pointer| pointer.signed_at.clone()),
13669                feed_hash: head
13670                    .pointer
13671                    .as_ref()
13672                    .map(|pointer| pointer.feed_hash.clone()),
13673                verified: true,
13674            };
13675            let identity = v2_identity(&head.identity);
13676            let canonical = head.brain_id.clone();
13677            accept_v2_head(cfg, &head)?;
13678            (canonical, identity, observed, true)
13679        } else {
13680            let remote = verified_remote_head(cfg, brain, false)?;
13681            let identity = remote
13682                .identity
13683                .clone()
13684                .ok_or_else(|| invalid_feed("cannot rotate a brain with no signed identity"))?;
13685            (remote.head.brain.clone(), identity, remote.head, false)
13686        };
13687    let served_multikey = format!("ed25519:{}", served_identity.fingerprint);
13688    let already_rotated = served_multikey == new_multikey;
13689    // A completed invocation removes the journal only after the server has
13690    // acknowledged its recovery boundary. If the verified identity advanced
13691    // but the journal remains, the prior response was ambiguous: replay the
13692    // exact signed statement so a v2 hub can repair/confirm its checkpoint.
13693    if already_rotated && !journal_path.exists() {
13694        remove_rotation_journal(&journal_path);
13695        return Ok(RotationReport {
13696            brain: brain.to_string(),
13697            multikey: new_multikey,
13698            key_file: out.display().to_string(),
13699            previous: served_identity
13700                .previous
13701                .iter()
13702                .map(|identity| format!("ed25519:{}", identity.fingerprint))
13703                .collect(),
13704        });
13705    }
13706    if !already_rotated && served_multikey != old_key.multikey {
13707        return Err(invalid_feed(
13708            "the supplied old key is not the brain's verified current identity",
13709        ));
13710    }
13711
13712    let journal = if journal_path.exists() {
13713        read_rotation_journal(&journal_path)?
13714    } else {
13715        let ts = crate::now()
13716            .with_timezone(&chrono::Utc)
13717            .format("%Y-%m-%dT%H:%M:%S%.3fZ")
13718            .to_string();
13719        let unsigned = serde_json::to_string(&UnsignedRotation {
13720            v: 1,
13721            op: "rotate",
13722            brain: &old_key.multikey,
13723            public_key: &old_key.public_key_spki,
13724            new_brain: &new_multikey,
13725            new_public_key: &new_spki,
13726            prior_head_seq: observed_head.seq,
13727            prior_feed_hash: observed_head.feed_hash.as_deref(),
13728            ts,
13729        })
13730        .expect("serialize rotation");
13731        let old_pair = agent_keypair(&old_key.pkcs8)?;
13732        let sig = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
13733        let statement = format!("{},\"sig\":\"{}\"}}", &unsigned[..unsigned.len() - 1], sig);
13734        let journal = RotationJournal {
13735            v: 1,
13736            origin: normalized_origin(&cfg.hub)?,
13737            brain: canonical_brain.clone(),
13738            old_brain: old_key.multikey.clone(),
13739            new_brain: new_multikey.clone(),
13740            prior_head_seq: observed_head.seq,
13741            prior_feed_hash: observed_head.feed_hash.clone(),
13742            statement,
13743        };
13744        let mut exact = serde_json::to_vec(&journal)
13745            .map_err(|_| invalid_feed("could not serialize rotation journal"))?;
13746        exact.push(b'\n');
13747        if write_secret_new(&journal_path, &exact).is_err() {
13748            // A concurrent retry may have won the O_EXCL race. Only an exact,
13749            // fully validated journal is allowed to recover that race.
13750            read_rotation_journal(&journal_path)?
13751        } else {
13752            journal
13753        }
13754    };
13755    validate_rotation_journal(
13756        &journal,
13757        cfg,
13758        &canonical_brain,
13759        old_key,
13760        &new_key,
13761        &observed_head,
13762    )?;
13763
13764    let body = json!({ "statement": journal.statement });
13765    let path = format!("/api/hub/brains/{brain}/rotate");
13766    let attempted = request(cfg, "POST", &path, Some(&body), Auth::Required);
13767    let attempted_failure = match attempted {
13768        Ok(response) if (200..300).contains(&response.status) => None,
13769        Ok(response) => Some(ensure_ok(response, "key rotate").unwrap_err()),
13770        Err(error) => Some(error),
13771    };
13772
13773    // A 2xx body is not authority, and a failed response may have followed a
13774    // committed mutation. In both cases success means the normal verifier sees
13775    // an append-only rotation chain ending at the durable new key.
13776    let identity = if v2_profile {
13777        match v2_verified_head(cfg, brain) {
13778            Ok(Some(after)) => {
13779                let identity = v2_identity(&after.identity);
13780                accept_v2_head(cfg, &after)?;
13781                identity
13782            }
13783            Ok(None) => {
13784                return Err(attempted_failure.unwrap_or_else(|| {
13785                    invalid_feed("rotated v2 brain no longer serves a v2 head")
13786                }));
13787            }
13788            Err(error) => return Err(attempted_failure.unwrap_or(error)),
13789        }
13790    } else {
13791        match verified_remote_head(cfg, brain, false) {
13792            Ok(after) => after
13793                .identity
13794                .ok_or_else(|| invalid_feed("rotated brain has no verified identity"))?,
13795            Err(error) => return Err(attempted_failure.unwrap_or(error)),
13796        }
13797    };
13798    if format!("ed25519:{}", identity.fingerprint) != new_multikey
13799        || identity.public_key_spki != new_spki
13800    {
13801        return Err(attempted_failure.unwrap_or_else(|| {
13802            invalid_feed("hub acknowledged rotation without committing the verified new identity")
13803        }));
13804    }
13805    if v2_profile {
13806        if let Some(error) = attempted_failure {
13807            // Seeing the new public identity is insufficient: v2 rotation is
13808            // successful only after the endpoint has forced and acknowledged
13809            // the recovery checkpoint that contains it. Keep the journal for
13810            // an exact retry after any non-2xx or transport ambiguity.
13811            return Err(error);
13812        }
13813    }
13814    let previous = identity
13815        .previous
13816        .iter()
13817        .map(|prior| format!("ed25519:{}", prior.fingerprint))
13818        .collect();
13819    remove_rotation_journal(&journal_path);
13820
13821    Ok(RotationReport {
13822        brain: brain.to_string(),
13823        multikey: new_multikey,
13824        key_file: out.display().to_string(),
13825        previous,
13826    })
13827}
13828
13829// ─────────────────────────────────────────────────────────────────────────────
13830// mirror — verified replication: the whole feed + files, re-servable
13831// ─────────────────────────────────────────────────────────────────────────────
13832
13833/// What `dbmd mirror` materialized.
13834#[derive(Debug, Serialize)]
13835pub struct MirrorReport {
13836    /// The brain id.
13837    pub brain: String,
13838    /// The mirrored feed head.
13839    #[serde(rename = "headSeq")]
13840    pub head_seq: u64,
13841    /// The head entry hash (the feed's advertised converged state).
13842    #[serde(rename = "feedHash")]
13843    pub feed_hash: Option<String>,
13844    /// Signed feed entries verified and stored.
13845    pub entries: u64,
13846    /// The brain's multikey, pinned in `.dbmd/config` (TOFU).
13847    pub pinned: String,
13848    /// Store files materialized by the pull.
13849    pub files: usize,
13850}
13851
13852/// The mirror state directory, relative to the mirror root.
13853pub const MIRROR_REL_DIR: &str = ".dbmd/mirror";
13854
13855/// Fully re-verified mirror material suitable for a read-only re-server.
13856#[derive(Debug)]
13857pub struct VerifiedMirrorMaterial {
13858    pub brain: String,
13859    pub head_seq: u64,
13860    pub feed_hash: Option<String>,
13861    pub identity: serde_json::Value,
13862    /// Sequence, exact normative entry JSON without newline, and entry hash.
13863    pub entries: Vec<(u64, String, String)>,
13864    pub pack_sha256: Option<String>,
13865}
13866
13867#[derive(Deserialize)]
13868#[serde(deny_unknown_fields)]
13869struct StoredMirrorHead {
13870    brain: String,
13871    #[serde(rename = "headSeq")]
13872    head_seq: u64,
13873    #[serde(rename = "feedHash")]
13874    feed_hash: Option<String>,
13875}
13876
13877/// Re-verify mirror metadata, every signature/hash/rotation boundary, and the
13878/// exact snapshot pack before `dbmd serve` exposes any bytes.
13879pub fn verify_mirror_material(
13880    head_bytes: &[u8],
13881    identity_bytes: &[u8],
13882    feed_bytes: &[Vec<u8>],
13883    snapshot_pack: Option<&[u8]>,
13884    expected_anchor: &str,
13885) -> LinkResult<VerifiedMirrorMaterial> {
13886    let snapshot_hash = snapshot_pack
13887        .filter(|pack| !pack.is_empty())
13888        .map(content_sha256);
13889    verify_mirror_material_with_pack_hash(
13890        head_bytes,
13891        identity_bytes,
13892        feed_bytes,
13893        snapshot_hash.as_deref(),
13894        expected_anchor,
13895    )
13896}
13897
13898/// Re-verify mirror metadata against a snapshot digest computed from a held
13899/// no-follow file capability. This lets `dbmd serve` authenticate and retain a
13900/// large pack without ever buffering the pack in process memory.
13901pub fn verify_mirror_material_with_pack_hash(
13902    head_bytes: &[u8],
13903    identity_bytes: &[u8],
13904    feed_bytes: &[Vec<u8>],
13905    snapshot_pack_sha256: Option<&str>,
13906    expected_anchor: &str,
13907) -> LinkResult<VerifiedMirrorMaterial> {
13908    let head: StoredMirrorHead = serde_json::from_slice(head_bytes)
13909        .map_err(|_| invalid_feed("stored mirror head did not parse exactly"))?;
13910    require_safe_ref(&head.brain)?;
13911    if head.head_seq > MAX_FEED_REPLAY_ENTRIES || feed_bytes.len() as u64 != head.head_seq {
13912        return Err(invalid_feed(
13913            "stored mirror feed count does not match its bounded head sequence",
13914        ));
13915    }
13916    let aggregate = feed_bytes
13917        .iter()
13918        .try_fold(0u64, |total, bytes| total.checked_add(bytes.len() as u64))
13919        .ok_or_else(|| invalid_feed("stored mirror feed size overflow"))?;
13920    if aggregate > MAX_FEED_REPLAY_BYTES {
13921        return Err(invalid_feed(
13922            "stored mirror feed metadata exceeds the aggregate limit",
13923        ));
13924    }
13925    let identity: FeedIdentity = serde_json::from_slice(identity_bytes)
13926        .map_err(|_| invalid_feed("stored mirror identity did not parse exactly"))?;
13927    let anchor = verify_identity_chain(&identity, None)?;
13928    if anchor != expected_anchor {
13929        return Err(invalid_feed(
13930            "stored mirror identity does not descend from the explicitly trusted anchor",
13931        ));
13932    }
13933
13934    let mut entries = Vec::with_capacity(feed_bytes.len());
13935    let mut items = Vec::with_capacity(feed_bytes.len());
13936    let mut previous_hash = None;
13937    let mut pack_sha256 = None;
13938    for (index, bytes) in feed_bytes.iter().enumerate() {
13939        let exact = bytes
13940            .strip_suffix(b"\n")
13941            .ok_or_else(|| invalid_feed("stored feed entry lacks its exact trailing newline"))?;
13942        if exact.ends_with(b"\n") {
13943            return Err(invalid_feed("stored feed entry has extra trailing bytes"));
13944        }
13945        let entry: FeedEntry = serde_json::from_slice(exact)
13946            .map_err(|_| invalid_feed("stored feed entry did not parse exactly"))?;
13947        let expected_seq = index as u64 + 1;
13948        if entry.seq != expected_seq || entry.prev_entry_hash != previous_hash {
13949            return Err(invalid_feed(
13950                "stored mirror feed is not contiguous and hash-chained",
13951            ));
13952        }
13953        let canonical = serde_json::to_vec(&entry)
13954            .map_err(|_| invalid_feed("could not canonicalize stored feed entry"))?;
13955        if canonical != exact {
13956            return Err(invalid_feed(
13957                "stored feed entry is not in normative serialization",
13958            ));
13959        }
13960        let hash = content_sha256(bytes);
13961        let item = FeedItem {
13962            hash: hash.clone(),
13963            entry,
13964        };
13965        verify_feed_item(&item, &identity)?;
13966        previous_hash = Some(hash.clone());
13967        if expected_seq == head.head_seq {
13968            pack_sha256 = Some(item.entry.pack_sha256.clone());
13969        }
13970        entries.push((
13971            expected_seq,
13972            std::str::from_utf8(exact)
13973                .map_err(|_| invalid_feed("stored feed entry is not UTF-8"))?
13974                .to_string(),
13975            hash,
13976        ));
13977        items.push(item);
13978    }
13979    if previous_hash != head.feed_hash {
13980        return Err(invalid_feed(
13981            "stored mirror feed does not converge on its advertised head",
13982        ));
13983    }
13984    verify_rotation_feed_boundaries(&identity, None, &items, head.head_seq)?;
13985    match (head.head_seq, snapshot_pack_sha256, pack_sha256.as_deref()) {
13986        (0, None, None) => {}
13987        (_, Some(actual), Some(expected)) if actual == expected => {}
13988        _ => {
13989            return Err(LinkError::InvalidPack {
13990                message: "stored snapshot pack does not match the signed head digest".to_string(),
13991            });
13992        }
13993    }
13994    let identity_value = serde_json::to_value(&identity)
13995        .map_err(|_| invalid_feed("could not serialize verified mirror identity"))?;
13996    Ok(VerifiedMirrorMaterial {
13997        brain: head.brain,
13998        head_seq: head.head_seq,
13999        feed_hash: head.feed_hash,
14000        identity: identity_value,
14001        entries,
14002        pack_sha256,
14003    })
14004}
14005
14006/// SHA-256 hex of one feed entry's stored bytes (`exact JSON + "\n"`) — the
14007/// entry hash every consumer recomputes (SPEC §5.3).
14008pub fn feed_entry_hash(exact_sans_newline: &str) -> String {
14009    format!(
14010        "{:x}",
14011        Sha256::digest(format!("{exact_sans_newline}\n").as_bytes())
14012    )
14013}
14014
14015/// SHA-256 hex for content a signed manifest names. Exposed for the thin
14016/// `dbmd serve` adapter; cryptographic verification remains centralized here.
14017pub fn content_sha256(bytes: &[u8]) -> String {
14018    format!("{:x}", Sha256::digest(bytes))
14019}
14020
14021/// SHA-256 a stream with a fixed-size working buffer.
14022pub fn content_sha256_reader(mut reader: impl Read) -> std::io::Result<String> {
14023    let mut digest = Sha256::new();
14024    let mut buffer = [0u8; 64 * 1024];
14025    loop {
14026        let read = reader.read(&mut buffer)?;
14027        if read == 0 {
14028            break;
14029        }
14030        digest.update(&buffer[..read]);
14031    }
14032    Ok(format!("{:x}", digest.finalize()))
14033}
14034
14035/// Replicate a brain with full verification (link.md §5.4 over the WHOLE
14036/// chain, not just the head): every entry's signature, hash, sequence
14037/// contiguity, prev-hash linkage, rotation chain, and exact signed pack are
14038/// checked in a sibling staging directory. Only then is the old mirror swapped
14039/// out through an atomic directory exchange. Every stage, install, and cleanup
14040/// operation is relative to one held no-follow parent-directory capability, so
14041/// renaming an ancestor cannot redirect any write or deletion.
14042#[cfg_attr(windows, allow(unreachable_code, unused_variables))]
14043pub fn mirror(cfg: &HubConfig, brain: &str, dest: &Path) -> LinkResult<MirrorReport> {
14044    require_hardened_filesystem("mirror")?;
14045    require_safe_ref(brain)?;
14046    #[cfg(windows)]
14047    {
14048        let _ = (cfg, dest);
14049        return Err(LinkError::UnsupportedPlatform {
14050            operation: "atomic whole-mirror replacement on Windows",
14051        });
14052    }
14053    let parent = dest.parent().unwrap_or_else(|| Path::new("."));
14054    let name = dest
14055        .file_name()
14056        .and_then(|name| name.to_str())
14057        .filter(|name| !name.is_empty() && *name != "." && *name != "..")
14058        .ok_or_else(|| LinkError::UnsafePath {
14059            path: dest.display().to_string(),
14060        })?;
14061    #[cfg(unix)]
14062    let parent_dir = open_or_create_dir_nofollow(parent)?;
14063    #[cfg(unix)]
14064    use std::os::fd::AsRawFd as _;
14065    #[cfg(unix)]
14066    let dest_name = c_name(name.as_bytes(), &dest.display().to_string())?;
14067    #[cfg(unix)]
14068    let dest_exists = match entry_is_dir_at(parent_dir.as_raw_fd(), &dest_name)? {
14069        None => false,
14070        Some(true) => true,
14071        Some(false) => {
14072            return Err(LinkError::UnsafePath {
14073                path: dest.display().to_string(),
14074            });
14075        }
14076    };
14077
14078    // A fixed backup was used by pre-hardening builds. Never interpret or
14079    // delete an attacker-planted entry at that name; require manual recovery.
14080    #[cfg(unix)]
14081    let legacy_backup_name = c_name(
14082        format!(".{name}.dbmd-backup").as_bytes(),
14083        &dest.display().to_string(),
14084    )?;
14085    #[cfg(unix)]
14086    if entry_is_dir_at(parent_dir.as_raw_fd(), &legacy_backup_name)?.is_some() {
14087        return Err(LinkError::UnsafePath {
14088            path: parent
14089                .join(format!(".{name}.dbmd-backup"))
14090                .display()
14091                .to_string(),
14092        });
14093    }
14094
14095    let nonce = std::time::SystemTime::now()
14096        .duration_since(std::time::UNIX_EPOCH)
14097        .unwrap_or_default()
14098        .as_nanos();
14099    let stage_label = format!(".{name}.dbmd-stage-{}-{nonce}", std::process::id());
14100    #[cfg(unix)]
14101    let stage_name = c_name(stage_label.as_bytes(), &dest.display().to_string())?;
14102    #[cfg(unix)]
14103    let stage_dir = create_dir_exclusive_at(
14104        parent_dir.as_raw_fd(),
14105        &stage_name,
14106        &dest.display().to_string(),
14107    )?;
14108
14109    let assembled = (|| -> LinkResult<MirrorReport> {
14110        let remote = verified_remote_head(cfg, brain, true)?;
14111        let brain_id = remote.head.brain.clone();
14112        let identity = remote
14113            .identity
14114            .as_ref()
14115            .ok_or_else(|| invalid_feed("brain has no signed identity"))?;
14116        let anchor = remote
14117            .anchor
14118            .clone()
14119            .ok_or_else(|| invalid_feed("brain has no verified identity anchor"))?;
14120        let pack = download_verified_snapshot_pack(cfg, &brain_id, &remote)?;
14121        let snapshot_entries = parse_store_pack(pack.clone())?;
14122        let snapshot_count = snapshot_entries.len();
14123        let mut staged_entries = snapshot_entries;
14124        staged_entries.push((format!("{MIRROR_REL_DIR}/snapshot.pack"), pack));
14125        for item in &remote.entries {
14126            let mut exact = serde_json::to_vec(&item.entry)
14127                .map_err(|_| invalid_feed("could not serialize signed feed entry"))?;
14128            exact.push(b'\n');
14129            if format!("{:x}", Sha256::digest(&exact)) != item.hash {
14130                return Err(invalid_feed(
14131                    "serialized mirror entry differs from its verified hash",
14132                ));
14133            }
14134            staged_entries.push((
14135                format!("{MIRROR_REL_DIR}/feed/{}.json", item.entry.seq),
14136                exact,
14137            ));
14138        }
14139        let mut identity_bytes = serde_json::to_vec(identity)
14140            .map_err(|_| invalid_feed("could not serialize verified identity chain"))?;
14141        identity_bytes.push(b'\n');
14142        staged_entries.push((format!("{MIRROR_REL_DIR}/identity.json"), identity_bytes));
14143        let mut head_bytes = serde_json::to_vec(&json!({
14144            "brain": brain_id,
14145            "headSeq": remote.head.seq,
14146            "feedHash": remote.head.feed_hash,
14147        }))
14148        .map_err(|_| invalid_feed("could not serialize verified mirror head"))?;
14149        head_bytes.push(b'\n');
14150        staged_entries.push((format!("{MIRROR_REL_DIR}/head.json"), head_bytes));
14151        staged_entries.push((
14152            CONFIG_REL_PATH.to_string(),
14153            format!("hub = {}\npin = {anchor}\n", cfg.hub).into_bytes(),
14154        ));
14155        #[cfg(unix)]
14156        write_pull_entries_beneath_dir(&stage_dir, &staged_entries)?;
14157
14158        Ok(MirrorReport {
14159            brain: brain_id,
14160            head_seq: remote.head.seq,
14161            feed_hash: remote.head.feed_hash,
14162            entries: remote.entries.len() as u64,
14163            pinned: anchor,
14164            files: snapshot_count,
14165        })
14166    })();
14167
14168    let report = match assembled {
14169        Ok(report) => report,
14170        Err(error) => {
14171            #[cfg(unix)]
14172            let _ = remove_tree_at(
14173                parent_dir.as_raw_fd(),
14174                &stage_name,
14175                &dest.display().to_string(),
14176            );
14177            return Err(error);
14178        }
14179    };
14180
14181    #[cfg(unix)]
14182    if let Err(error) =
14183        install_stage_at(parent_dir.as_raw_fd(), &stage_name, &dest_name, dest_exists)
14184    {
14185        let _ = remove_tree_at(
14186            parent_dir.as_raw_fd(),
14187            &stage_name,
14188            &dest.display().to_string(),
14189        );
14190        return Err(error);
14191    }
14192    // An exchange leaves the old mirror at the unique stage name. Cleanup is
14193    // capability-relative and never follows symlinks in hostile old content.
14194    #[cfg(unix)]
14195    if dest_exists {
14196        remove_tree_at(
14197            parent_dir.as_raw_fd(),
14198            &stage_name,
14199            &dest.display().to_string(),
14200        )?;
14201    }
14202    #[cfg(unix)]
14203    parent_dir.sync_all()?;
14204    Ok(report)
14205}
14206
14207fn verified_remote_head(
14208    cfg: &HubConfig,
14209    brain: &str,
14210    require_full_chain: bool,
14211) -> LinkResult<VerifiedRemote> {
14212    require_hardened_filesystem("verified link.md state")?;
14213    require_safe_ref(brain)?;
14214    // Refuse an unsafe state root before sending credentials or consulting an
14215    // untrusted card, and retain this exact directory inode for the complete
14216    // checkpoint transaction.
14217    let trust_directory = open_trust_dir(cfg)?;
14218    let path = format!("/api/hub/brains/{brain}");
14219    let body = ensure_ok(
14220        request(cfg, "GET", &path, None, Auth::Required)?,
14221        "subscribe",
14222    )?;
14223    let resolved_brain = body
14224        .get("id")
14225        .and_then(Value::as_str)
14226        .filter(|id| crate::ulid::is_ulid(id))
14227        .ok_or_else(|| invalid_feed("brain card has no canonical ULID id"))?
14228        .to_string();
14229    if crate::ulid::is_ulid(brain) && resolved_brain != brain {
14230        return Err(invalid_feed(
14231            "brain card id differs from the explicitly requested brain id",
14232        ));
14233    }
14234    // The card supplies the canonical ULID. Lock alias + canonical keys in
14235    // deterministic filename order, then hold both through verify + save.
14236    // Concurrent aliases for the same brain therefore converge on one
14237    // checkpoint instead of establishing independent TOFU universes.
14238    let _trust_locks = lock_trust_many(cfg, &trust_directory, &[brain, &resolved_brain])?;
14239    let (pinned, alias_binding) =
14240        load_canonical_pin(cfg, &trust_directory, brain, &resolved_brain)?;
14241    let seq = body.get("headSeq").and_then(Value::as_u64).unwrap_or(0);
14242    let advertised_hash = body
14243        .get("feedHash")
14244        .and_then(Value::as_str)
14245        .map(str::to_string);
14246    let updated_at = body
14247        .get("updatedAt")
14248        .and_then(Value::as_str)
14249        .map(str::to_string);
14250    if let Some(pin) = &pinned {
14251        if seq < pin.head_seq {
14252            return Err(invalid_feed(format!(
14253                "feed rollback: hub advertised sequence {seq}, local checkpoint is {}",
14254                pin.head_seq
14255            )));
14256        }
14257        if seq == pin.head_seq && advertised_hash != pin.feed_hash {
14258            return Err(invalid_feed(
14259                "feed equivocation: the checkpoint sequence now has a different hash",
14260            ));
14261        }
14262    }
14263    if seq == 0 {
14264        if advertised_hash.is_some() {
14265            return Err(invalid_feed("an empty feed advertised a head hash"));
14266        }
14267        let identity: FeedIdentity = serde_json::from_value(
14268            body.get("identity")
14269                .cloned()
14270                .ok_or_else(|| invalid_feed("empty brain card has no signed identity"))?,
14271        )
14272        .map_err(|_| invalid_feed("empty brain card has an invalid identity"))?;
14273        let anchor = verify_identity_chain(&identity, pinned.as_ref())?;
14274        // A valid old-key-signed rotation is still inconsistent if it commits
14275        // to history that the same card now claims never existed. Run the
14276        // identical feed-boundary proof used by non-empty heads before this
14277        // identity can become a durable TOFU checkpoint.
14278        verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &[], seq)?;
14279        save_canonical_pin_and_alias(
14280            cfg,
14281            &trust_directory,
14282            brain,
14283            &resolved_brain,
14284            TrustState {
14285                v: 2,
14286                origin: normalized_origin(&cfg.hub)?,
14287                requested: resolved_brain.clone(),
14288                brain: resolved_brain.clone(),
14289                home: None,
14290                anchor: anchor.clone(),
14291                current: format!("ed25519:{}", identity.fingerprint),
14292                head_seq: 0,
14293                feed_hash: None,
14294                rotations: identity.rotations.clone(),
14295                hub_signer: None,
14296                protocol_profile: None,
14297            },
14298            alias_binding.as_ref(),
14299        )?;
14300        return Ok(VerifiedRemote {
14301            head: Head {
14302                brain: resolved_brain,
14303                seq,
14304                updated_at,
14305                feed_hash: None,
14306                verified: true,
14307            },
14308            identity: Some(identity),
14309            head_entry: None,
14310            entries: Vec::new(),
14311            anchor: Some(anchor),
14312        });
14313    }
14314    if advertised_hash.as_ref().is_none_or(|hash| !is_sha256(hash)) {
14315        return Err(invalid_feed(
14316            "non-empty feed did not advertise a valid SHA-256 head",
14317        ));
14318    }
14319
14320    // On first contact the signed head itself is the TOFU checkpoint. A full
14321    // history replay cannot add authority before an anchor exists; mirrors
14322    // still request the complete chain because they promise an archival copy.
14323    let replay_head_only = !require_full_chain
14324        && pinned
14325            .as_ref()
14326            .is_none_or(|checkpoint| checkpoint.head_seq == seq);
14327    let mut after = if replay_head_only {
14328        seq - 1
14329    } else if require_full_chain || pinned.is_none() {
14330        0
14331    } else {
14332        pinned.as_ref().map_or(0, |checkpoint| checkpoint.head_seq)
14333    };
14334    let mut expected_seq = after + 1;
14335    let mut previous_hash = if require_full_chain || pinned.is_none() || replay_head_only {
14336        None
14337    } else {
14338        pinned
14339            .as_ref()
14340            .and_then(|checkpoint| checkpoint.feed_hash.clone())
14341    };
14342    let mut identity: Option<FeedIdentity> = None;
14343    let mut anchor: Option<String> = None;
14344    let mut head_entry: Option<FeedItem> = None;
14345    let mut all_entries = Vec::new();
14346    let mut observed_entries = Vec::new();
14347    let replay_count = seq
14348        .checked_sub(after)
14349        .ok_or_else(|| invalid_feed("feed replay range moved backward"))?;
14350    if replay_count > MAX_FEED_REPLAY_ENTRIES {
14351        return Err(invalid_feed(format!(
14352            "feed replay requires {replay_count} entries, over the client cap"
14353        )));
14354    }
14355    let mut replay_bytes = 0u64;
14356
14357    loop {
14358        let feed_bytes = ensure_raw_ok(
14359            request_raw(
14360                cfg,
14361                "GET",
14362                &format!("/api/hub/brains/{brain}/feed?after={after}&limit=100"),
14363                None,
14364                Auth::Required,
14365                MAX_FEED_RESPONSE_BYTES,
14366            )?,
14367            "subscribe feed",
14368        )?;
14369        let feed: FeedResponse = serde_json::from_slice(&feed_bytes)
14370            .map_err(|_| invalid_feed("hub returned an invalid feed shape"))?;
14371        if feed.head_seq != seq || feed.feed_hash != advertised_hash {
14372            return Err(invalid_feed("brain card and feed head disagree"));
14373        }
14374        if feed.entries.len() > FEED_PAGE_LIMIT {
14375            return Err(invalid_feed("feed page exceeds the requested entry limit"));
14376        }
14377        if feed.scope_limited {
14378            if require_full_chain {
14379                return Err(invalid_feed(
14380                    "path-scoped grants cannot verify a full snapshot chain",
14381                ));
14382            }
14383            return Ok(VerifiedRemote {
14384                head: Head {
14385                    brain: resolved_brain,
14386                    seq,
14387                    updated_at,
14388                    feed_hash: advertised_hash,
14389                    verified: false,
14390                },
14391                identity: None,
14392                head_entry: None,
14393                entries: Vec::new(),
14394                anchor: None,
14395            });
14396        }
14397        let page_identity = feed
14398            .identity
14399            .ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14400        let page_anchor = verify_identity_chain(&page_identity, pinned.as_ref())?;
14401        if identity
14402            .as_ref()
14403            .is_some_and(|existing| existing != &page_identity)
14404        {
14405            return Err(invalid_feed("identity changed while reading the feed"));
14406        }
14407        if anchor
14408            .as_ref()
14409            .is_some_and(|existing| existing != &page_anchor)
14410        {
14411            return Err(invalid_feed(
14412                "identity anchor changed while reading the feed",
14413            ));
14414        }
14415        identity = Some(page_identity.clone());
14416        if anchor.is_none() {
14417            anchor = Some(page_anchor);
14418        }
14419        if feed.entries.is_empty() {
14420            return Err(invalid_feed("feed page was empty before the signed head"));
14421        }
14422
14423        for item in feed.entries {
14424            if item.entry.seq != expected_seq {
14425                return Err(invalid_feed(format!(
14426                    "expected entry {expected_seq}, feed served {}",
14427                    item.entry.seq
14428                )));
14429            }
14430            if item.entry.seq > seq {
14431                return Err(invalid_feed("feed advanced past the card snapshot"));
14432            }
14433            if !replay_head_only && item.entry.prev_entry_hash != previous_hash {
14434                return Err(invalid_feed(format!(
14435                    "entry {} does not chain to the local checkpoint",
14436                    item.entry.seq
14437                )));
14438            }
14439            verify_feed_item(&item, &page_identity)?;
14440            reject_retired_signer_after_checkpoint(&page_identity, pinned.as_ref(), &item)?;
14441            replay_bytes = replay_bytes.saturating_add(
14442                serde_json::to_vec(&item)
14443                    .map_err(|_| invalid_feed("could not size feed entry"))?
14444                    .len() as u64,
14445            );
14446            if replay_bytes > MAX_FEED_REPLAY_BYTES {
14447                return Err(invalid_feed("feed replay metadata exceeds the client cap"));
14448            }
14449            previous_hash = Some(item.hash.clone());
14450            after = item.entry.seq;
14451            expected_seq = expected_seq
14452                .checked_add(1)
14453                .ok_or_else(|| invalid_feed("feed sequence overflow"))?;
14454            if require_full_chain {
14455                all_entries.push(item.clone());
14456            }
14457            observed_entries.push(item.clone());
14458            head_entry = Some(item);
14459        }
14460        if after == seq {
14461            break;
14462        }
14463    }
14464
14465    if head_entry.as_ref().map(|item| &item.hash) != advertised_hash.as_ref() {
14466        return Err(invalid_feed(
14467            "verified chain does not converge on the advertised head",
14468        ));
14469    }
14470    let identity = identity.ok_or_else(|| invalid_feed("feed has no brain identity"))?;
14471    let anchor = anchor.ok_or_else(|| invalid_feed("feed has no identity anchor"))?;
14472    verify_rotation_feed_boundaries(&identity, pinned.as_ref(), &observed_entries, seq)?;
14473    save_canonical_pin_and_alias(
14474        cfg,
14475        &trust_directory,
14476        brain,
14477        &resolved_brain,
14478        TrustState {
14479            v: 2,
14480            origin: normalized_origin(&cfg.hub)?,
14481            requested: resolved_brain.clone(),
14482            brain: resolved_brain.clone(),
14483            home: None,
14484            anchor: anchor.clone(),
14485            current: format!("ed25519:{}", identity.fingerprint),
14486            head_seq: seq,
14487            feed_hash: advertised_hash.clone(),
14488            rotations: identity.rotations.clone(),
14489            hub_signer: pinned.as_ref().and_then(|state| state.hub_signer.clone()),
14490            protocol_profile: pinned
14491                .as_ref()
14492                .and_then(|state| state.protocol_profile.clone()),
14493        },
14494        alias_binding.as_ref(),
14495    )?;
14496    Ok(VerifiedRemote {
14497        head: Head {
14498            brain: resolved_brain,
14499            seq,
14500            updated_at,
14501            feed_hash: advertised_hash,
14502            verified: true,
14503        },
14504        identity: Some(identity),
14505        head_entry,
14506        entries: all_entries,
14507        anchor: Some(anchor),
14508    })
14509}
14510
14511/// Read and locally verify the brain's current signed feed head. Identity
14512/// rotation is accepted only through an old-key-signed chain rooted at the
14513/// local TOFU anchor; sequence and hash checkpoints reject rollback and
14514/// equivocation across invocations.
14515pub fn head(cfg: &HubConfig, brain: &str) -> LinkResult<Head> {
14516    if let Some(verified) = v2_verified_head(cfg, brain)? {
14517        let observation = Head {
14518            brain: verified.brain_id.clone(),
14519            seq: verified.pointer.as_ref().map_or(0, |pointer| pointer.seq),
14520            updated_at: verified
14521                .pointer
14522                .as_ref()
14523                .map(|pointer| pointer.signed_at.clone()),
14524            feed_hash: verified
14525                .pointer
14526                .as_ref()
14527                .map(|pointer| pointer.feed_hash.clone()),
14528            verified: true,
14529        };
14530        accept_v2_head(cfg, &verified)?;
14531        return Ok(observation);
14532    }
14533    Ok(verified_remote_head(cfg, brain, false)?.head)
14534}
14535
14536#[cfg(test)]
14537mod tests {
14538    use super::*;
14539
14540    const TEST_BRAIN_ID: &str = "01j5qc3v9k4ym8rwbn2tqe6f7d";
14541
14542    fn upload_declaration(index: usize, coordinate_len: usize) -> Value {
14543        json!({
14544            "sha256": "a".repeat(64),
14545            "bytes": 10,
14546            "coordinates": [format!("records/notes/{}-{}.md", "n".repeat(coordinate_len), index)],
14547        })
14548    }
14549
14550    #[test]
14551    fn upload_reservations_batch_by_count_and_by_size() {
14552        // A real store's first push declares one blob per changed file, which
14553        // does not fit in one request. Every batch has to stay inside both
14554        // bounds, and concatenating them has to reproduce the original order.
14555        let declarations: Vec<Value> = (0..5_000).map(|i| upload_declaration(i, 8)).collect();
14556        let batches = batch_upload_declarations(declarations.clone());
14557
14558        assert!(batches.len() > 1, "5,000 blobs must not ride one request");
14559        for batch in &batches {
14560            assert!(batch.len() <= MAX_UPLOAD_RESERVATION_BLOBS);
14561            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14562                .expect("batch serializes")
14563                .len();
14564            assert!(
14565                bytes <= MAX_UPLOAD_RESERVATION_BYTES + 1_024,
14566                "batch body {bytes} exceeds the reservation budget"
14567            );
14568        }
14569        let flattened: Vec<Value> = batches.into_iter().flatten().collect();
14570        assert_eq!(
14571            flattened, declarations,
14572            "batching must preserve the set and order"
14573        );
14574    }
14575
14576    #[test]
14577    fn only_load_shaped_hub_answers_are_worth_asking_again() {
14578        // A migration opens dozens of staging windows, so a busy hub's 429 or
14579        // 5xx must not end the push — while a refusal that states something
14580        // about the request itself has to surface immediately rather than
14581        // being repeated six times behind a minute of waiting.
14582        for status in [408, 429, 500, 502, 503, 504] {
14583            assert!(is_retryable_hub_status(status), "{status} is load-shaped");
14584        }
14585        for status in [400, 401, 403, 404, 409, 413, 422] {
14586            assert!(
14587                !is_retryable_hub_status(status),
14588                "{status} states something about the request"
14589            );
14590        }
14591        // The waits must reach past a whole limiter window to be worth taking.
14592        let total: u64 = RESERVATION_BACKOFF_MS.iter().sum();
14593        assert!(total >= 60_000, "backoff totals only {total}ms");
14594    }
14595
14596    #[test]
14597    fn a_batch_shares_a_connection_only_within_one_authority() {
14598        // Sharing a pooled agent is what makes a migration stage in minutes,
14599        // but it must never widen where bytes can go: a batch that spans two
14600        // authorities, or names an unsafe URL, falls back to per-object agents
14601        // rather than reusing a connection pinned to somewhere else.
14602        let cfg = HubConfig {
14603            hub: "https://www.sevrahq.com".to_string(),
14604            key: Some("k".to_string()),
14605            agent_key: None,
14606            brain_key: None,
14607            state_dir: PathBuf::from("."),
14608            store_selected: false,
14609        };
14610        assert!(shared_staging_agent(&cfg, &[]).is_none());
14611        assert!(
14612            shared_staging_agent(
14613                &cfg,
14614                &[
14615                    "https://one.example.com/a?sig=1",
14616                    "https://two.example.com/b?sig=2",
14617                ]
14618            )
14619            .is_none(),
14620            "two authorities must not share a pinned pool"
14621        );
14622        assert!(
14623            shared_staging_agent(&cfg, &["http://one.example.com/a"]).is_none(),
14624            "an unsafe object-store URL must not produce an agent"
14625        );
14626        assert!(
14627            shared_staging_agent(&cfg, &["https://user:pw@one.example.com/a"]).is_none(),
14628            "credentials in the URL must not produce an agent"
14629        );
14630    }
14631
14632    #[test]
14633    fn a_staged_change_states_only_operations_and_blobs() {
14634        // The hub refuses a manifest with any other key, so the two sides have
14635        // to agree exactly. A drifting extra field would fail a whole migration
14636        // at the commit, after every byte had already been staged.
14637        let operations = vec![json!({
14638            "op": "put",
14639            "path": "records/a.md",
14640            "blob": "a".repeat(64),
14641            "bytes": 3,
14642        })];
14643        let blobs = json!([{ "sha256": "a".repeat(64), "bytes": 3, "reservation_id": "01" }]);
14644        let bytes = v2_change_manifest(&operations, blobs.clone()).expect("manifest");
14645        let parsed: Value = serde_json::from_slice(&bytes).expect("manifest is JSON");
14646        let keys: Vec<&str> = parsed
14647            .as_object()
14648            .expect("manifest is an object")
14649            .keys()
14650            .map(String::as_str)
14651            .collect();
14652        assert_eq!(keys, ["blobs", "operations"]);
14653        assert_eq!(parsed["operations"], Value::Array(operations));
14654        assert_eq!(parsed["blobs"], blobs);
14655    }
14656
14657    #[test]
14658    fn a_staged_push_signs_the_change_not_the_transport() {
14659        // Self-custody signs a request hash the hub recomputes from the parsed
14660        // change. A staged body no longer states its operations, so the signed
14661        // view has to restore them or every large self-custodied push would
14662        // sign a different request than the one the hub validated.
14663        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14664        let staged = json!({
14665            "mutation_id": "dbmd-1",
14666            "rebase": "strict",
14667            "staged_change": { "sha256": "a".repeat(64), "bytes": 9, "reservation_id": "01" },
14668        });
14669        let view = v2_signed_request_view(&staged, &operations);
14670        assert_eq!(view["operations"], Value::Array(operations.clone()));
14671        assert!(view.get("staged_change").is_none());
14672        assert_eq!(view["mutation_id"], staged["mutation_id"]);
14673
14674        let inline = json!({ "mutation_id": "dbmd-1", "operations": operations });
14675        assert_eq!(v2_signed_request_view(&inline, &[]), inline);
14676    }
14677
14678    #[test]
14679    fn a_change_past_the_staging_ceiling_is_refused_before_transport() {
14680        let huge = vec![Value::String("a".repeat(MAX_STAGED_CHANGE_BYTES + 1))];
14681        let error = v2_change_manifest(&huge, Value::Array(Vec::new()))
14682            .expect_err("an oversized change must not be staged");
14683        assert!(
14684            matches!(error, LinkError::PushTooLarge { .. }),
14685            "expected a size refusal, got {error:?}"
14686        );
14687    }
14688
14689    #[test]
14690    fn a_push_that_fits_the_request_is_left_inline() {
14691        // The staged lane costs a reservation and a round trip, so it must
14692        // engage only when the body genuinely does not fit. The hub here is a
14693        // closed port: reaching for it at all would fail this test.
14694        let cfg = HubConfig {
14695            hub: "http://127.0.0.1:9".to_string(),
14696            key: Some("k".to_string()),
14697            agent_key: None,
14698            brain_key: None,
14699            state_dir: PathBuf::from("."),
14700            store_selected: false,
14701        };
14702        let operations = vec![json!({ "op": "delete", "path": "records/a.md" })];
14703        let mut body = json!({
14704            "mutation_id": "dbmd-1",
14705            "operations": operations,
14706            "blobs": [],
14707        });
14708        stage_oversized_v2_change(&cfg, "brain", &operations, &mut body).expect("no staging");
14709        assert!(body.get("staged_change").is_none());
14710        assert_eq!(body["operations"], Value::Array(operations));
14711    }
14712
14713    #[test]
14714    fn wide_coordinate_sets_shrink_batches_below_the_count_bound() {
14715        // Byte budget, not just count. One hash can carry many coordinates
14716        // (the same bytes reachable at several paths), so a batch sized only by
14717        // count would blow the body budget on a deduplicated store.
14718        let declarations: Vec<Value> = (0..2_000)
14719            .map(|index| {
14720                json!({
14721                    "sha256": "a".repeat(64),
14722                    "bytes": 10,
14723                    "coordinates": (0..24)
14724                        .map(|slot| format!(
14725                            "records/workflowy-nodes/2019/02/a-fairly-long-node-title-{index}-{slot}-abcd1234.md"
14726                        ))
14727                        .collect::<Vec<_>>(),
14728                })
14729            })
14730            .collect();
14731        let batches = batch_upload_declarations(declarations);
14732        assert!(
14733            batches[0].len() < MAX_UPLOAD_RESERVATION_BLOBS,
14734            "wide coordinate sets must bound the batch by size"
14735        );
14736        for batch in &batches {
14737            let bytes = serde_json::to_string(&json!({ "blobs": batch }))
14738                .expect("batch serializes")
14739                .len();
14740            assert!(bytes <= MAX_UPLOAD_RESERVATION_BYTES + 2_048);
14741        }
14742    }
14743
14744    #[test]
14745    fn a_small_push_still_rides_exactly_one_request() {
14746        let declarations: Vec<Value> = (0..3).map(|i| upload_declaration(i, 8)).collect();
14747        assert_eq!(batch_upload_declarations(declarations).len(), 1);
14748        assert!(batch_upload_declarations(Vec::new()).is_empty());
14749    }
14750
14751    #[test]
14752    fn exact_source_move_becomes_one_provenance_preserving_rename() {
14753        let hash = "a".repeat(64);
14754        let operations = vec![
14755            json!({
14756                "op": "put",
14757                "path": "sources/curated/item.md",
14758                "expected": { "kind": "absent" },
14759                "blob": hash,
14760                "bytes": 19,
14761            }),
14762            json!({
14763                "op": "delete",
14764                "path": "sources/inbox/item.md",
14765                "expected": { "kind": "blob", "hash": hash },
14766            }),
14767        ];
14768
14769        assert_eq!(
14770            infer_exact_source_promotions(operations),
14771            vec![json!({
14772                "op": "rename",
14773                "from": "sources/inbox/item.md",
14774                "to": "sources/curated/item.md",
14775                "expected_from": { "kind": "blob", "hash": hash },
14776                "expected_to": { "kind": "absent" },
14777                "blob": hash,
14778                "bytes": 19,
14779            })]
14780        );
14781    }
14782
14783    #[test]
14784    fn duplicate_source_bytes_never_guess_which_evidence_was_promoted() {
14785        let hash = "b".repeat(64);
14786        let operations = vec![
14787            json!({
14788                "op": "delete",
14789                "path": "sources/inbox/a.md",
14790                "expected": { "kind": "blob", "hash": hash },
14791            }),
14792            json!({
14793                "op": "delete",
14794                "path": "sources/inbox/b.md",
14795                "expected": { "kind": "blob", "hash": hash },
14796            }),
14797            json!({
14798                "op": "put",
14799                "path": "sources/curated/item.md",
14800                "expected": { "kind": "absent" },
14801                "blob": hash,
14802                "bytes": 19,
14803            }),
14804        ];
14805
14806        assert_eq!(
14807            infer_exact_source_promotions(operations.clone()),
14808            operations,
14809            "an ambiguous filesystem diff must reach the hub unchanged and fail closed"
14810        );
14811    }
14812
14813    #[test]
14814    fn accepted_source_promotion_advances_the_local_baseline_exactly() {
14815        let hash = "c".repeat(64);
14816        let mut candidate = std::collections::BTreeMap::from([(
14817            "sources/inbox/item.md".to_string(),
14818            V2BaselineFile {
14819                sha256: hash.clone(),
14820                bytes: 19,
14821                proof: None,
14822            },
14823        )]);
14824        let mut candidate_assets = std::collections::BTreeMap::new();
14825        let operations = vec![
14826            json!({
14827                "op": "rename",
14828                "from": "sources/inbox/item.md",
14829                "to": "sources/curated/item.md",
14830                "expected_from": { "kind": "blob", "hash": hash },
14831                "expected_to": { "kind": "absent" },
14832                "blob": hash,
14833                "bytes": 19,
14834            }),
14835            json!({
14836                "op": "put",
14837                "path": "records/rsvps/item.md",
14838                "expected": { "kind": "absent" },
14839                "blob": "d".repeat(64),
14840                "bytes": 23,
14841            }),
14842        ];
14843
14844        assert!(!apply_generated_v2_operations(
14845            &operations,
14846            &std::collections::BTreeMap::new(),
14847            &mut candidate,
14848            &mut candidate_assets,
14849        )
14850        .unwrap());
14851        assert!(!candidate.contains_key("sources/inbox/item.md"));
14852        assert_eq!(
14853            candidate
14854                .get("sources/curated/item.md")
14855                .map(|file| (&file.sha256, file.bytes)),
14856            Some((&hash, 19))
14857        );
14858        assert_eq!(
14859            candidate
14860                .get("records/rsvps/item.md")
14861                .map(|file| (file.sha256.as_str(), file.bytes)),
14862            Some((
14863                "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
14864                23
14865            ))
14866        );
14867    }
14868
14869    fn merge_fixture(
14870        base: Option<&str>,
14871        remote: Option<&str>,
14872        local: Option<&str>,
14873        keep_local: bool,
14874    ) -> V2PulledMerge<String> {
14875        let map = |value: Option<&str>| {
14876            value
14877                .map(|value| [("records/a.md".to_string(), value.to_string())])
14878                .into_iter()
14879                .flatten()
14880                .collect::<std::collections::BTreeMap<_, _>>()
14881        };
14882        merge_v2_pulled_records(
14883            &map(base),
14884            &map(remote),
14885            &map(local),
14886            |value, _| value.clone(),
14887            |value, _| value.clone(),
14888            |_| keep_local,
14889        )
14890    }
14891
14892    #[test]
14893    fn v2_pull_three_way_merge_never_discards_a_local_only_change() {
14894        let path = "records/a.md".to_string();
14895
14896        let local_add = merge_fixture(None, None, Some("local"), false);
14897        assert_eq!(
14898            local_add.records.get(&path).map(String::as_str),
14899            Some("local")
14900        );
14901        assert!(local_add.accept_remote.is_empty());
14902        assert!(local_add.conflicts.is_empty());
14903
14904        let local_edit = merge_fixture(Some("old"), Some("old"), Some("local"), false);
14905        assert_eq!(
14906            local_edit.records.get(&path).map(String::as_str),
14907            Some("local")
14908        );
14909        assert!(local_edit.accept_remote.is_empty());
14910        assert!(local_edit.conflicts.is_empty());
14911
14912        let local_delete = merge_fixture(Some("old"), Some("old"), None, false);
14913        assert!(!local_delete.records.contains_key(&path));
14914        assert!(local_delete.accept_remote.is_empty());
14915        assert!(local_delete.conflicts.is_empty());
14916
14917        let remote_edit = merge_fixture(Some("old"), Some("remote"), Some("old"), false);
14918        assert_eq!(
14919            remote_edit.records.get(&path).map(String::as_str),
14920            Some("remote")
14921        );
14922        assert!(remote_edit.accept_remote.contains(&path));
14923        assert!(remote_edit.conflicts.is_empty());
14924
14925        let remote_delete = merge_fixture(Some("old"), None, Some("old"), false);
14926        assert!(!remote_delete.records.contains_key(&path));
14927        assert!(remote_delete.accept_remote.contains(&path));
14928        assert!(remote_delete.conflicts.is_empty());
14929
14930        let same_edit = merge_fixture(Some("old"), Some("same"), Some("same"), false);
14931        assert_eq!(
14932            same_edit.records.get(&path).map(String::as_str),
14933            Some("same")
14934        );
14935        assert!(same_edit.accept_remote.contains(&path));
14936        assert!(same_edit.conflicts.is_empty());
14937
14938        let conflict = merge_fixture(Some("old"), Some("remote"), Some("local"), false);
14939        assert_eq!(conflict.conflicts, vec![path.clone()]);
14940        assert_eq!(
14941            conflict.records.get(&path).map(String::as_str),
14942            Some("local")
14943        );
14944        assert!(conflict.accept_remote.is_empty());
14945
14946        let kept_home = merge_fixture(Some("old"), Some("remote"), Some("local"), true);
14947        assert_eq!(
14948            kept_home.records.get(&path).map(String::as_str),
14949            Some("local")
14950        );
14951        assert!(kept_home.accept_remote.is_empty());
14952        assert!(kept_home.conflicts.is_empty());
14953    }
14954
14955    #[test]
14956    fn v2_asset_hosting_resume_is_an_explicit_local_policy_transition() {
14957        let path = "sources/report.pdf";
14958        let record = crate::AssetRecord {
14959            path: path.to_string(),
14960            sha256: "a".repeat(64),
14961            bytes: 42,
14962            media_type: "application/pdf".to_string(),
14963            wrappers: vec!["gzip".to_string()],
14964            required: true,
14965        };
14966        let mut remote = V2BaselineAsset {
14967            blob_sha256: record.sha256.clone(),
14968            bytes: record.bytes,
14969            media_type: record.media_type.clone(),
14970            wrappers: record.wrappers.clone(),
14971            required: record.required,
14972            disposition: "withheld".to_string(),
14973            leaf_hash: "b".repeat(64),
14974        };
14975
14976        assert!(v2_asset_resumes_hosting(
14977            Some(&remote),
14978            path,
14979            &record,
14980            "hosted"
14981        ));
14982        assert!(!v2_asset_resumes_hosting(
14983            Some(&remote),
14984            path,
14985            &record,
14986            "withheld"
14987        ));
14988
14989        remote.disposition = "hosted".to_string();
14990        assert!(!v2_asset_resumes_hosting(
14991            Some(&remote),
14992            path,
14993            &record,
14994            "hosted"
14995        ));
14996
14997        remote.disposition = "withheld".to_string();
14998        remote.blob_sha256 = "c".repeat(64);
14999        assert!(!v2_asset_resumes_hosting(
15000            Some(&remote),
15001            path,
15002            &record,
15003            "hosted"
15004        ));
15005        assert!(!v2_asset_resumes_hosting(None, path, &record, "hosted"));
15006    }
15007
15008    #[test]
15009    fn take_remote_resolution_rebinds_to_the_exact_current_manifest() {
15010        let path = "records/team/alpha.md".to_string();
15011        let deleted_path = "records/team/deleted.md".to_string();
15012        let coordinate = |sha256: Option<String>, bytes: Option<u64>| V2ConflictCoordinate {
15013            sha256,
15014            bytes,
15015            file: None,
15016        };
15017        let files = vec![
15018            V2ConflictFile {
15019                path: path.clone(),
15020                base: coordinate(None, None),
15021                local: coordinate(Some("b".repeat(64)), Some(7)),
15022                remote: coordinate(Some("a".repeat(64)), Some(5)),
15023            },
15024            V2ConflictFile {
15025                path: deleted_path.clone(),
15026                base: coordinate(Some("c".repeat(64)), Some(9)),
15027                local: coordinate(Some("d".repeat(64)), Some(11)),
15028                remote: coordinate(None, None),
15029            },
15030        ];
15031        let proven = V2BaselineFile {
15032            sha256: "a".repeat(64),
15033            bytes: 5,
15034            proof: None,
15035        };
15036        let current = [(path.clone(), proven.clone())]
15037            .into_iter()
15038            .collect::<std::collections::BTreeMap<_, _>>();
15039
15040        let (selected, deleted) = v2_take_remote_selection(&files, &current).unwrap();
15041        assert_eq!(selected.get(&path).unwrap().sha256, proven.sha256);
15042        assert_eq!(deleted, vec![deleted_path.clone()]);
15043
15044        let changed = [(
15045            path.clone(),
15046            V2BaselineFile {
15047                sha256: "e".repeat(64),
15048                bytes: 5,
15049                proof: None,
15050            },
15051        )]
15052        .into_iter()
15053        .collect::<std::collections::BTreeMap<_, _>>();
15054        assert!(v2_take_remote_selection(&files, &changed).is_err());
15055
15056        let resurrected = [
15057            (path, proven),
15058            (
15059                deleted_path,
15060                V2BaselineFile {
15061                    sha256: "f".repeat(64),
15062                    bytes: 13,
15063                    proof: None,
15064                },
15065            ),
15066        ]
15067        .into_iter()
15068        .collect::<std::collections::BTreeMap<_, _>>();
15069        assert!(v2_take_remote_selection(&files, &resurrected).is_err());
15070    }
15071
15072    #[cfg(target_os = "linux")]
15073    #[test]
15074    fn linux_snapshot_install_is_atomic_for_create_and_exchange() {
15075        use std::os::fd::AsRawFd as _;
15076
15077        let sandbox = tempfile::TempDir::new().unwrap();
15078        let parent = std::fs::File::open(sandbox.path()).unwrap();
15079        let stage = std::ffi::CString::new("stage").unwrap();
15080        let destination = std::ffi::CString::new("brain").unwrap();
15081
15082        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15083        std::fs::write(sandbox.path().join("stage/value"), b"created").unwrap();
15084        install_stage_at(
15085            parent.as_raw_fd(),
15086            stage.as_c_str(),
15087            destination.as_c_str(),
15088            false,
15089        )
15090        .unwrap();
15091        assert!(!sandbox.path().join("stage").exists());
15092        assert_eq!(
15093            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15094            b"created"
15095        );
15096
15097        std::fs::create_dir(sandbox.path().join("stage")).unwrap();
15098        std::fs::write(sandbox.path().join("stage/value"), b"replacement").unwrap();
15099        install_stage_at(
15100            parent.as_raw_fd(),
15101            stage.as_c_str(),
15102            destination.as_c_str(),
15103            true,
15104        )
15105        .unwrap();
15106        assert_eq!(
15107            std::fs::read(sandbox.path().join("brain/value")).unwrap(),
15108            b"replacement"
15109        );
15110        assert_eq!(
15111            std::fs::read(sandbox.path().join("stage/value")).unwrap(),
15112            b"created",
15113            "RENAME_EXCHANGE must leave the predecessor at the unique stage name"
15114        );
15115    }
15116
15117    struct SignedRemoteFixture {
15118        card: String,
15119        feed: String,
15120        key: AgentSigningKey,
15121        identity: FeedIdentity,
15122    }
15123
15124    fn signed_remote_fixture() -> SignedRemoteFixture {
15125        let rng = ring::rand::SystemRandom::new();
15126        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15127        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15128        let (public_key, multikey) = public_identity_for(&pair);
15129        let identity = FeedIdentity {
15130            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
15131            public_key_spki: public_key.clone(),
15132            previous: Vec::new(),
15133            rotations: Vec::new(),
15134        };
15135        let mut entry = FeedEntry {
15136            v: 1,
15137            seq: 1,
15138            ts: "2026-07-30T12:00:00.000Z".to_string(),
15139            brain: multikey.clone(),
15140            public_key: public_key.clone(),
15141            kind: "push".to_string(),
15142            op: "snapshot".to_string(),
15143            pack_sha256: "a".repeat(64),
15144            files: Vec::new(),
15145            removed: Vec::new(),
15146            prev_entry_hash: None,
15147            sig: String::new(),
15148        };
15149        let unsigned = UnsignedFeedEntry {
15150            v: entry.v,
15151            seq: entry.seq,
15152            ts: &entry.ts,
15153            brain: &entry.brain,
15154            public_key: &entry.public_key,
15155            kind: &entry.kind,
15156            op: &entry.op,
15157            pack_sha256: &entry.pack_sha256,
15158            files: &entry.files,
15159            removed: &entry.removed,
15160            prev_entry_hash: &entry.prev_entry_hash,
15161        };
15162        entry.sig =
15163            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
15164        let mut exact = serde_json::to_vec(&entry).unwrap();
15165        exact.push(b'\n');
15166        let hash = content_sha256(&exact);
15167        let card = json!({
15168            "id": TEST_BRAIN_ID,
15169            "headSeq": 1,
15170            "feedHash": hash,
15171            "identity": identity.clone(),
15172        })
15173        .to_string();
15174        let feed = json!({
15175            "headSeq": 1,
15176            "feedHash": hash,
15177            "identity": identity.clone(),
15178            "entries": [{"hash": hash, "entry": entry}],
15179            "scopeLimited": false,
15180        })
15181        .to_string();
15182        SignedRemoteFixture {
15183            card,
15184            feed,
15185            key: AgentSigningKey {
15186                pkcs8: pkcs8.as_ref().to_vec(),
15187                multikey,
15188                public_key_spki: public_key,
15189            },
15190            identity,
15191        }
15192    }
15193
15194    #[test]
15195    fn v1_edit_disclosure_accepts_minimal_and_superset_forms() {
15196        let file = |path: &str, byte: char| FeedFile {
15197            path: path.to_string(),
15198            sha256: byte.to_string().repeat(64),
15199            bytes: 1,
15200        };
15201        let a0 = file("records/a.md", 'a');
15202        let a1 = file("records/a.md", 'b');
15203        let stable = file("records/stable.md", 'c');
15204        let added = file("records/added.md", 'd');
15205        let removed_file = file("records/removed.md", 'e');
15206        let previous = vec![a0, stable.clone(), removed_file.clone()];
15207        let resulting = vec![a1.clone(), stable.clone(), added.clone()];
15208        let removed = vec![removed_file.path.clone()];
15209
15210        assert_eq!(
15211            verify_v1_manifest_disclosure(
15212                "edit",
15213                &previous,
15214                &resulting,
15215                &[a1.clone(), added.clone()],
15216                &removed,
15217            ),
15218            Ok(())
15219        );
15220        assert_eq!(
15221            verify_v1_manifest_disclosure(
15222                "edit",
15223                &previous,
15224                &resulting,
15225                &[stable.clone(), added.clone(), a1.clone()],
15226                &removed,
15227            ),
15228            Ok(())
15229        );
15230        assert_eq!(
15231            verify_v1_manifest_disclosure(
15232                "edit",
15233                &previous,
15234                &resulting,
15235                std::slice::from_ref(&added),
15236                &removed,
15237            ),
15238            Err(V1DisclosureError::EditMissingChange)
15239        );
15240        assert_eq!(
15241            verify_v1_manifest_disclosure(
15242                "edit",
15243                &previous,
15244                &resulting,
15245                &[file("records/a.md", 'f'), added.clone()],
15246                &removed,
15247            ),
15248            Err(V1DisclosureError::EditFalseFile)
15249        );
15250        assert_eq!(
15251            verify_v1_manifest_disclosure(
15252                "edit",
15253                &previous,
15254                &resulting,
15255                &[a1.clone(), added.clone()],
15256                &[],
15257            ),
15258            Err(V1DisclosureError::RemovedMismatch)
15259        );
15260        assert_eq!(
15261            verify_v1_manifest_disclosure(
15262                "push",
15263                &previous,
15264                &resulting,
15265                &[added.clone(), stable, a1],
15266                &removed,
15267            ),
15268            Ok(())
15269        );
15270        assert_eq!(
15271            verify_v1_manifest_disclosure("push", &previous, &resulting, &[added], &removed,),
15272            Err(V1DisclosureError::PushManifestMismatch)
15273        );
15274    }
15275
15276    #[test]
15277    fn wire_sequences_are_rejected_at_their_endpoint_count_limits() {
15278        let fixture = signed_remote_fixture();
15279        let feed: Value = serde_json::from_str(&fixture.feed).unwrap();
15280        let item = feed["entries"][0].to_string();
15281        let oversized_page = format!(
15282            "{{\"headSeq\":1,\"feedHash\":null,\"identity\":null,\"entries\":[{}],\"scopeLimited\":false}}",
15283            std::iter::repeat_n(item.as_str(), FEED_PAGE_LIMIT + 1)
15284                .collect::<Vec<_>>()
15285                .join(",")
15286        );
15287        assert!(serde_json::from_str::<FeedResponse>(&oversized_page).is_err());
15288
15289        let oversized_identity = format!(
15290            "{{\"fingerprint\":\"fp\",\"publicKeySpki\":\"spki\",\"previous\":[],\"rotations\":[{}]}}",
15291            std::iter::repeat_n("\"rotation\"", MAX_IDENTITY_ROTATIONS + 1)
15292                .collect::<Vec<_>>()
15293                .join(",")
15294        );
15295        assert!(serde_json::from_str::<FeedIdentity>(&oversized_identity).is_err());
15296
15297        let file = r#"{"path":"a","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","bytes":1}"#;
15298        let oversized_entry = format!(
15299            "{{\"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\"}}",
15300            "a".repeat(64),
15301            std::iter::repeat_n(file, MAX_PUSH_FILES + 1)
15302                .collect::<Vec<_>>()
15303                .join(",")
15304        );
15305        assert!(serde_json::from_str::<FeedEntry>(&oversized_entry).is_err());
15306    }
15307
15308    #[test]
15309    fn bulk_confirmation_parser_accepts_only_the_exact_wire_shape() {
15310        let id = "01arz3ndektsv4rrffq69g5fav";
15311        let digest = "a".repeat(64);
15312        assert_eq!(
15313            V2BulkConfirmation::parse(&format!("{id}:{digest}")).unwrap(),
15314            V2BulkConfirmation {
15315                id: id.to_string(),
15316                digest,
15317            }
15318        );
15319        for invalid in [
15320            "",
15321            "01arz3ndektsv4rrffq69g5fav",
15322            "01ARZ3NDEKTSV4RRFFQ69G5FAV:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15323            "01arz3ndektsv4rrffq69g5fav:ABCDEF",
15324            "01arz3ndektsv4rrffq69g5fav:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
15325        ] {
15326            assert!(matches!(
15327                V2BulkConfirmation::parse(invalid),
15328                Err(LinkError::InvalidPack { .. })
15329            ));
15330        }
15331    }
15332
15333    fn scripted_json_hub(responses: Vec<(u16, String)>) -> (String, std::thread::JoinHandle<()>) {
15334        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15335        use std::net::TcpListener;
15336
15337        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15338        let url = format!("http://{}", listener.local_addr().unwrap());
15339        let handle = std::thread::spawn(move || {
15340            for (status, body) in responses {
15341                let (stream, _) = listener.accept().unwrap();
15342                let mut reader = BufReader::new(stream);
15343                let mut line = String::new();
15344                reader.read_line(&mut line).unwrap();
15345                let mut content_length = 0usize;
15346                loop {
15347                    line.clear();
15348                    reader.read_line(&mut line).unwrap();
15349                    if line == "\r\n" || line == "\n" || line.is_empty() {
15350                        break;
15351                    }
15352                    if let Some((name, value)) = line.split_once(':') {
15353                        if name.eq_ignore_ascii_case("content-length") {
15354                            content_length = value.trim().parse().unwrap();
15355                        }
15356                    }
15357                }
15358                let mut request_body = vec![0_u8; content_length];
15359                reader.read_exact(&mut request_body).unwrap();
15360                let response = format!(
15361                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15362                    body.len()
15363                );
15364                reader.get_mut().write_all(response.as_bytes()).unwrap();
15365            }
15366        });
15367        (url, handle)
15368    }
15369
15370    fn routed_json_hub(
15371        requests: usize,
15372        mut respond: impl FnMut(&str) -> (u16, String) + Send + 'static,
15373    ) -> (String, std::thread::JoinHandle<()>) {
15374        use std::io::{BufRead as _, BufReader, Read as _, Write as _};
15375        use std::net::TcpListener;
15376
15377        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
15378        let url = format!("http://{}", listener.local_addr().unwrap());
15379        let handle = std::thread::spawn(move || {
15380            for _ in 0..requests {
15381                let (stream, _) = listener.accept().unwrap();
15382                let mut reader = BufReader::new(stream);
15383                let mut line = String::new();
15384                reader.read_line(&mut line).unwrap();
15385                let path = line.split_whitespace().nth(1).unwrap_or("").to_string();
15386                let mut content_length = 0usize;
15387                loop {
15388                    line.clear();
15389                    reader.read_line(&mut line).unwrap();
15390                    if line == "\r\n" || line == "\n" || line.is_empty() {
15391                        break;
15392                    }
15393                    if let Some((name, value)) = line.split_once(':') {
15394                        if name.eq_ignore_ascii_case("content-length") {
15395                            content_length = value.trim().parse().unwrap();
15396                        }
15397                    }
15398                }
15399                let mut request_body = vec![0_u8; content_length];
15400                reader.read_exact(&mut request_body).unwrap();
15401                let (status, body) = respond(&path);
15402                let response = format!(
15403                    "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
15404                    body.len()
15405                );
15406                reader.get_mut().write_all(response.as_bytes()).unwrap();
15407            }
15408        });
15409        (url, handle)
15410    }
15411
15412    fn test_hub_config(hub: String, state_dir: PathBuf) -> HubConfig {
15413        HubConfig {
15414            hub,
15415            key: Some("test-key".to_string()),
15416            agent_key: None,
15417            brain_key: None,
15418            state_dir,
15419            store_selected: false,
15420        }
15421    }
15422
15423    #[test]
15424    fn linkmd_sig_v2_is_bound_to_the_exact_hub_origin() {
15425        use ring::signature::KeyPair as _;
15426
15427        let rng = ring::rand::SystemRandom::new();
15428        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
15429        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
15430        let (spki, multikey) = public_identity_for(&pair);
15431        let key = AgentSigningKey {
15432            pkcs8: pkcs8.as_ref().to_vec(),
15433            multikey,
15434            public_key_spki: spki,
15435        };
15436        let header = linkmd_sig_header(
15437            &key,
15438            "https://hub-a.example",
15439            "post",
15440            "/api/hub/brains/brain/push?mode=exact",
15441            Some("{\"ok\":true}"),
15442        )
15443        .unwrap();
15444        assert!(header.starts_with("LinkMD-Sig v2,"));
15445        let ts = header
15446            .split(",ts=")
15447            .nth(1)
15448            .unwrap()
15449            .split(',')
15450            .next()
15451            .unwrap();
15452        let signature = URL_SAFE_NO_PAD
15453            .decode(header.rsplit(",sig=").next().unwrap())
15454            .unwrap();
15455        let body_hash = format!("{:x}", Sha256::digest(b"{\"ok\":true}"));
15456        let accepted = format!(
15457            "v2\nhttps://hub-a.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15458        );
15459        let replayed = format!(
15460            "v2\nhttps://hub-b.example\nPOST\n/api/hub/brains/brain/push?mode=exact\n{ts}\n{body_hash}"
15461        );
15462        let public = pair.public_key().as_ref();
15463        assert!(UnparsedPublicKey::new(&ED25519, public)
15464            .verify(accepted.as_bytes(), &signature)
15465            .is_ok());
15466        assert!(
15467            UnparsedPublicKey::new(&ED25519, public)
15468                .verify(replayed.as_bytes(), &signature)
15469                .is_err(),
15470            "a proof captured at hub A must not authenticate at hub B"
15471        );
15472    }
15473
15474    #[test]
15475    fn explicit_brain_id_rejects_a_substituted_card_before_feed_trust() {
15476        let other = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15477        let card = json!({
15478            "id": other,
15479            "headSeq": 0,
15480            "identity": signed_remote_fixture().identity,
15481        })
15482        .to_string();
15483        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15484        let state = tempfile::tempdir().unwrap();
15485        let cfg = test_hub_config(hub, state.path().to_path_buf());
15486        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15487        assert!(
15488            error.contains("differs from the explicitly requested"),
15489            "{error}"
15490        );
15491        server.join().unwrap();
15492    }
15493
15494    #[test]
15495    fn empty_brain_identity_is_pinned_and_cannot_be_substituted_later() {
15496        let first = signed_remote_fixture().identity;
15497        let second = signed_remote_fixture().identity;
15498        let card = |identity: FeedIdentity| {
15499            json!({
15500                "id": TEST_BRAIN_ID,
15501                "headSeq": 0,
15502                "identity": identity,
15503            })
15504            .to_string()
15505        };
15506        let (hub, server) = scripted_json_hub(vec![
15507            (404, "{}".to_string()),
15508            (200, card(first)),
15509            (404, "{}".to_string()),
15510            (200, card(second)),
15511        ]);
15512        let state = tempfile::tempdir().unwrap();
15513        let cfg = test_hub_config(hub, state.path().to_path_buf());
15514        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15515        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15516        assert!(
15517            error.contains("pinned anchor") || error.contains("forked away"),
15518            "{error}"
15519        );
15520        server.join().unwrap();
15521    }
15522
15523    #[test]
15524    fn empty_head_rejects_a_rotation_that_commits_to_hidden_history() {
15525        let old = signed_remote_fixture();
15526        let new = signed_remote_fixture();
15527        let old_pair = ring::signature::Ed25519KeyPair::from_pkcs8(&old.key.pkcs8).unwrap();
15528        let unsigned = serde_json::to_string(&UnsignedRotation {
15529            v: 1,
15530            op: "rotate",
15531            brain: &old.key.multikey,
15532            public_key: &old.key.public_key_spki,
15533            new_brain: &new.key.multikey,
15534            new_public_key: &new.key.public_key_spki,
15535            prior_head_seq: 1,
15536            prior_feed_hash: Some(&"a".repeat(64)),
15537            ts: "2026-07-30T12:00:00.000Z".to_string(),
15538        })
15539        .unwrap();
15540        let signature = URL_SAFE_NO_PAD.encode(old_pair.sign(unsigned.as_bytes()).as_ref());
15541        let rotation = format!(
15542            "{},\"sig\":\"{}\"}}",
15543            &unsigned[..unsigned.len() - 1],
15544            signature
15545        );
15546        let identity = FeedIdentity {
15547            fingerprint: new.key.multikey.trim_start_matches("ed25519:").to_string(),
15548            public_key_spki: new.key.public_key_spki,
15549            previous: vec![PreviousIdentity {
15550                fingerprint: old.key.multikey.trim_start_matches("ed25519:").to_string(),
15551                public_key_spki: old.key.public_key_spki,
15552            }],
15553            rotations: vec![rotation],
15554        };
15555        let card = json!({
15556            "id": TEST_BRAIN_ID,
15557            "headSeq": 0,
15558            "feedHash": null,
15559            "identity": identity,
15560        })
15561        .to_string();
15562        let (hub, server) = scripted_json_hub(vec![(404, "{}".to_string()), (200, card)]);
15563        let state = tempfile::tempdir().unwrap();
15564        let cfg = test_hub_config(hub, state.path().to_path_buf());
15565        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15566        assert!(
15567            error.contains("rotation claims a feed boundary beyond the advertised head"),
15568            "{error}"
15569        );
15570        assert!(
15571            load_trust(&cfg, TEST_BRAIN_ID).unwrap().is_none(),
15572            "an inconsistent empty-head identity must not become the TOFU checkpoint"
15573        );
15574        server.join().unwrap();
15575    }
15576
15577    #[test]
15578    fn trust_checkpoint_rejects_a_later_fork() {
15579        let fixture = signed_remote_fixture();
15580        let mut fork: Value = serde_json::from_str(&fixture.card).unwrap();
15581        fork["feedHash"] = Value::String("b".repeat(64));
15582        let (hub, server) = scripted_json_hub(vec![
15583            (404, "{}".to_string()),
15584            (200, fixture.card),
15585            (200, fixture.feed),
15586            (404, "{}".to_string()),
15587            (200, fork.to_string()),
15588        ]);
15589        let state = tempfile::tempdir().unwrap();
15590        let cfg = test_hub_config(hub, state.path().to_path_buf());
15591        assert!(head(&cfg, TEST_BRAIN_ID).unwrap().verified);
15592        assert!(head(&cfg, TEST_BRAIN_ID).is_err());
15593        server.join().unwrap();
15594    }
15595
15596    #[test]
15597    fn alias_and_canonical_id_share_one_identity_checkpoint() {
15598        let trusted = signed_remote_fixture();
15599        let attacker = signed_remote_fixture();
15600        let (hub, server) = scripted_json_hub(vec![
15601            (404, "{}".to_string()),
15602            (200, trusted.card),
15603            (200, trusted.feed),
15604            (404, "{}".to_string()),
15605            (200, attacker.card),
15606        ]);
15607        let state = tempfile::tempdir().unwrap();
15608        let cfg = test_hub_config(hub, state.path().to_path_buf());
15609        assert!(head(&cfg, "trusted-slug").unwrap().verified);
15610        let error = head(&cfg, TEST_BRAIN_ID).unwrap_err().to_string();
15611        assert!(
15612            error.contains("equivocation")
15613                || error.contains("pinned")
15614                || error.contains("identity"),
15615            "{error}"
15616        );
15617        server.join().unwrap();
15618    }
15619
15620    #[test]
15621    fn moved_alias_requires_exact_explicit_rebind_without_rewriting_history() {
15622        let state = tempfile::tempdir().unwrap();
15623        let cfg = test_hub_config(
15624            "https://hub.example".to_string(),
15625            state.path().to_path_buf(),
15626        );
15627        let directory = open_trust_dir(&cfg).unwrap();
15628        let old = TEST_BRAIN_ID;
15629        let new = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15630        save_alias_in(
15631            &cfg,
15632            &directory,
15633            &AliasBinding {
15634                v: 1,
15635                origin: normalized_origin(&cfg.hub).unwrap(),
15636                requested: "company-brain".to_string(),
15637                brain: old.to_string(),
15638                home: Some("company-brain".to_string()),
15639            },
15640        )
15641        .unwrap();
15642
15643        let error = load_canonical_pin(&cfg, &directory, "company-brain", new).unwrap_err();
15644        assert!(matches!(
15645            error,
15646            LinkError::AliasRebindRequired {
15647                alias,
15648                from,
15649                to
15650            } if alias == "company-brain" && from == old && to == new
15651        ));
15652        let unchanged = load_alias_in(&cfg, &directory, "company-brain")
15653            .unwrap()
15654            .unwrap();
15655        assert_eq!(unchanged.brain, old);
15656        assert_eq!(unchanged.home.as_deref(), Some("company-brain"));
15657    }
15658
15659    #[test]
15660    fn concurrent_aliases_cannot_establish_conflicting_tofu_pins() {
15661        let alpha = signed_remote_fixture();
15662        let beta = signed_remote_fixture();
15663        let alpha_card = alpha.card.clone();
15664        let alpha_feed = alpha.feed.clone();
15665        let beta_card = beta.card.clone();
15666        let beta_feed = beta.feed.clone();
15667        let (hub, server) = routed_json_hub(5, move |path| {
15668            if path.ends_with("/v2/head") {
15669                (404, "{}".to_string())
15670            } else if path.contains("/alpha/feed?") {
15671                (200, alpha_feed.clone())
15672            } else if path.contains("/beta/feed?") {
15673                (200, beta_feed.clone())
15674            } else if path.ends_with("/alpha") {
15675                (200, alpha_card.clone())
15676            } else if path.ends_with("/beta") {
15677                (200, beta_card.clone())
15678            } else {
15679                (500, r#"{"error":"unexpected path"}"#.to_string())
15680            }
15681        });
15682        let state = tempfile::tempdir().unwrap();
15683        let cfg = test_hub_config(hub, state.path().to_path_buf());
15684        let alpha_cfg = cfg.clone();
15685        let beta_cfg = cfg;
15686        let first = std::thread::spawn(move || head(&alpha_cfg, "alpha"));
15687        let second = std::thread::spawn(move || head(&beta_cfg, "beta"));
15688        let results = [first.join().unwrap(), second.join().unwrap()];
15689        assert_eq!(
15690            results.iter().filter(|result| result.is_ok()).count(),
15691            1,
15692            "only one alias identity may establish canonical TOFU: {results:?}"
15693        );
15694        assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1);
15695        server.join().unwrap();
15696    }
15697
15698    #[cfg(unix)]
15699    #[test]
15700    fn trust_transaction_survives_an_ancestor_swap_without_writing_outside() {
15701        use std::os::unix::fs::symlink;
15702
15703        let fixture = signed_remote_fixture();
15704        let card = json!({
15705            "id": TEST_BRAIN_ID,
15706            "headSeq": 0,
15707            "feedHash": Value::Null,
15708            "identity": fixture.identity,
15709        })
15710        .to_string();
15711        let work = tempfile::tempdir().unwrap();
15712        let outside = tempfile::tempdir().unwrap();
15713        let state = work.path().join("state");
15714        let moved = work.path().join("state-held");
15715        let swap_state = state.clone();
15716        let swap_moved = moved.clone();
15717        let outside_path = outside.path().to_path_buf();
15718        let (hub, server) = routed_json_hub(1, move |_| {
15719            // The client has already opened state/trust before this response.
15720            std::fs::rename(&swap_state, &swap_moved).unwrap();
15721            symlink(&outside_path, &swap_state).unwrap();
15722            (200, card.clone())
15723        });
15724        let cfg = test_hub_config(hub, state);
15725
15726        let verified = verified_remote_head(&cfg, TEST_BRAIN_ID, false).unwrap();
15727        assert_eq!(verified.head.seq, 0);
15728        assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
15729        assert!(std::fs::read_dir(moved.join("trust"))
15730            .unwrap()
15731            .flatten()
15732            .any(|entry| entry.path().extension().is_some_and(|ext| ext == "json")));
15733        server.join().unwrap();
15734    }
15735
15736    #[test]
15737    fn self_custody_push_refuses_an_unrelated_key_before_requesting_an_upload() {
15738        let remote = signed_remote_fixture();
15739        let unrelated = signed_remote_fixture().key;
15740        let (hub, server) = scripted_json_hub(vec![(200, remote.card), (200, remote.feed)]);
15741        let state = tempfile::tempdir().unwrap();
15742        let mut cfg = test_hub_config(hub, state.path().to_path_buf());
15743        cfg.brain_key = Some(unrelated);
15744        let error = sync_push(
15745            &cfg,
15746            TEST_BRAIN_ID,
15747            &[("DB.md".to_string(), "signed local content".to_string())],
15748        )
15749        .unwrap_err()
15750        .to_string();
15751        assert!(
15752            error.contains("not the verified current brain identity"),
15753            "{error}"
15754        );
15755        server.join().unwrap();
15756    }
15757
15758    #[test]
15759    fn rotation_ignores_a_forged_2xx_body_and_requires_verified_postcondition() {
15760        let remote = signed_remote_fixture();
15761        let new = signed_remote_fixture().key;
15762        let state = tempfile::tempdir().unwrap();
15763        let new_file = state.path().join("new.key");
15764        std::fs::write(
15765            &new_file,
15766            format!("{}\n", URL_SAFE_NO_PAD.encode(&new.pkcs8)),
15767        )
15768        .unwrap();
15769        #[cfg(unix)]
15770        {
15771            use std::os::unix::fs::PermissionsExt as _;
15772            std::fs::set_permissions(&new_file, std::fs::Permissions::from_mode(0o600)).unwrap();
15773        }
15774        let forged = json!({
15775            "brain": TEST_BRAIN_ID,
15776            "identity": {
15777                "fingerprint": new.multikey.trim_start_matches("ed25519:"),
15778                "publicKeySpki": new.public_key_spki,
15779            }
15780        })
15781        .to_string();
15782        let (hub, server) = scripted_json_hub(vec![
15783            (404, "{}".to_string()),
15784            (200, remote.card.clone()),
15785            (200, remote.feed.clone()),
15786            (200, forged),
15787            (200, remote.card),
15788            (200, remote.feed),
15789        ]);
15790        let cfg = test_hub_config(hub, state.path().to_path_buf());
15791        let error = rotate_brain_key(&cfg, TEST_BRAIN_ID, &remote.key, &new_file)
15792            .unwrap_err()
15793            .to_string();
15794        assert!(
15795            error.contains("without committing the verified new identity"),
15796            "{error}"
15797        );
15798        server.join().unwrap();
15799    }
15800
15801    #[test]
15802    fn resolve_record_is_derived_from_signed_pack_not_a_query_response() {
15803        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15804        let raw = format!(
15805            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15806        );
15807        let pack = build_store_pack(&[
15808            (
15809                "DB.md".to_string(),
15810                "---\ntype: db-md\nscope: company\nowner: Test\n---\n".to_string(),
15811            ),
15812            ("records/clients/truth.md".to_string(), raw.clone()),
15813        ])
15814        .unwrap();
15815        let by_id = resolve_from_verified_pack(
15816            "01j5qc3v9k4ym8rwbn2tqe6f7d",
15817            &AddressTarget::Id(record_id.to_string()),
15818            pack.clone(),
15819        )
15820        .unwrap();
15821        assert_eq!(by_id["document"]["summary"], "Signed truth");
15822        assert_eq!(by_id["document"]["body"], "# Signed truth\n");
15823        assert_eq!(
15824            by_id["document"]["contentSha"],
15825            content_sha256(raw.as_bytes())
15826        );
15827
15828        let by_path = resolve_from_verified_pack(
15829            "01j5qc3v9k4ym8rwbn2tqe6f7d",
15830            &AddressTarget::Path("records/clients/truth.md".to_string()),
15831            pack,
15832        )
15833        .unwrap();
15834        assert_eq!(by_path["document"]["id"], record_id);
15835        assert_eq!(by_path["document"]["path"], "records/clients/truth.md");
15836
15837        let wrong_id = resolve_from_verified_record_bytes(
15838            TEST_BRAIN_ID,
15839            &AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7f".to_string()),
15840            "records/clients/truth.md".to_string(),
15841            raw.as_bytes().to_vec(),
15842        )
15843        .unwrap_err()
15844        .to_string();
15845        assert!(wrong_id.contains("id differs"), "{wrong_id}");
15846
15847        let wrong_path = resolve_from_verified_record_bytes(
15848            TEST_BRAIN_ID,
15849            &AddressTarget::Path("records/clients/other.md".to_string()),
15850            "records/clients/truth.md".to_string(),
15851            raw.into_bytes(),
15852        )
15853        .unwrap_err()
15854        .to_string();
15855        assert!(wrong_path.contains("path differs"), "{wrong_path}");
15856    }
15857
15858    #[test]
15859    fn v2_record_locators_return_one_proof_bound_to_the_signed_content_root() {
15860        let path = "records/clients/truth.md";
15861        let record_id = "01j5qc3v9k4ym8rwbn2tqe6f7e";
15862        let raw = format!(
15863            "---\ntype: client\nid: {record_id}\nsummary: Signed truth\n---\n# Signed truth\n"
15864        );
15865        let sha256 = content_sha256(raw.as_bytes());
15866        let mut nonce = 0_u128;
15867        let tree = crate::linkmd_v2::build_content_tree(
15868            &[crate::linkmd_v2::ContentFile {
15869                path: path.to_string(),
15870                blob_hash: sha256.clone(),
15871                bytes: raw.len() as u64,
15872            }],
15873            None,
15874            &mut || {
15875                nonce += 1;
15876                format!("{nonce:032x}")
15877            },
15878        )
15879        .unwrap();
15880        let root = tree.root.clone().unwrap();
15881        let mut directory_root = root.clone();
15882        let mut proof = Vec::new();
15883        for component in path.split('/') {
15884            let inclusion =
15885                crate::linkmd_v2::create_proof(&directory_root, component, &tree.nodes).unwrap();
15886            let child = match &inclusion {
15887                crate::linkmd_v2::HamtProof::Inclusion { entry, .. } => entry.child_hash.clone(),
15888                crate::linkmd_v2::HamtProof::NonInclusion { .. } => {
15889                    panic!("fixture path must have an inclusion proof")
15890                }
15891            };
15892            proof.push(json!({
15893                "directory_root": directory_root,
15894                "component": component,
15895                "proof": inclusion,
15896            }));
15897            directory_root = child;
15898        }
15899        let commit_hash = "c".repeat(64);
15900        let pointer = V2PointerBody {
15901            v: 2,
15902            brain: TEST_BRAIN_ID.to_string(),
15903            seq: 1,
15904            commit_hash: commit_hash.clone(),
15905            feed_hash: "f".repeat(64),
15906            content_root: Some(root.clone()),
15907            asset_root: None,
15908            materializer: "dbmd-projection-v1".to_string(),
15909            signer_epoch: 1,
15910            control_revision: "d".repeat(64),
15911            backup_preparation: "e".repeat(64),
15912            prior_pointer_hash: None,
15913            signed_at: "2026-08-21T12:00:00.000Z".to_string(),
15914        };
15915        let manifest = json!({
15916            "v": 2,
15917            "commit": commit_hash,
15918            "content_root": root,
15919            "files": [{
15920                "path": path,
15921                "sha256": sha256,
15922                "bytes": raw.len(),
15923                "proof": proof,
15924            }],
15925            "next_cursor": Value::Null,
15926        })
15927        .to_string();
15928
15929        let path_manifest = manifest.clone();
15930        let (hub, server) = routed_json_hub(1, move |request| {
15931            assert_eq!(
15932                request,
15933                format!(
15934                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Ftruth.md",
15935                    "c".repeat(64)
15936                )
15937            );
15938            (200, path_manifest.clone())
15939        });
15940        let state = tempfile::tempdir().unwrap();
15941        let cfg = test_hub_config(hub, state.path().to_path_buf());
15942        let by_path = v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, path)
15943            .unwrap()
15944            .unwrap();
15945        assert_eq!(by_path.sha256, content_sha256(raw.as_bytes()));
15946        assert!(by_path.proof.is_some());
15947        server.join().unwrap();
15948
15949        let (hub, server) = routed_json_hub(1, move |request| {
15950            assert_eq!(
15951                request,
15952                format!(
15953                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&path=records%2Fclients%2Fmissing.md",
15954                    "c".repeat(64)
15955                )
15956            );
15957            (404, r#"{"error":"File not found"}"#.to_string())
15958        });
15959        let state = tempfile::tempdir().unwrap();
15960        let cfg = test_hub_config(hub, state.path().to_path_buf());
15961        assert!(
15962            v2_manifest_file(&cfg, TEST_BRAIN_ID, &pointer, "records/clients/missing.md")
15963                .unwrap()
15964                .is_none()
15965        );
15966        server.join().unwrap();
15967
15968        let id_manifest = manifest;
15969        let (hub, server) = routed_json_hub(1, move |request| {
15970            assert_eq!(
15971                request,
15972                format!(
15973                    "/api/hub/brains/{TEST_BRAIN_ID}/v2/files?commit={}&id={record_id}",
15974                    "c".repeat(64)
15975                )
15976            );
15977            (200, id_manifest.clone())
15978        });
15979        let state = tempfile::tempdir().unwrap();
15980        let cfg = test_hub_config(hub, state.path().to_path_buf());
15981        let (located_path, by_id) =
15982            v2_manifest_file_by_id(&cfg, TEST_BRAIN_ID, &pointer, record_id).unwrap();
15983        assert_eq!(located_path, path);
15984        assert_eq!(by_id.sha256, content_sha256(raw.as_bytes()));
15985        server.join().unwrap();
15986    }
15987
15988    #[test]
15989    fn canonical_store_pack_matches_the_cross_language_zip32_golden() {
15990        let unsorted = vec![
15991            ("records/a.md".to_string(), "alpha\n".to_string()),
15992            ("DB.md".to_string(), "# db\n".to_string()),
15993        ];
15994        let sorted = vec![
15995            ("DB.md".to_string(), "# db\n".to_string()),
15996            ("records/a.md".to_string(), "alpha\n".to_string()),
15997        ];
15998        let pack = build_store_pack(&unsorted).unwrap();
15999
16000        // This digest is shared with the hub implementation. It locks every
16001        // byte of the wire profile: raw UTF-8 order, STORED payloads, fixed DOS
16002        // epoch, explicit CRC/sizes, Unix regular-0600 attributes, and the
16003        // absence of descriptors/extras/comments/ZIP64.
16004        assert_eq!(pack.len(), 219);
16005        assert_eq!(
16006            content_sha256(&pack),
16007            "972fb2045becaa21588baaf4b349e62a430687fa2c21167b53f4ca0efa6c9408"
16008        );
16009        assert_eq!(pack, build_store_pack(&sorted).unwrap());
16010        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x07\x08"));
16011        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x06"));
16012        assert!(!pack.windows(4).any(|bytes| bytes == b"PK\x06\x07"));
16013
16014        assert_eq!(
16015            parse_store_pack(pack).unwrap(),
16016            vec![
16017                ("DB.md".to_string(), b"# db\n".to_vec()),
16018                ("records/a.md".to_string(), b"alpha\n".to_vec()),
16019            ]
16020        );
16021    }
16022
16023    #[test]
16024    fn canonical_store_pack_validates_every_path_before_writing() {
16025        let duplicate = vec![
16026            ("DB.md".to_string(), "first".to_string()),
16027            ("DB.md".to_string(), "second".to_string()),
16028        ];
16029        assert!(build_store_pack(&duplicate)
16030            .unwrap_err()
16031            .to_string()
16032            .contains("duplicate path"));
16033        assert!(matches!(
16034            build_store_pack(&[("../escape.md".to_string(), "no".to_string())]),
16035            Err(LinkError::UnsafePath { .. })
16036        ));
16037    }
16038
16039    #[test]
16040    fn zip64_preflight_rejects_an_entry_count_bomb_before_zip_parsing() {
16041        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16042        // One byte stands in for the central directory. The trailer itself is
16043        // structurally valid; the count is the sole reason for refusal.
16044        let mut bytes = vec![0_u8];
16045        let zip64_offset = bytes.len() as u64;
16046        bytes.extend_from_slice(b"PK\x06\x06");
16047        bytes.extend_from_slice(&44_u64.to_le_bytes());
16048        bytes.extend_from_slice(&[0_u8; 12]);
16049        bytes.extend_from_slice(&COUNT.to_le_bytes());
16050        bytes.extend_from_slice(&COUNT.to_le_bytes());
16051        bytes.extend_from_slice(&1_u64.to_le_bytes());
16052        bytes.extend_from_slice(&0_u64.to_le_bytes());
16053        bytes.extend_from_slice(b"PK\x06\x07");
16054        bytes.extend_from_slice(&0_u32.to_le_bytes());
16055        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16056        bytes.extend_from_slice(&1_u32.to_le_bytes());
16057        bytes.extend_from_slice(b"PK\x05\x06");
16058        bytes.extend_from_slice(&0_u16.to_le_bytes());
16059        bytes.extend_from_slice(&0_u16.to_le_bytes());
16060        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16061        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16062        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16063        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16064        bytes.extend_from_slice(&0_u16.to_le_bytes());
16065
16066        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16067            .unwrap_err()
16068            .to_string();
16069        assert!(error.contains("invalid file count"), "{error}");
16070    }
16071
16072    #[test]
16073    fn zip_preflight_rejects_a_fake_trailing_eocd_before_zip_parsing() {
16074        const COUNT: u64 = MAX_PUSH_FILES as u64 + 1;
16075        let mut bytes = vec![0_u8];
16076        let zip64_offset = bytes.len() as u64;
16077        bytes.extend_from_slice(b"PK\x06\x06");
16078        bytes.extend_from_slice(&44_u64.to_le_bytes());
16079        bytes.extend_from_slice(&[0_u8; 12]);
16080        bytes.extend_from_slice(&COUNT.to_le_bytes());
16081        bytes.extend_from_slice(&COUNT.to_le_bytes());
16082        bytes.extend_from_slice(&1_u64.to_le_bytes());
16083        bytes.extend_from_slice(&0_u64.to_le_bytes());
16084        bytes.extend_from_slice(b"PK\x06\x07");
16085        bytes.extend_from_slice(&0_u32.to_le_bytes());
16086        bytes.extend_from_slice(&zip64_offset.to_le_bytes());
16087        bytes.extend_from_slice(&1_u32.to_le_bytes());
16088        bytes.extend_from_slice(b"PK\x05\x06");
16089        bytes.extend_from_slice(&0_u16.to_le_bytes());
16090        bytes.extend_from_slice(&0_u16.to_le_bytes());
16091        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16092        bytes.extend_from_slice(&u16::MAX.to_le_bytes());
16093        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16094        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
16095        bytes.extend_from_slice(&0_u16.to_le_bytes());
16096        // The old parser trusted this last low-count signature, while
16097        // ZipArchive fell back to the real Zip64 directory and allocated for
16098        // COUNT entries. Its central-directory offsets are deliberately fake.
16099        let fake_eocd = bytes.len() as u32;
16100        bytes.extend_from_slice(b"PK\x05\x06");
16101        bytes.extend_from_slice(&0_u16.to_le_bytes());
16102        bytes.extend_from_slice(&0_u16.to_le_bytes());
16103        bytes.extend_from_slice(&1_u16.to_le_bytes());
16104        bytes.extend_from_slice(&1_u16.to_le_bytes());
16105        bytes.extend_from_slice(&0_u32.to_le_bytes());
16106        bytes.extend_from_slice(&fake_eocd.to_le_bytes());
16107        bytes.extend_from_slice(&0_u16.to_le_bytes());
16108
16109        let error = preflight_zip_entry_count(&bytes, MAX_PUSH_FILES)
16110            .unwrap_err()
16111            .to_string();
16112        assert!(error.contains("central directory"), "{error}");
16113    }
16114
16115    #[test]
16116    fn strict_http_status_handling_rejects_redirects_without_panicking() {
16117        let error = ensure_ok(
16118            HubResponse {
16119                status: 302,
16120                body: Some(json!({"redirect": "/elsewhere"})),
16121            },
16122            "mutation",
16123        )
16124        .unwrap_err();
16125        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16126
16127        let error = ensure_raw_ok(
16128            RawHubResponse {
16129                status: 302,
16130                body: br#"{"redirect":"/elsewhere"}"#.to_vec(),
16131            },
16132            "feed",
16133        )
16134        .unwrap_err();
16135        assert!(matches!(error, LinkError::Http { status: 302, .. }));
16136    }
16137
16138    #[cfg(unix)]
16139    #[test]
16140    fn collect_push_files_refuses_external_symlink_and_nested_store() {
16141        use std::os::unix::fs::symlink;
16142
16143        let root = tempfile::tempdir().unwrap();
16144        std::fs::write(
16145            root.path().join("DB.md"),
16146            "---\ntype: db-md\nscope: company\nowner: Test\n---\n",
16147        )
16148        .unwrap();
16149        std::fs::create_dir_all(root.path().join("records/notes")).unwrap();
16150
16151        let external = tempfile::tempdir().unwrap();
16152        let secret = external.path().join("secret.md");
16153        std::fs::write(&secret, "TOP SECRET").unwrap();
16154        symlink(&secret, root.path().join("records/notes/secret.md")).unwrap();
16155
16156        let store = Store::open_strict(root.path()).unwrap();
16157        let err = collect_push_files(&store).unwrap_err().to_string();
16158        assert!(err.contains("cannot push"), "{err}");
16159        assert!(
16160            !err.contains("TOP SECRET"),
16161            "external bytes must never leak"
16162        );
16163
16164        std::fs::remove_file(root.path().join("records/notes/secret.md")).unwrap();
16165        let nested = root.path().join("records/nested");
16166        std::fs::create_dir_all(&nested).unwrap();
16167        std::fs::write(
16168            nested.join("DB.md"),
16169            "---\ntype: db-md\nscope: research\nowner: Nested\n---\n",
16170        )
16171        .unwrap();
16172        let err = collect_push_files(&store).unwrap_err().to_string();
16173        assert!(err.contains("nested db.md store"), "{err}");
16174    }
16175
16176    #[cfg(unix)]
16177    #[test]
16178    fn remote_push_uses_opened_root_after_path_replacement() {
16179        use std::os::unix::fs::symlink;
16180
16181        let sandbox = tempfile::tempdir().unwrap();
16182        let root = sandbox.path().join("store");
16183        std::fs::create_dir_all(root.join("records/notes")).unwrap();
16184        std::fs::write(root.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16185        std::fs::write(
16186            root.join("records/notes/owned.md"),
16187            "---\ntype: note\nsummary: owned\n---\nowned upload\n",
16188        )
16189        .unwrap();
16190        let store = Store::open_strict(&root).unwrap();
16191        let detached = sandbox.path().join("detached");
16192        std::fs::rename(&root, &detached).unwrap();
16193
16194        let replacement = sandbox.path().join("replacement");
16195        std::fs::create_dir_all(replacement.join("records/notes")).unwrap();
16196        std::fs::write(replacement.join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
16197        std::fs::write(
16198            replacement.join("records/notes/secret.md"),
16199            "---\ntype: note\nsummary: secret\n---\nreplacement sentinel\n",
16200        )
16201        .unwrap();
16202        symlink(&replacement, &root).unwrap();
16203
16204        let files = collect_push_files(&store).unwrap();
16205        let wire_text = files
16206            .iter()
16207            .map(|(path, content)| format!("{path}\n{content}"))
16208            .collect::<Vec<_>>()
16209            .join("\n");
16210        assert!(wire_text.contains("owned upload"));
16211        assert!(!wire_text.contains("replacement sentinel"));
16212        assert!(!wire_text.contains("records/notes/secret.md"));
16213
16214        let remote = signed_remote_fixture();
16215        let (hub, server) = scripted_json_hub(vec![
16216            (200, remote.card),
16217            (200, remote.feed),
16218            (200, json!({"ok": true}).to_string()),
16219        ]);
16220        let state = tempfile::tempdir().unwrap();
16221        let cfg = test_hub_config(hub, state.path().to_path_buf());
16222        let pushed = sync_push(&cfg, TEST_BRAIN_ID, &files).unwrap();
16223        assert_eq!(pushed, json!({"ok": true}));
16224        server.join().unwrap();
16225    }
16226
16227    #[test]
16228    fn signed_feed_item_verifies_identity_hash_and_signature() {
16229        use ring::rand::SystemRandom;
16230        use ring::signature::{Ed25519KeyPair, KeyPair};
16231
16232        const PREFIX: &[u8] = &[
16233            0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
16234        ];
16235        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap();
16236        let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16237        let mut spki = PREFIX.to_vec();
16238        spki.extend_from_slice(pair.public_key().as_ref());
16239        let public_key = URL_SAFE_NO_PAD.encode(&spki);
16240        let fingerprint = URL_SAFE_NO_PAD.encode(Sha256::digest(&spki));
16241        let mut entry = FeedEntry {
16242            v: 1,
16243            seq: 1,
16244            ts: "2026-07-14T00:00:00.000Z".to_string(),
16245            brain: format!("ed25519:{fingerprint}"),
16246            public_key: public_key.clone(),
16247            kind: "push".to_string(),
16248            op: "snapshot".to_string(),
16249            pack_sha256: "a".repeat(64),
16250            files: vec![FeedFile {
16251                path: "DB.md".to_string(),
16252                sha256: "b".repeat(64),
16253                bytes: 3,
16254            }],
16255            removed: vec![],
16256            prev_entry_hash: None,
16257            sig: String::new(),
16258        };
16259        let unsigned = UnsignedFeedEntry {
16260            v: entry.v,
16261            seq: entry.seq,
16262            ts: &entry.ts,
16263            brain: &entry.brain,
16264            public_key: &entry.public_key,
16265            kind: &entry.kind,
16266            op: &entry.op,
16267            pack_sha256: &entry.pack_sha256,
16268            files: &entry.files,
16269            removed: &entry.removed,
16270            prev_entry_hash: &entry.prev_entry_hash,
16271        };
16272        entry.sig =
16273            URL_SAFE_NO_PAD.encode(pair.sign(&serde_json::to_vec(&unsigned).unwrap()).as_ref());
16274        let mut exact = serde_json::to_vec(&entry).unwrap();
16275        exact.push(b'\n');
16276        let item = FeedItem {
16277            hash: format!("{:x}", Sha256::digest(&exact)),
16278            entry,
16279        };
16280        let identity = FeedIdentity {
16281            fingerprint,
16282            public_key_spki: public_key,
16283            previous: Vec::new(),
16284            rotations: Vec::new(),
16285        };
16286        assert!(verify_feed_item(&item, &identity).is_ok());
16287        let mut tampered = item;
16288        tampered.entry.pack_sha256 = "c".repeat(64);
16289        assert!(verify_feed_item(&tampered, &identity).is_err());
16290    }
16291
16292    #[test]
16293    fn v2_commit_requires_exact_fields_and_a_valid_genesis_bridge() {
16294        let rng = ring::rand::SystemRandom::new();
16295        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16296        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16297        let (spki, multikey) = public_identity_for(&pair);
16298        let identity = V2HeadIdentity {
16299            custody: "self".to_string(),
16300            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16301            public_key_spki: spki.clone(),
16302            previous: Vec::new(),
16303            rotations: Vec::new(),
16304        };
16305        let unsigned = json!({
16306            "actor_ref": "a".repeat(64),
16307            "asset_root": Value::Null,
16308            "brain": multikey,
16309            "changes_sha256": "b".repeat(64),
16310            "control_revision": "c".repeat(64),
16311            "materializer": "dbmd-projection-v1",
16312            "op": "changeset",
16313            "parent_asset_root": Value::Null,
16314            "parent_commit": Value::Null,
16315            "parent_root": Value::Null,
16316            "prev_entry_hash": Value::Null,
16317            "public_key": spki,
16318            "seq": 1,
16319            "signer_epoch": 1,
16320            "state_root": "d".repeat(64),
16321            "ts": "2026-08-19T12:00:00.000Z",
16322            "v": 2,
16323            "v1_bridge": {
16324                "feed_hash": "e".repeat(64),
16325                "head_seq": 7,
16326                "pack_sha256": "f".repeat(64),
16327            },
16328        });
16329        let sign_value = |value: Value| {
16330            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16331            let mut object = value.as_object().unwrap().clone();
16332            object.insert(
16333                "sig".to_string(),
16334                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16335            );
16336            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16337        };
16338        assert!(verified_v2_commit_object(&sign_value(unsigned.clone()), &identity).is_ok());
16339
16340        let mut extra = unsigned.clone();
16341        extra
16342            .as_object_mut()
16343            .unwrap()
16344            .insert("future".to_string(), Value::Bool(true));
16345        assert!(verified_v2_commit_object(&sign_value(extra), &identity).is_err());
16346
16347        let mut missing = unsigned.clone();
16348        missing.as_object_mut().unwrap().remove("v1_bridge");
16349        assert!(verified_v2_commit_object(&sign_value(missing), &identity).is_err());
16350
16351        let mut invalid_bridge = unsigned;
16352        invalid_bridge.as_object_mut().unwrap().insert(
16353            "v1_bridge".to_string(),
16354            json!({"feed_hash": "e".repeat(64), "head_seq": 0, "pack_sha256": "f".repeat(64)}),
16355        );
16356        assert!(verified_v2_commit_object(&sign_value(invalid_bridge), &identity).is_err());
16357    }
16358
16359    #[test]
16360    fn shared_v2_commit_bridge_vector_matches_the_typescript_signer() {
16361        let vector: Value = serde_json::from_str(include_str!(
16362            "../tests/vectors/linkmd-v2-commit-bridge.json"
16363        ))
16364        .unwrap();
16365        let identity_value = vector.get("identity").unwrap();
16366        let identity = V2HeadIdentity {
16367            custody: "self".to_string(),
16368            fingerprint: identity_value
16369                .get("fingerprint")
16370                .and_then(Value::as_str)
16371                .unwrap()
16372                .to_string(),
16373            public_key_spki: identity_value
16374                .get("public_key_spki")
16375                .and_then(Value::as_str)
16376                .unwrap()
16377                .to_string(),
16378            previous: Vec::new(),
16379            rotations: Vec::new(),
16380        };
16381        let private = URL_SAFE_NO_PAD
16382            .decode(
16383                identity_value
16384                    .get("private_key_pkcs8")
16385                    .and_then(Value::as_str)
16386                    .unwrap(),
16387            )
16388            .unwrap();
16389        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&private)
16390            .or_else(|_| ring::signature::Ed25519KeyPair::from_pkcs8_maybe_unchecked(&private))
16391            .unwrap();
16392        let base = vector.get("body").unwrap().as_object().unwrap();
16393
16394        for item in vector.get("valid").unwrap().as_array().unwrap() {
16395            let mut body = base.clone();
16396            body.insert(
16397                "v1_bridge".to_string(),
16398                item.get("v1_bridge").unwrap().clone(),
16399            );
16400            body.insert(
16401                "sig".to_string(),
16402                item.get("signature_base64url").unwrap().clone(),
16403            );
16404            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16405            assert!(verified_v2_commit_object(&signed, &identity).is_ok());
16406            assert_eq!(
16407                crate::linkmd_v2::domain_hash_bytes("v2/commit", &signed).unwrap(),
16408                item.get("commit_hash").and_then(Value::as_str).unwrap()
16409            );
16410            assert_eq!(
16411                format!("{:x}", Sha256::digest(&signed)),
16412                item.get("feed_hash").and_then(Value::as_str).unwrap()
16413            );
16414        }
16415
16416        for item in vector.get("invalid").unwrap().as_array().unwrap() {
16417            let mut body = base.clone();
16418            if let Some(remove) = item.get("remove").and_then(Value::as_array) {
16419                for field in remove {
16420                    body.remove(field.as_str().unwrap());
16421                }
16422            }
16423            if let Some(set) = item.get("set").and_then(Value::as_object) {
16424                for (field, value) in set {
16425                    body.insert(field.clone(), value.clone());
16426                }
16427            }
16428            let message = crate::linkmd_v2::canonical_bytes(&Value::Object(body.clone())).unwrap();
16429            body.insert(
16430                "sig".to_string(),
16431                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16432            );
16433            let signed = crate::linkmd_v2::canonical_bytes(&Value::Object(body)).unwrap();
16434            assert!(
16435                verified_v2_commit_object(&signed, &identity).is_err(),
16436                "accepted invalid shared vector {}",
16437                item.get("reason").and_then(Value::as_str).unwrap()
16438            );
16439        }
16440    }
16441
16442    #[test]
16443    fn shared_v2_kept_home_changeset_vector_matches_the_typescript_hub() {
16444        let vector: Value = serde_json::from_str(include_str!(
16445            "../tests/vectors/linkmd-v2-changeset-withheld.json"
16446        ))
16447        .unwrap();
16448        assert_eq!(
16449            vector.get("profile").and_then(Value::as_str),
16450            Some("link.md-v2-changeset-withheld")
16451        );
16452        let canonical =
16453            crate::linkmd_v2::canonical_bytes(vector.get("changeset").unwrap()).unwrap();
16454        let expected = STANDARD
16455            .decode(
16456                vector
16457                    .get("canonical_base64")
16458                    .and_then(Value::as_str)
16459                    .unwrap(),
16460            )
16461            .unwrap();
16462        assert_eq!(canonical, expected);
16463        assert_eq!(
16464            crate::linkmd_v2::domain_hash_bytes("v2/changeset", &canonical).unwrap(),
16465            vector.get("domain_hash").and_then(Value::as_str).unwrap()
16466        );
16467    }
16468
16469    #[test]
16470    fn v2_profile_transition_reverifies_the_exact_signed_v1_bridge() {
16471        let remote = signed_remote_fixture();
16472        let legacy: FeedResponse = serde_json::from_str(&remote.feed).unwrap();
16473        let legacy_item = legacy.entries.first().unwrap();
16474        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(&remote.key.pkcs8).unwrap();
16475        let body = json!({
16476            "actor_ref": "a".repeat(64),
16477            "asset_root": Value::Null,
16478            "brain": remote.key.multikey,
16479            "changes_sha256": "b".repeat(64),
16480            "control_revision": "c".repeat(64),
16481            "materializer": "dbmd-projection-v1",
16482            "op": "changeset",
16483            "parent_asset_root": Value::Null,
16484            "parent_commit": Value::Null,
16485            "parent_root": Value::Null,
16486            "prev_entry_hash": Value::Null,
16487            "public_key": remote.key.public_key_spki,
16488            "seq": 1,
16489            "signer_epoch": 1,
16490            "state_root": "d".repeat(64),
16491            "ts": "2026-08-19T12:00:00.000Z",
16492            "v": 2,
16493            "v1_bridge": {
16494                "feed_hash": legacy_item.hash,
16495                "head_seq": legacy_item.entry.seq,
16496                "pack_sha256": legacy_item.entry.pack_sha256,
16497            },
16498        });
16499        let message = crate::linkmd_v2::canonical_bytes(&body).unwrap();
16500        let mut signed = body.as_object().unwrap().clone();
16501        signed.insert(
16502            "sig".to_string(),
16503            Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16504        );
16505        let raw = crate::linkmd_v2::canonical_bytes(&Value::Object(signed)).unwrap();
16506        let commit_hash = crate::linkmd_v2::domain_hash_bytes("v2/commit", &raw).unwrap();
16507        let feed_hash = content_sha256(&raw);
16508        let pointer = V2PointerBody {
16509            v: 2,
16510            brain: TEST_BRAIN_ID.to_string(),
16511            seq: 1,
16512            commit_hash: commit_hash.clone(),
16513            feed_hash: feed_hash.clone(),
16514            content_root: Some("d".repeat(64)),
16515            asset_root: None,
16516            materializer: "dbmd-projection-v1".to_string(),
16517            signer_epoch: 1,
16518            control_revision: "c".repeat(64),
16519            backup_preparation: "e".repeat(64),
16520            prior_pointer_hash: None,
16521            signed_at: "2026-08-19T12:00:00.000Z".to_string(),
16522        };
16523        let v2_page = json!({
16524            "v": 2,
16525            "head_seq": 1,
16526            "head_commit_hash": commit_hash,
16527            "head_feed_hash": feed_hash,
16528            "entries": [{
16529                "seq": 1,
16530                "commit_hash": pointer.commit_hash,
16531                "feed_hash": pointer.feed_hash,
16532                "bytes_base64": STANDARD.encode(&raw),
16533            }],
16534            "next_after": 1,
16535            "complete": true,
16536        })
16537        .to_string();
16538        let identity = V2HeadIdentity {
16539            custody: "self".to_string(),
16540            fingerprint: remote.identity.fingerprint.clone(),
16541            public_key_spki: remote.identity.public_key_spki.clone(),
16542            previous: Vec::new(),
16543            rotations: Vec::new(),
16544        };
16545        let checkpoint = TrustState {
16546            v: 2,
16547            origin: "unused".to_string(),
16548            requested: TEST_BRAIN_ID.to_string(),
16549            brain: TEST_BRAIN_ID.to_string(),
16550            home: None,
16551            anchor: remote.key.multikey.clone(),
16552            current: remote.key.multikey,
16553            head_seq: legacy_item.entry.seq,
16554            feed_hash: Some(legacy_item.hash.clone()),
16555            rotations: Vec::new(),
16556            hub_signer: None,
16557            protocol_profile: None,
16558        };
16559        let (hub, server) = scripted_json_hub(vec![(200, v2_page), (200, remote.feed)]);
16560        let state = tempfile::tempdir().unwrap();
16561        let cfg = test_hub_config(hub, state.path().to_path_buf());
16562        verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &checkpoint).unwrap();
16563        server.join().unwrap();
16564
16565        let mut wrong = checkpoint;
16566        wrong.feed_hash = Some("0".repeat(64));
16567        let (hub, server) = scripted_json_hub(vec![(
16568            200,
16569            json!({
16570                "v": 2,
16571                "head_seq": 1,
16572                "head_commit_hash": pointer.commit_hash,
16573                "head_feed_hash": pointer.feed_hash,
16574                "entries": [{
16575                    "seq": 1,
16576                    "commit_hash": pointer.commit_hash,
16577                    "feed_hash": pointer.feed_hash,
16578                    "bytes_base64": STANDARD.encode(&raw),
16579                }],
16580                "next_after": 1,
16581                "complete": true,
16582            })
16583            .to_string(),
16584        )]);
16585        let state = tempfile::tempdir().unwrap();
16586        let cfg = test_hub_config(hub, state.path().to_path_buf());
16587        assert!(verify_v1_to_v2_bridge(&cfg, TEST_BRAIN_ID, &pointer, &identity, &wrong,).is_err());
16588        server.join().unwrap();
16589    }
16590
16591    #[test]
16592    fn v2_commit_signer_epoch_follows_the_authenticated_rotation_boundary() {
16593        let rng = ring::rand::SystemRandom::new();
16594        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16595        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16596        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16597        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16598        let (old_spki, old_multikey) = public_identity_for(&old);
16599        let (new_spki, new_multikey) = public_identity_for(&new);
16600        let rotation_unsigned = serde_json::to_string(&UnsignedRotation {
16601            v: 1,
16602            op: "rotate",
16603            brain: &old_multikey,
16604            public_key: &old_spki,
16605            new_brain: &new_multikey,
16606            new_public_key: &new_spki,
16607            prior_head_seq: 1,
16608            prior_feed_hash: Some(&"9".repeat(64)),
16609            ts: "2026-08-19T12:01:00.000Z".to_string(),
16610        })
16611        .unwrap();
16612        let rotation_sig = URL_SAFE_NO_PAD.encode(old.sign(rotation_unsigned.as_bytes()).as_ref());
16613        let rotation = format!(
16614            "{},\"sig\":\"{}\"}}",
16615            &rotation_unsigned[..rotation_unsigned.len() - 1],
16616            rotation_sig
16617        );
16618        let identity = V2HeadIdentity {
16619            custody: "self".to_string(),
16620            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16621            public_key_spki: new_spki.clone(),
16622            previous: vec![V2PreviousIdentity {
16623                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16624                public_key_spki: old_spki.clone(),
16625            }],
16626            rotations: vec![rotation],
16627        };
16628        let commit = |seq: u64,
16629                      epoch: u64,
16630                      multikey: &str,
16631                      spki: &str,
16632                      pair: &ring::signature::Ed25519KeyPair| {
16633            let value = json!({
16634                "actor_ref": "a".repeat(64),
16635                "asset_root": Value::Null,
16636                "brain": multikey,
16637                "changes_sha256": "b".repeat(64),
16638                "control_revision": "c".repeat(64),
16639                "materializer": "dbmd-projection-v1",
16640                "op": "changeset",
16641                "parent_asset_root": Value::Null,
16642                "parent_commit": if seq == 1 { Value::Null } else { Value::String("d".repeat(64)) },
16643                "parent_root": if seq == 1 { Value::Null } else { Value::String("e".repeat(64)) },
16644                "prev_entry_hash": if seq == 1 { Value::Null } else { Value::String("f".repeat(64)) },
16645                "public_key": spki,
16646                "seq": seq,
16647                "signer_epoch": epoch,
16648                "state_root": "1".repeat(64),
16649                "ts": "2026-08-19T12:00:00.000Z",
16650                "v": 2,
16651                "v1_bridge": Value::Null,
16652            });
16653            let message = crate::linkmd_v2::canonical_bytes(&value).unwrap();
16654            let mut object = value.as_object().unwrap().clone();
16655            object.insert(
16656                "sig".to_string(),
16657                Value::String(URL_SAFE_NO_PAD.encode(pair.sign(&message).as_ref())),
16658            );
16659            crate::linkmd_v2::canonical_bytes(&Value::Object(object)).unwrap()
16660        };
16661
16662        assert!(verified_v2_commit_object(
16663            &commit(1, 1, &old_multikey, &old_spki, &old),
16664            &identity,
16665        )
16666        .is_ok());
16667        assert!(verified_v2_commit_object(
16668            &commit(2, 2, &new_multikey, &new_spki, &new),
16669            &identity,
16670        )
16671        .is_ok());
16672        assert!(verified_v2_commit_object(
16673            &commit(2, 1, &old_multikey, &old_spki, &old),
16674            &identity,
16675        )
16676        .is_err());
16677        assert!(verified_v2_commit_object(
16678            &commit(1, 2, &new_multikey, &new_spki, &new),
16679            &identity,
16680        )
16681        .is_err());
16682    }
16683
16684    #[test]
16685    fn a_self_custody_entry_verifies_like_any_hub_entry() {
16686        let rng = ring::rand::SystemRandom::new();
16687        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16688        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
16689        let (spki, multikey) = public_identity_for(&pair);
16690        let key = AgentSigningKey {
16691            pkcs8: pkcs8.as_ref().to_vec(),
16692            multikey: multikey.clone(),
16693            public_key_spki: spki.clone(),
16694        };
16695        let files = vec![WireFeedFile {
16696            path: "DB.md".to_string(),
16697            sha256: "a".repeat(64),
16698            bytes: 3,
16699        }];
16700        let raw = self_custody_entry(
16701            &key,
16702            1,
16703            "2026-07-23T12:00:00.000Z".to_string(),
16704            &"c".repeat(64),
16705            &files,
16706            None,
16707        )
16708        .unwrap();
16709        // The exact client serialization parses as a feed entry and passes the
16710        // SAME verifier every subscribe read runs — the self-custody path
16711        // produces first-class wire-profile-v1 entries.
16712        let entry: FeedEntry = serde_json::from_str(&raw).unwrap();
16713        let hash = format!("{:x}", Sha256::digest(format!("{raw}\n").as_bytes()));
16714        let item = FeedItem { hash, entry };
16715        let identity = FeedIdentity {
16716            fingerprint: multikey.trim_start_matches("ed25519:").to_string(),
16717            public_key_spki: spki,
16718            previous: Vec::new(),
16719            rotations: Vec::new(),
16720        };
16721        assert!(verify_feed_item(&item, &identity).is_ok());
16722    }
16723
16724    #[test]
16725    fn identity_rotation_requires_old_key_signature_and_preserves_the_pin() {
16726        let rng = ring::rand::SystemRandom::new();
16727        let old_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16728        let old = ring::signature::Ed25519KeyPair::from_pkcs8(old_pkcs8.as_ref()).unwrap();
16729        let new_pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
16730        let new = ring::signature::Ed25519KeyPair::from_pkcs8(new_pkcs8.as_ref()).unwrap();
16731        let (old_spki, old_multikey) = public_identity_for(&old);
16732        let (new_spki, new_multikey) = public_identity_for(&new);
16733        let unsigned = serde_json::to_string(&UnsignedRotation {
16734            v: 1,
16735            op: "rotate",
16736            brain: &old_multikey,
16737            public_key: &old_spki,
16738            new_brain: &new_multikey,
16739            new_public_key: &new_spki,
16740            prior_head_seq: 1,
16741            prior_feed_hash: Some(&"a".repeat(64)),
16742            ts: "2026-07-30T12:00:00.000Z".to_string(),
16743        })
16744        .unwrap();
16745        let signature = URL_SAFE_NO_PAD.encode(old.sign(unsigned.as_bytes()).as_ref());
16746        let rotation = format!(
16747            "{},\"sig\":\"{}\"}}",
16748            &unsigned[..unsigned.len() - 1],
16749            signature
16750        );
16751        let identity = FeedIdentity {
16752            fingerprint: new_multikey.trim_start_matches("ed25519:").to_string(),
16753            public_key_spki: new_spki,
16754            previous: vec![PreviousIdentity {
16755                fingerprint: old_multikey.trim_start_matches("ed25519:").to_string(),
16756                public_key_spki: old_spki,
16757            }],
16758            rotations: vec![rotation],
16759        };
16760        let pin = TrustState {
16761            v: 2,
16762            origin: "https://hub.example".to_string(),
16763            requested: "brain".to_string(),
16764            brain: "brain".to_string(),
16765            home: None,
16766            anchor: old_multikey.clone(),
16767            current: old_multikey.clone(),
16768            head_seq: 1,
16769            feed_hash: Some("a".repeat(64)),
16770            rotations: Vec::new(),
16771            hub_signer: None,
16772            protocol_profile: None,
16773        };
16774        assert_eq!(
16775            verify_identity_chain(&identity, Some(&pin)).unwrap(),
16776            old_multikey
16777        );
16778        let mut accepted = pin.clone();
16779        accepted.current = new_multikey.clone();
16780        accepted.rotations = identity.rotations.clone();
16781        let alternate_unsigned = serde_json::to_string(&UnsignedRotation {
16782            v: 1,
16783            op: "rotate",
16784            brain: &old_multikey,
16785            public_key: &identity.previous[0].public_key_spki,
16786            new_brain: &new_multikey,
16787            new_public_key: &identity.public_key_spki,
16788            prior_head_seq: 1,
16789            prior_feed_hash: Some(&"a".repeat(64)),
16790            ts: "2026-07-30T12:00:01.000Z".to_string(),
16791        })
16792        .unwrap();
16793        let alternate_signature =
16794            URL_SAFE_NO_PAD.encode(old.sign(alternate_unsigned.as_bytes()).as_ref());
16795        let mut rewritten = identity.clone();
16796        rewritten.rotations[0] = format!(
16797            "{},\"sig\":\"{}\"}}",
16798            &alternate_unsigned[..alternate_unsigned.len() - 1],
16799            alternate_signature
16800        );
16801        assert!(
16802            verify_identity_chain(&rewritten, Some(&accepted)).is_err(),
16803            "an alternate valid statement must not rewrite accepted history"
16804        );
16805
16806        let mut stale_entry = FeedEntry {
16807            v: 1,
16808            seq: 2,
16809            ts: "2026-07-30T12:01:00.000Z".to_string(),
16810            brain: pin.current.clone(),
16811            public_key: identity.previous[0].public_key_spki.clone(),
16812            kind: "push".to_string(),
16813            op: "snapshot".to_string(),
16814            pack_sha256: "b".repeat(64),
16815            files: Vec::new(),
16816            removed: Vec::new(),
16817            prev_entry_hash: pin.feed_hash.clone(),
16818            sig: String::new(),
16819        };
16820        let stale_unsigned = UnsignedFeedEntry {
16821            v: stale_entry.v,
16822            seq: stale_entry.seq,
16823            ts: &stale_entry.ts,
16824            brain: &stale_entry.brain,
16825            public_key: &stale_entry.public_key,
16826            kind: &stale_entry.kind,
16827            op: &stale_entry.op,
16828            pack_sha256: &stale_entry.pack_sha256,
16829            files: &stale_entry.files,
16830            removed: &stale_entry.removed,
16831            prev_entry_hash: &stale_entry.prev_entry_hash,
16832        };
16833        stale_entry.sig = URL_SAFE_NO_PAD.encode(
16834            old.sign(&serde_json::to_vec(&stale_unsigned).unwrap())
16835                .as_ref(),
16836        );
16837        let mut stale_exact = serde_json::to_vec(&stale_entry).unwrap();
16838        stale_exact.push(b'\n');
16839        let stale_item = FeedItem {
16840            hash: content_sha256(&stale_exact),
16841            entry: stale_entry,
16842        };
16843        assert!(
16844            reject_retired_signer_after_checkpoint(&identity, Some(&accepted), &stale_item)
16845                .is_err(),
16846            "a key retired before the checkpoint must never append after it"
16847        );
16848        assert!(
16849            verify_feed_item(&stale_item, &identity).is_err(),
16850            "an old key must never append after its signed rotation boundary"
16851        );
16852
16853        let mut missing = identity.clone();
16854        missing.rotations.clear();
16855        assert!(verify_identity_chain(&missing, Some(&pin)).is_err());
16856
16857        let mut tampered = identity;
16858        tampered.rotations[0] = tampered.rotations[0].replace("\"sig\":\"", "\"sig\":\"A");
16859        assert!(verify_identity_chain(&tampered, Some(&pin)).is_err());
16860    }
16861
16862    #[cfg(unix)]
16863    #[test]
16864    fn key_creation_refuses_a_planted_symlink_without_touching_its_target() {
16865        use std::os::unix::fs::symlink;
16866
16867        let dir = tempfile::tempdir().unwrap();
16868        let target = dir.path().join("valuable.txt");
16869        let planted = dir.path().join("agent.key");
16870        std::fs::write(&target, "do not overwrite").unwrap();
16871        symlink(&target, &planted).unwrap();
16872
16873        assert!(matches!(
16874            generate_agent_key(&planted),
16875            Err(LinkError::BadAgentKey { .. })
16876        ));
16877        assert_eq!(std::fs::read_to_string(target).unwrap(), "do not overwrite");
16878    }
16879
16880    #[cfg(unix)]
16881    #[test]
16882    fn key_creation_refuses_a_symlinked_parent_without_writing_through_it() {
16883        use std::os::unix::fs::symlink;
16884
16885        let root = tempfile::tempdir().unwrap();
16886        let outside = tempfile::tempdir().unwrap();
16887        symlink(outside.path(), root.path().join("redirect")).unwrap();
16888
16889        assert!(generate_agent_key(&root.path().join("redirect/agent.key")).is_err());
16890        assert!(!outside.path().join("agent.key").exists());
16891    }
16892
16893    // ── Address parsing ─────────────────────────────────────────────────────
16894
16895    #[test]
16896    fn address_bare_brain_with_and_without_sigil() {
16897        for raw in ["@acme-ops", "acme-ops"] {
16898            let a = Address::parse(raw).expect(raw);
16899            assert_eq!(a.brain, "acme-ops");
16900            assert_eq!(a.target, None);
16901        }
16902    }
16903
16904    #[test]
16905    fn address_ulid_target_parses_as_id() {
16906        let a = Address::parse("@acme/01j5qc3v9k4ym8rwbn2tqe6f7d").unwrap();
16907        assert_eq!(a.brain, "acme");
16908        assert_eq!(
16909            a.target,
16910            Some(AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d".to_string()))
16911        );
16912    }
16913
16914    #[test]
16915    fn address_md_path_target_parses_as_path() {
16916        let a = Address::parse("@acme/records/clients/lumio.md").unwrap();
16917        assert_eq!(
16918            a.target,
16919            Some(AddressTarget::Path("records/clients/lumio.md".to_string()))
16920        );
16921    }
16922
16923    #[test]
16924    fn address_rejects_malformed_forms() {
16925        for raw in [
16926            "",
16927            "@",
16928            "@/x",
16929            "@acme/",
16930            "@acme/../etc/passwd",
16931            "@acme/records/.hidden.md",
16932            "@ACME",             // uppercase is not a hub ref shape
16933            "@acme/notes/x.txt", // target is neither ULID nor .md path
16934            "@a b",              // whitespace
16935        ] {
16936            assert!(Address::parse(raw).is_err(), "should reject {raw:?}");
16937        }
16938    }
16939
16940    // ── Path safety ─────────────────────────────────────────────────────────
16941
16942    #[test]
16943    fn safe_paths_accept_store_shapes_and_reject_escapes() {
16944        for ok in [
16945            "DB.md",
16946            "assets.jsonl",
16947            "records/clients/lumio.md",
16948            "sources/emails/2026/07/x.md",
16949        ] {
16950            assert!(safe_store_rel_path(ok), "should accept {ok:?}");
16951        }
16952        for bad in [
16953            "",
16954            "/etc/passwd",
16955            "../up.md",
16956            "records/../../up.md",
16957            "records//x.md",
16958            ".dbmd/config",
16959            "records/.hidden/x.md",
16960            "records/a b.md",
16961            "records\\win.md",
16962        ] {
16963            assert!(!safe_store_rel_path(bad), "should reject {bad:?}");
16964        }
16965    }
16966
16967    #[cfg(unix)]
16968    #[test]
16969    fn opened_destination_capability_survives_an_ancestor_path_swap() {
16970        use std::os::unix::fs::symlink;
16971
16972        let work = tempfile::tempdir().unwrap();
16973        let outside = tempfile::tempdir().unwrap();
16974        let original = work.path().join("destination");
16975        let moved = work.path().join("destination-moved");
16976        let directory = open_or_create_dir_nofollow(&original).unwrap();
16977
16978        std::fs::rename(&original, &moved).unwrap();
16979        symlink(outside.path(), &original).unwrap();
16980        write_pull_entries_beneath_dir(
16981            &directory,
16982            &[("records/note.md".to_string(), b"held inode".to_vec())],
16983        )
16984        .unwrap();
16985
16986        assert_eq!(
16987            std::fs::read(moved.join("records/note.md")).unwrap(),
16988            b"held inode"
16989        );
16990        assert!(!outside.path().join("records/note.md").exists());
16991    }
16992
16993    // ── Config resolution (flag + file precedence; env is covered by the CLI
16994    //    integration tests, where a child process isolates it) ───────────────
16995
16996    #[test]
16997    fn hub_config_flag_beats_file_and_requires_some_source() {
16998        let dir = tempfile::tempdir().unwrap();
16999        std::fs::create_dir_all(dir.path().join(".dbmd")).unwrap();
17000        std::fs::write(
17001            dir.path().join(CONFIG_REL_PATH),
17002            "# toolkit state\nhub = https://file.example.com\nunknown = ignored\n",
17003        )
17004        .unwrap();
17005
17006        let from_flag = hub_config(Some("https://flag.example.com/"), dir.path()).unwrap();
17007        assert_eq!(from_flag.hub, "https://flag.example.com");
17008
17009        let from_file = hub_config(None, dir.path()).unwrap();
17010        assert_eq!(from_file.hub, "https://file.example.com");
17011
17012        let none = hub_config(None, tempfile::tempdir().unwrap().path());
17013        assert!(matches!(none, Err(LinkError::NoHub)));
17014    }
17015
17016    #[test]
17017    fn https_guard_allows_loopback_only_for_plain_http() {
17018        assert!(assert_safe_hub("https://hub.example.com").is_ok());
17019        assert!(assert_safe_hub("http://localhost:3000").is_ok());
17020        assert!(assert_safe_hub("http://127.0.0.1:3000").is_ok());
17021        assert!(assert_safe_hub("http://[::1]:3000").is_ok());
17022        assert!(matches!(
17023            assert_safe_hub("http://hub.example.com"),
17024            Err(LinkError::UnsafeHub { .. })
17025        ));
17026        assert!(matches!(
17027            assert_safe_hub("hub.example.com"),
17028            Err(LinkError::UnsafeHub { .. })
17029        ));
17030        assert!(matches!(
17031            assert_safe_hub("http://localhost:80@127.0.0.1:1"),
17032            Err(LinkError::UnsafeHub { .. })
17033        ));
17034        assert!(matches!(
17035            assert_safe_hub("https://hub.example.com@attacker.example"),
17036            Err(LinkError::UnsafeHub { .. })
17037        ));
17038        assert!(matches!(
17039            assert_safe_hub("https://hub.example.com/base"),
17040            Err(LinkError::UnsafeHub { .. })
17041        ));
17042    }
17043
17044    #[test]
17045    fn registry_ssrf_classifier_rejects_local_private_and_documentation_ranges() {
17046        for blocked in [
17047            "127.0.0.1",
17048            "10.0.0.1",
17049            "100.64.0.1",
17050            "169.254.169.254",
17051            "172.16.0.1",
17052            "192.168.0.1",
17053            "192.88.99.1",
17054            "198.18.0.1",
17055            "203.0.113.1",
17056            "::1",
17057            "fe80::1",
17058            "fd00::1",
17059            "2001:db8::1",
17060            "2001:1::1",
17061            "2002:7f00:1::",
17062            "3fff::1",
17063        ] {
17064            assert!(
17065                !is_public_registry_ip(blocked.parse().unwrap()),
17066                "must block {blocked}"
17067            );
17068        }
17069        assert!(is_public_registry_ip("1.1.1.1".parse().unwrap()));
17070        assert!(is_public_registry_ip(
17071            "2606:4700:4700::1111".parse().unwrap()
17072        ));
17073        assert!(is_public_registry_ip("3fff:1000::1".parse().unwrap()));
17074    }
17075
17076    #[test]
17077    fn registry_dns_answer_is_pinned_and_cannot_rebind_or_change_authority() {
17078        use ureq::Resolver as _;
17079
17080        let pinned: std::net::SocketAddr = "1.1.1.1:443".parse().unwrap();
17081        let resolver = PinnedRegistryResolver {
17082            netloc: "home.example:443".to_string(),
17083            addresses: vec![pinned],
17084        };
17085        assert_eq!(resolver.resolve("home.example:443").unwrap(), vec![pinned]);
17086        assert!(resolver.resolve("127.0.0.1:443").is_err());
17087        assert_eq!(
17088            resolver.resolve("home.example:443").unwrap(),
17089            vec![pinned],
17090            "subsequent connects reuse the validated answer instead of DNS"
17091        );
17092    }
17093
17094    #[test]
17095    fn production_object_urls_and_store_selected_hubs_cannot_reach_private_ips() {
17096        let cfg = HubConfig {
17097            hub: "https://hub.example".to_string(),
17098            key: None,
17099            agent_key: None,
17100            brain_key: None,
17101            state_dir: tempfile::tempdir().unwrap().keep(),
17102            store_selected: false,
17103        };
17104        assert!(
17105            presigned_agent(&cfg, "https://127.0.0.1/private").is_err(),
17106            "a production hub must not turn its presigned URL into an SSRF primitive"
17107        );
17108
17109        let store_selected = HubConfig {
17110            hub: "https://127.0.0.1".to_string(),
17111            store_selected: true,
17112            ..cfg
17113        };
17114        assert!(
17115            hub_agent(&store_selected).is_err(),
17116            "bytes in a cloned store must not select a private-network hub"
17117        );
17118    }
17119
17120    #[test]
17121    fn presigned_download_ceiling_cannot_overflow_at_u64_max() {
17122        assert_eq!(
17123            one_past_bounded_limit(MAX_PACK_BYTES),
17124            Some(MAX_PACK_BYTES + 1),
17125            "the presigned reader consumes exactly one refusal byte beyond its fixed pack cap"
17126        );
17127        assert_eq!(
17128            presigned_download_read_limit(),
17129            MAX_PACK_BYTES + 1,
17130            "the presigned reader is capped by the client constant, not a hub response"
17131        );
17132        assert_eq!(
17133            one_past_bounded_limit(u64::MAX),
17134            None,
17135            "the former attacker-controlled u64::MAX + 1 shape must fail without overflow"
17136        );
17137    }
17138
17139    #[test]
17140    fn https_guard_matches_the_scheme_case_insensitively() {
17141        // RFC 3986 schemes are case-insensitive: an uppercase-scheme HTTPS
17142        // hub is still HTTPS, never a misleading non-HTTPS refusal.
17143        assert!(assert_safe_hub("HTTPS://hub.example.com").is_ok());
17144        assert!(assert_safe_hub("Https://hub.example.com").is_ok());
17145        // And an uppercase plain-HTTP hub is still refused outside loopback.
17146        assert!(matches!(
17147            assert_safe_hub("HTTP://hub.example.com"),
17148            Err(LinkError::UnsafeHub { .. })
17149        ));
17150    }
17151
17152    #[test]
17153    fn clean_key_refuses_paste_artifacts_without_echoing() {
17154        assert_eq!(clean_key("  vc_account_abc  ").unwrap(), "vc_account_abc");
17155        for bad in ["vc account", "vc\naccount", "ключ", ""] {
17156            let err = clean_key(bad).unwrap_err();
17157            assert!(matches!(err, LinkError::BadKey));
17158            assert!(
17159                !err.to_string().contains(bad.trim()) || bad.trim().is_empty(),
17160                "error must not echo the key"
17161            );
17162        }
17163    }
17164
17165    // ── Verb entry gates: refs must never reshape the request path ──────────
17166
17167    /// A config whose hub passes the loopback guard but is never listened on:
17168    /// every refusal below must come from the entry gate BEFORE a request
17169    /// exists — a dial on this dead port would surface `Transport` instead.
17170    fn dead_hub() -> HubConfig {
17171        HubConfig {
17172            hub: "http://127.0.0.1:9".to_string(),
17173            key: Some("k".to_string()),
17174            agent_key: None,
17175            brain_key: None,
17176            state_dir: PathBuf::from("."),
17177            store_selected: false,
17178        }
17179    }
17180
17181    #[test]
17182    fn request_retries_a_connection_failure_before_sending() {
17183        use std::io::{Read as _, Write as _};
17184        use std::net::TcpListener;
17185        use std::thread;
17186        use std::time::Duration;
17187
17188        let probe = TcpListener::bind("127.0.0.1:0").unwrap();
17189        let address = probe.local_addr().unwrap();
17190        drop(probe);
17191        let server = thread::spawn(move || {
17192            thread::sleep(Duration::from_millis(40));
17193            let listener = TcpListener::bind(address).unwrap();
17194            let (mut stream, _) = listener.accept().unwrap();
17195            let mut request_bytes = [0_u8; 1024];
17196            let _ = stream.read(&mut request_bytes).unwrap();
17197            stream
17198                .write_all(
17199                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
17200                )
17201                .unwrap();
17202        });
17203        let cfg = HubConfig {
17204            hub: format!("http://{address}"),
17205            key: None,
17206            agent_key: None,
17207            brain_key: None,
17208            state_dir: tempfile::tempdir().unwrap().keep(),
17209            store_selected: false,
17210        };
17211
17212        let response = request(&cfg, "GET", "/retry", None, Auth::None).unwrap();
17213        assert_eq!(response.status, 200);
17214        assert_eq!(response.body, Some(json!({ "ok": true })));
17215        server.join().unwrap();
17216    }
17217
17218    #[test]
17219    fn a_commit_goes_back_for_a_receipt_it_lost() {
17220        use std::io::{Read as _, Write as _};
17221        use std::net::TcpListener;
17222        use std::thread;
17223
17224        // The mutation coordinate is derived from the observed base and the
17225        // exact operation set so a repeat is the SAME effect. A commit that
17226        // lands durably and then loses its connection must therefore be asked
17227        // after, not reported as a failure — which is what happened to a
17228        // 32,338-file store that had already committed.
17229        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17230        let address = listener.local_addr().unwrap();
17231        let server = thread::spawn(move || {
17232            // First attempt: promise a body, hang up mid-way.
17233            let (mut first, _) = listener.accept().unwrap();
17234            let mut bytes = [0_u8; 4096];
17235            let _ = first.read(&mut bytes).unwrap();
17236            first
17237                .write_all(
17238                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 90\r\nConnection: close\r\n\r\n{\"v\":2",
17239                )
17240                .unwrap();
17241            drop(first);
17242            // Second attempt: the receipt for the commit that already landed.
17243            let (mut second, _) = listener.accept().unwrap();
17244            let _ = second.read(&mut bytes).unwrap();
17245            second
17246                .write_all(
17247                    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\"}",
17248                )
17249                .unwrap();
17250        });
17251        let cfg = HubConfig {
17252            hub: format!("http://{address}"),
17253            key: Some("k".to_string()),
17254            agent_key: None,
17255            brain_key: None,
17256            state_dir: tempfile::tempdir().unwrap().keep(),
17257            store_selected: false,
17258        };
17259
17260        let response = request_patient(
17261            &cfg,
17262            "POST",
17263            "/api/hub/brains/b/v2/commits",
17264            Some(&json!({ "mutation_id": "dbmd-1" })),
17265            Auth::Required,
17266        )
17267        .expect("the receipt is collected on the second ask");
17268        assert_eq!(response.status, 200);
17269        assert_eq!(
17270            response
17271                .body
17272                .as_ref()
17273                .and_then(|value| value.get("outcome"))
17274                .and_then(Value::as_str),
17275            Some("converged"),
17276            "an already-applied mutation answers with its receipt"
17277        );
17278        server.join().unwrap();
17279    }
17280
17281    #[test]
17282    fn a_body_that_dies_mid_stream_is_a_transport_failure() {
17283        use std::io::{Read as _, Write as _};
17284        use std::net::TcpListener;
17285        use std::thread;
17286
17287        // A connection that fails while the body is arriving is the same class
17288        // of problem as one that fails before it arrives at all. Typed as a
17289        // bare io error it matched no retry path and named no peer, and one
17290        // stalled body ended a whole migration reporting only "timed out
17291        // reading response".
17292        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17293        let address = listener.local_addr().unwrap();
17294        let server = thread::spawn(move || {
17295            let (mut stream, _) = listener.accept().unwrap();
17296            let mut request_bytes = [0_u8; 1024];
17297            let _ = stream.read(&mut request_bytes).unwrap();
17298            // Promise a hundred bytes, send ten, hang up.
17299            stream
17300                .write_all(
17301                    b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\nConnection: close\r\n\r\n{\"ok\":true",
17302                )
17303                .unwrap();
17304        });
17305        let cfg = HubConfig {
17306            hub: format!("http://{address}"),
17307            key: None,
17308            agent_key: None,
17309            brain_key: None,
17310            state_dir: tempfile::tempdir().unwrap().keep(),
17311            store_selected: false,
17312        };
17313
17314        let error = request(&cfg, "GET", "/truncated", None, Auth::None)
17315            .expect_err("a truncated body must not read as success");
17316        match error {
17317            LinkError::Transport { hub, .. } => {
17318                assert!(hub.contains(&address.to_string()), "names its peer: {hub}");
17319            }
17320            other => panic!("expected a transport failure, got {other:?}"),
17321        }
17322        server.join().unwrap();
17323    }
17324
17325    #[test]
17326    fn object_store_transport_errors_never_render_presigned_urls() {
17327        use std::net::TcpListener;
17328
17329        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17330        let address = listener.local_addr().unwrap();
17331        drop(listener);
17332        let signature = "do-not-render-this-presigned-signature";
17333        let raw =
17334            format!("http://{address}/blob?X-Amz-Credential=temporary&X-Amz-Signature={signature}");
17335        let error = ureq::get(&raw)
17336            .timeout(std::time::Duration::from_millis(250))
17337            .call()
17338            .expect_err("the closed local port must fail");
17339        let ureq::Error::Transport(transport) = error else {
17340            panic!("expected a transport failure");
17341        };
17342
17343        let rendered = object_store_transport_error(transport).to_string();
17344        assert!(rendered.contains("the object store"));
17345        assert!(rendered.contains("network error"));
17346        assert!(!rendered.contains(&raw));
17347        assert!(!rendered.contains(signature));
17348        assert!(!rendered.contains("X-Amz-"));
17349    }
17350
17351    #[test]
17352    fn endpoint_cap_refuses_a_body_before_json_parsing() {
17353        let (hub, server) = scripted_json_hub(vec![(200, "x".repeat(2_048))]);
17354        let cfg = HubConfig {
17355            hub,
17356            key: None,
17357            agent_key: None,
17358            brain_key: None,
17359            state_dir: tempfile::tempdir().unwrap().keep(),
17360            store_selected: false,
17361        };
17362
17363        assert!(matches!(
17364            request_capped(&cfg, "GET", "/bounded", None, Auth::None, 1_024),
17365            Err(LinkError::ResponseTooLarge { .. })
17366        ));
17367        server.join().unwrap();
17368    }
17369
17370    #[test]
17371    fn overall_deadline_stops_a_dribbled_response_body() {
17372        use std::io::{Read as _, Write as _};
17373        use std::net::TcpListener;
17374        use std::time::{Duration, Instant};
17375
17376        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17377        let url = format!("http://{}/dribble", listener.local_addr().unwrap());
17378        let server = std::thread::spawn(move || {
17379            let (mut stream, _) = listener.accept().unwrap();
17380            let mut request = [0_u8; 1024];
17381            let _ = stream.read(&mut request);
17382            stream
17383                .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 32\r\nConnection: close\r\n\r\n")
17384                .unwrap();
17385            for byte in [b'x'; 32] {
17386                if stream.write_all(&[byte]).is_err() {
17387                    break;
17388                }
17389                std::thread::sleep(Duration::from_millis(40));
17390            }
17391        });
17392        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17393        let started = Instant::now();
17394        let response = http.get(&url).call().unwrap();
17395        let mut body = Vec::new();
17396        let error = response
17397            .into_reader()
17398            .read_to_end(&mut body)
17399            .expect_err("per-read progress must not reset the overall deadline");
17400        assert!(
17401            started.elapsed() < Duration::from_millis(700),
17402            "dribbled body exceeded the wall-clock budget: {error}"
17403        );
17404        server.join().unwrap();
17405    }
17406
17407    #[test]
17408    fn overall_deadline_stops_a_stalled_upload() {
17409        use std::net::TcpListener;
17410        use std::time::{Duration, Instant};
17411
17412        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17413        let url = format!("http://{}/upload", listener.local_addr().unwrap());
17414        let server = std::thread::spawn(move || {
17415            let (_stream, _) = listener.accept().unwrap();
17416            // Never consume the request body. Once the kernel send buffer fills,
17417            // the client must leave on its absolute write deadline.
17418            std::thread::sleep(Duration::from_millis(600));
17419        });
17420        let http = agent_builder_with_timeout(Duration::from_millis(150)).build();
17421        let body = vec![0x5a; 32 * 1024 * 1024];
17422        let started = Instant::now();
17423        let error = http
17424            .put(&url)
17425            .send_bytes(&body)
17426            .expect_err("stalled request-body writes must time out");
17427        assert!(
17428            started.elapsed() < Duration::from_millis(700),
17429            "stalled upload exceeded the wall-clock budget: {error}"
17430        );
17431        server.join().unwrap();
17432    }
17433
17434    #[test]
17435    fn presigned_source_retries_share_one_upload_deadline() {
17436        use std::net::TcpListener;
17437        use std::time::{Duration, Instant};
17438
17439        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
17440        let address = listener.local_addr().unwrap();
17441        let signature = "do-not-render-this-stalled-upload-signature";
17442        let url = format!(
17443            "http://{address}/upload?X-Amz-Credential=temporary&X-Amz-Signature={signature}"
17444        );
17445        let server = std::thread::spawn(move || {
17446            let (_stream, _) = listener.accept().unwrap();
17447            // Keep the socket open without consuming the request. The first
17448            // attempt must spend the complete shared budget; retries may not
17449            // each restart that budget.
17450            std::thread::sleep(Duration::from_millis(600));
17451        });
17452
17453        let directory = tempfile::tempdir().unwrap();
17454        std::fs::write(directory.path().join("DB.md"), "---\ntype: db-md\n---\n").unwrap();
17455        std::fs::create_dir(directory.path().join("records")).unwrap();
17456        let relative = "records/stalled.bin";
17457        let bytes = vec![0x5a; 32 * 1024 * 1024];
17458        std::fs::write(directory.path().join(relative), &bytes).unwrap();
17459        let store = Store::open_strict(directory.path()).unwrap();
17460        let cfg = HubConfig {
17461            hub: format!("http://{address}"),
17462            key: None,
17463            agent_key: None,
17464            brain_key: None,
17465            state_dir: tempfile::tempdir().unwrap().keep(),
17466            store_selected: false,
17467        };
17468        let source = V2UploadSource {
17469            path: relative.to_string(),
17470            bytes: bytes.len() as u64,
17471        };
17472
17473        let started = Instant::now();
17474        let error = put_presigned_source_with_budget(
17475            &cfg,
17476            &url,
17477            &json!({ "content-length": source.bytes.to_string() }),
17478            &store,
17479            &source,
17480            None,
17481            Duration::from_millis(150),
17482        )
17483        .expect_err("a black-holed upload must leave at its shared deadline");
17484        assert!(
17485            started.elapsed() < Duration::from_millis(700),
17486            "presigned retries exceeded their shared budget: {error}"
17487        );
17488        let rendered = error.to_string();
17489        assert!(rendered.contains("the object store"));
17490        assert!(!rendered.contains(&url));
17491        assert!(!rendered.contains(signature));
17492        server.join().unwrap();
17493    }
17494
17495    #[test]
17496    fn verb_entry_gates_accept_the_hub_ref_shapes() {
17497        for ok in ["acme-ops", "a", "01j5qc3v9k4ym8rwbn2tqe6f7d"] {
17498            assert!(require_safe_ref(ok).is_ok(), "brain ref {ok:?}");
17499            assert!(require_valid_handle(ok).is_ok(), "handle {ok:?}");
17500            assert!(require_safe_grant_id(ok).is_ok(), "grant id {ok:?}");
17501        }
17502    }
17503
17504    #[test]
17505    fn raw_ref_verbs_refuse_url_reshaping_brain_refs_before_any_request() {
17506        let cfg = dead_hub();
17507        for bad in ["../up", "a/b", "a?x=1", "a#frag", "a%2e%2e", "A", "a b", ""] {
17508            assert!(
17509                matches!(
17510                    sync_pull(&cfg, bad, None),
17511                    Err(LinkError::BadAddress { .. })
17512                ),
17513                "sync_pull must refuse {bad:?}"
17514            );
17515            assert!(
17516                matches!(sync_push(&cfg, bad, &[]), Err(LinkError::BadAddress { .. })),
17517                "sync_push must refuse {bad:?}"
17518            );
17519            assert!(
17520                matches!(
17521                    grant_issue(&cfg, bad, "maya@example.com", Capability::Read, None, None),
17522                    Err(LinkError::BadAddress { .. })
17523                ),
17524                "grant_issue must refuse {bad:?}"
17525            );
17526            assert!(
17527                matches!(grant_list(&cfg, bad), Err(LinkError::BadAddress { .. })),
17528                "grant_list must refuse {bad:?}"
17529            );
17530            assert!(
17531                matches!(
17532                    grant_revoke(&cfg, bad, "01j5qc3v9k4ym8rwbn2tqe6f7f"),
17533                    Err(LinkError::BadAddress { .. })
17534                ),
17535                "grant_revoke must refuse brain {bad:?}"
17536            );
17537            assert!(
17538                matches!(head(&cfg, bad), Err(LinkError::BadAddress { .. })),
17539                "head must refuse {bad:?}"
17540            );
17541        }
17542    }
17543
17544    #[test]
17545    fn grant_revoke_refuses_url_reshaping_grant_ids() {
17546        let cfg = dead_hub();
17547        for bad in ["../01j", "a/b", "id?x=1", "id#frag", "ID", ""] {
17548            assert!(
17549                matches!(
17550                    grant_revoke(&cfg, "acme", bad),
17551                    Err(LinkError::BadGrantId { .. })
17552                ),
17553                "grant_revoke must refuse grant id {bad:?}"
17554            );
17555        }
17556    }
17557
17558    #[test]
17559    fn propose_refuses_url_reshaping_handles_and_oversize_bodies_before_upload() {
17560        let cfg = dead_hub();
17561        for bad in ["../up", "a/b", "a?x=1", "a#frag", "A", ""] {
17562            assert!(
17563                matches!(
17564                    propose(&cfg, bad, "intake", "hi"),
17565                    Err(LinkError::BadAddress { .. })
17566                ),
17567                "propose must refuse handle {bad:?}"
17568            );
17569        }
17570        let oversize = "a".repeat(MAX_PROPOSE_BYTES as usize + 1);
17571        assert!(matches!(
17572            propose(&cfg, "acme-site", "intake", &oversize),
17573            Err(LinkError::ProposeTooLarge { .. })
17574        ));
17575        // A clean handle + in-cap body passes both gates: the failure is now
17576        // the (dead) wire, proving the gates refuse shape, not the verb.
17577        assert!(matches!(
17578            propose(&cfg, "acme-site", "intake", "hi"),
17579            Err(LinkError::Transport { .. })
17580        ));
17581    }
17582
17583    #[test]
17584    fn resolve_refuses_a_hand_built_unsafe_address() {
17585        let cfg = dead_hub();
17586        for brain in ["../up", "a/b", "a?x", "a#f"] {
17587            let addr = Address {
17588                brain: brain.to_string(),
17589                target: None,
17590            };
17591            assert!(
17592                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
17593                "resolve must refuse brain {brain:?}"
17594            );
17595        }
17596        for target in [
17597            AddressTarget::Id("01j5qc3v9k4ym8rwbn2tqe6f7d?id=other".to_string()),
17598            AddressTarget::Id("01J5QC3V9K4YM8RWBN2TQE6F7D".to_string()), // not the minted shape
17599            AddressTarget::Path("../up.md".to_string()),
17600            AddressTarget::Path("records/x.md#frag".to_string()),
17601        ] {
17602            let addr = Address {
17603                brain: "acme".to_string(),
17604                target: Some(target.clone()),
17605            };
17606            assert!(
17607                matches!(resolve(&cfg, &addr), Err(LinkError::BadAddress { .. })),
17608                "resolve must refuse target {target:?}"
17609            );
17610        }
17611    }
17612
17613    #[test]
17614    fn v2_final_barrier_refuses_to_advance_a_remote_ahead_checkout() {
17615        let mut local = std::collections::BTreeMap::new();
17616        local.insert("records/a.md".to_string(), ("a".repeat(64), 0));
17617        local.insert("records/b.md".to_string(), ("b".repeat(64), 0));
17618        let mut remote = std::collections::BTreeMap::new();
17619        remote.insert(
17620            "records/a.md".to_string(),
17621            V2BaselineFile {
17622                sha256: "c".repeat(64),
17623                bytes: 1,
17624                proof: None,
17625            },
17626        );
17627        remote.insert(
17628            "records/b.md".to_string(),
17629            V2BaselineFile {
17630                sha256: "b".repeat(64),
17631                bytes: 1,
17632                proof: None,
17633            },
17634        );
17635        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
17636    }
17637
17638    #[test]
17639    fn v2_final_barrier_ignores_only_explicit_kept_home_paths() {
17640        let local = std::collections::BTreeMap::new();
17641        let mut remote = std::collections::BTreeMap::new();
17642        remote.insert(
17643            "private/local.md".to_string(),
17644            V2BaselineFile {
17645                sha256: "d".repeat(64),
17646                bytes: 1,
17647                proof: None,
17648            },
17649        );
17650        assert!(v2_riding_matches_remote(&local, &remote, |path| path == "private/local.md"));
17651        assert!(!v2_riding_matches_remote(&local, &remote, |_| false));
17652    }
17653
17654    fn scoped_test_head(revision: &str) -> V2VerifiedHead {
17655        V2VerifiedHead {
17656            requested: TEST_BRAIN_ID.to_string(),
17657            brain_id: TEST_BRAIN_ID.to_string(),
17658            view_kind: "scoped".to_string(),
17659            view_revision: revision.to_string(),
17660            control_revision: revision.to_string(),
17661            identity: V2HeadIdentity {
17662                custody: "hub".to_string(),
17663                fingerprint: "test".to_string(),
17664                public_key_spki: "test".to_string(),
17665                previous: Vec::new(),
17666                rotations: Vec::new(),
17667            },
17668            pointer: None,
17669            trust: TrustState {
17670                v: 2,
17671                origin: "https://hub.example".to_string(),
17672                requested: TEST_BRAIN_ID.to_string(),
17673                brain: TEST_BRAIN_ID.to_string(),
17674                home: None,
17675                anchor: "ed25519:test".to_string(),
17676                current: "ed25519:test".to_string(),
17677                head_seq: 0,
17678                feed_hash: None,
17679                rotations: Vec::new(),
17680                hub_signer: None,
17681                protocol_profile: Some("link-v2".to_string()),
17682            },
17683            alias: None,
17684        }
17685    }
17686
17687    #[test]
17688    fn accepted_v2_checkpoint_never_downgrades_after_profile_migration() {
17689        let mut trust = scoped_test_head(&"a".repeat(64)).trust;
17690        assert!(accepted_as_v2(&trust));
17691
17692        trust.protocol_profile = None;
17693        trust.hub_signer = Some("ed25519:hub".to_string());
17694        assert!(accepted_as_v2(&trust));
17695
17696        trust.hub_signer = None;
17697        assert!(!accepted_as_v2(&trust));
17698    }
17699
17700    fn scoped_test_baseline(revision: &str) -> V2SyncBaseline {
17701        V2SyncBaseline {
17702            v: 2,
17703            origin: "https://hub.example".to_string(),
17704            brain: TEST_BRAIN_ID.to_string(),
17705            checkout_id: Some("c".repeat(64)),
17706            head_seq: Some(0),
17707            commit_hash: None,
17708            content_root: None,
17709            asset_root: None,
17710            assets: std::collections::BTreeMap::new(),
17711            view_kind: Some("scoped".to_string()),
17712            view_revision: Some(revision.to_string()),
17713            projection_sha256: Some(scoped_projection_sha256(TEST_BRAIN_ID)),
17714            files: std::collections::BTreeMap::new(),
17715            local_policy_digest: None,
17716            local_eligibility: std::collections::BTreeMap::new(),
17717            remote_copy_remains: std::collections::BTreeMap::new(),
17718        }
17719    }
17720
17721    #[test]
17722    fn v2_sync_baseline_keeps_asset_and_markdown_size_bounds_distinct() {
17723        let cfg = test_hub_config(
17724            "https://hub.example".to_string(),
17725            tempfile::tempdir().unwrap().keep(),
17726        );
17727        let mut baseline = scoped_test_baseline(&"a".repeat(64));
17728        baseline.assets.insert(
17729            "assets/archive.bin".to_string(),
17730            V2BaselineAsset {
17731                blob_sha256: "b".repeat(64),
17732                bytes: MAX_STORE_BYTES + 1,
17733                media_type: "application/octet-stream".to_string(),
17734                wrappers: vec!["records/archive.md".to_string()],
17735                required: true,
17736                disposition: "hosted".to_string(),
17737                leaf_hash: "c".repeat(64),
17738            },
17739        );
17740
17741        let accepted = serde_json::to_vec(&baseline).unwrap();
17742        assert!(parse_v2_baseline(&cfg, TEST_BRAIN_ID, &accepted).is_ok());
17743
17744        baseline.assets.get_mut("assets/archive.bin").unwrap().bytes = MAX_ASSET_BYTES + 1;
17745        let refused = serde_json::to_vec(&baseline).unwrap();
17746        assert!(matches!(
17747            parse_v2_baseline(&cfg, TEST_BRAIN_ID, &refused),
17748            Err(LinkError::InvalidFeed { .. })
17749        ));
17750    }
17751
17752    #[test]
17753    fn scoped_projection_is_a_valid_local_store_marker_but_never_rides() {
17754        let directory = tempfile::tempdir().unwrap();
17755        std::fs::write(
17756            directory.path().join("DB.md"),
17757            scoped_projection_bytes(TEST_BRAIN_ID),
17758        )
17759        .unwrap();
17760        let store = Store::open_strict(directory.path()).unwrap();
17761        let head = scoped_test_head(&"a".repeat(64));
17762        let baseline = scoped_test_baseline(&"a".repeat(64));
17763        let mut view = v2_local_files(&store).unwrap();
17764        remove_scoped_projection(&head, Some(&baseline), &mut view).unwrap();
17765        assert!(!view.riding.contains_key("DB.md"));
17766        assert!(!view.eligibility.contains_key("DB.md"));
17767    }
17768
17769    #[test]
17770    fn scoped_push_accepts_a_pull_verified_handoff_without_double_checking_projection() {
17771        let directory = tempfile::tempdir().unwrap();
17772        std::fs::write(
17773            directory.path().join("DB.md"),
17774            scoped_projection_bytes(TEST_BRAIN_ID),
17775        )
17776        .unwrap();
17777        let store = Store::open_strict(directory.path()).unwrap();
17778        let head = scoped_test_head(&"a".repeat(64));
17779        let baseline = scoped_test_baseline(&"a".repeat(64));
17780
17781        let mut carried = v2_local_files(&store).unwrap();
17782        remove_scoped_projection(&head, Some(&baseline), &mut carried).unwrap();
17783        let handed_off =
17784            local_view_for_v2_push(&store, &head, Some(&baseline), Some(carried)).unwrap();
17785        assert!(!handed_off.riding.contains_key("DB.md"));
17786
17787        let freshly_scanned = local_view_for_v2_push(&store, &head, Some(&baseline), None).unwrap();
17788        assert!(!freshly_scanned.riding.contains_key("DB.md"));
17789
17790        std::fs::write(
17791            directory.path().join("DB.md"),
17792            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
17793        )
17794        .unwrap();
17795        let tampered = Store::open_strict(directory.path()).unwrap();
17796        assert!(matches!(
17797            local_view_for_v2_push(&tampered, &head, Some(&baseline), None),
17798            Err(LinkError::ScopedProjectionModified)
17799        ));
17800    }
17801
17802    #[test]
17803    fn v2_local_view_reports_only_exact_linked_kept_home_markdown() {
17804        let directory = tempfile::tempdir().unwrap();
17805        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
17806        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
17807        std::fs::write(
17808            directory.path().join("DB.md"),
17809            b"---\nname: Kept home test\n---\n",
17810        )
17811        .unwrap();
17812        std::fs::write(
17813            directory.path().join("records/notes/a.md"),
17814            b"---\ntype: note\n---\nSee [[sources/private/secret]].\n",
17815        )
17816        .unwrap();
17817        std::fs::write(
17818            directory.path().join("sources/private/secret.md"),
17819            b"---\ntype: note\n---\nlocal only\n",
17820        )
17821        .unwrap();
17822        std::fs::write(
17823            directory.path().join("sources/private/unlinked.md"),
17824            b"---\ntype: note\n---\nnot disclosed\n",
17825        )
17826        .unwrap();
17827        std::fs::write(
17828            directory.path().join(".sevralocal"),
17829            b"sources/private/**\n",
17830        )
17831        .unwrap();
17832
17833        let store = Store::open_strict(directory.path()).unwrap();
17834        let view = v2_local_files(&store).unwrap();
17835        assert!(!view.riding.contains_key("sources/private/secret.md"));
17836        assert_eq!(
17837            view.withheld_links,
17838            vec![V2WithheldLink {
17839                source: "records/notes/a.md".to_string(),
17840                target: "sources/private/secret.md".to_string(),
17841            }]
17842        );
17843    }
17844
17845    #[test]
17846    fn a_kept_home_target_is_withheld_even_when_this_machine_lacks_it() {
17847        // An export omits kept-home content by construction, so a store
17848        // restored from one links to names it does not carry. Without this the
17849        // owner's own export could never be pushed back — the links would read
17850        // as broken and the commit would be refused.
17851        let directory = tempfile::tempdir().unwrap();
17852        std::fs::create_dir_all(directory.path().join("records/notes")).unwrap();
17853        std::fs::write(
17854            directory.path().join("DB.md"),
17855            b"---\nname: Restored export\n---\n",
17856        )
17857        .unwrap();
17858        std::fs::write(
17859            directory.path().join("records/notes/a.md"),
17860            b"---\ntype: note\n---\nSee [[sources/private/absent]].\n",
17861        )
17862        .unwrap();
17863        std::fs::write(
17864            directory.path().join(".sevralocal"),
17865            b"sources/private/**\n",
17866        )
17867        .unwrap();
17868
17869        let store = Store::open_strict(directory.path()).unwrap();
17870        let view = v2_local_files(&store).unwrap();
17871        assert_eq!(
17872            view.withheld_links,
17873            vec![V2WithheldLink {
17874                source: "records/notes/a.md".to_string(),
17875                target: "sources/private/absent.md".to_string(),
17876            }]
17877        );
17878        // A dangling link the owner never claimed is still dangling.
17879        std::fs::write(
17880            directory.path().join("records/notes/b.md"),
17881            b"---\ntype: note\n---\nSee [[records/notes/nowhere]].\n",
17882        )
17883        .unwrap();
17884        let store = Store::open_strict(directory.path()).unwrap();
17885        let view = v2_local_files(&store).unwrap();
17886        assert!(
17887            !view
17888                .withheld_links
17889                .iter()
17890                .any(|link| link.target == "records/notes/nowhere.md"),
17891            "an unclaimed dangling target must not be declared withheld"
17892        );
17893    }
17894
17895    #[test]
17896    fn explicit_content_withdrawal_requires_exact_current_kept_home_bytes() {
17897        let directory = tempfile::tempdir().unwrap();
17898        std::fs::create_dir_all(directory.path().join("sources/private")).unwrap();
17899        std::fs::write(
17900            directory.path().join("DB.md"),
17901            b"---\nname: Withdrawal test\n---\n",
17902        )
17903        .unwrap();
17904        let source = b"---\ntype: note\n---\nlocal evidence\n";
17905        std::fs::write(directory.path().join("sources/private/evidence.md"), source).unwrap();
17906        std::fs::write(
17907            directory.path().join(".sevralocal"),
17908            b"sources/private/**\n",
17909        )
17910        .unwrap();
17911        let store = Store::open_strict(directory.path()).unwrap();
17912        let view = v2_local_files(&store).unwrap();
17913        let mut remote = std::collections::BTreeMap::new();
17914        remote.insert(
17915            "sources/private/evidence.md".to_string(),
17916            V2BaselineFile {
17917                sha256: content_sha256(source),
17918                bytes: source.len() as u64,
17919                proof: None,
17920            },
17921        );
17922        assert_eq!(
17923            v2_content_withdrawal_operation(
17924                &store,
17925                &view,
17926                &remote,
17927                "sources/private/evidence.md",
17928                "approved retention change",
17929            )
17930            .unwrap(),
17931            json!({
17932                "op": "withdraw_from_hosting",
17933                "path": "sources/private/evidence.md",
17934                "expected": { "kind": "blob", "hash": content_sha256(source) },
17935                "reason": "approved retention change",
17936            })
17937        );
17938
17939        std::fs::write(
17940            directory.path().join("sources/private/evidence.md"),
17941            b"changed after review",
17942        )
17943        .unwrap();
17944        assert!(matches!(
17945            v2_content_withdrawal_operation(
17946                &store,
17947                &view,
17948                &remote,
17949                "sources/private/evidence.md",
17950                "approved retention change",
17951            ),
17952            Err(LinkError::InvalidPack { .. })
17953        ));
17954    }
17955
17956    #[test]
17957    fn explicit_asset_withdrawal_requires_the_exact_hosted_leaf_and_local_bytes() {
17958        let directory = tempfile::tempdir().unwrap();
17959        std::fs::create_dir_all(directory.path().join("sources/files")).unwrap();
17960        std::fs::write(
17961            directory.path().join("DB.md"),
17962            b"---\nname: Asset withdrawal test\n---\n",
17963        )
17964        .unwrap();
17965        let bytes = b"private binary";
17966        std::fs::write(directory.path().join("sources/files/private.pdf"), bytes).unwrap();
17967        std::fs::write(
17968            directory.path().join(".sevralocal"),
17969            b"sources/files/private.pdf\n",
17970        )
17971        .unwrap();
17972        let store = Store::open_strict(directory.path()).unwrap();
17973        let view = v2_local_files(&store).unwrap();
17974        let local = crate::AssetRecord {
17975            path: "sources/files/private.pdf".to_string(),
17976            sha256: content_sha256(bytes),
17977            bytes: bytes.len() as u64,
17978            media_type: "application/pdf".to_string(),
17979            wrappers: vec!["sources/files/private.md".to_string()],
17980            required: true,
17981        };
17982        let current = V2BaselineAsset {
17983            blob_sha256: local.sha256.clone(),
17984            bytes: local.bytes,
17985            media_type: local.media_type.clone(),
17986            wrappers: local.wrappers.clone(),
17987            required: local.required,
17988            disposition: "hosted".to_string(),
17989            leaf_hash: "d".repeat(64),
17990        };
17991        assert_eq!(
17992            v2_asset_withdrawal_operation(
17993                &store,
17994                &view,
17995                &local.path,
17996                &local,
17997                &current,
17998                "approved retention change",
17999            )
18000            .unwrap(),
18001            json!({
18002                "op": "asset_withdraw",
18003                "path": local.path,
18004                "expected": { "kind": "asset", "hash": "d".repeat(64) },
18005                "reason": "approved retention change",
18006            })
18007        );
18008
18009        let mut mismatched = current.clone();
18010        mismatched.required = false;
18011        assert!(matches!(
18012            v2_asset_withdrawal_operation(
18013                &store,
18014                &view,
18015                &local.path,
18016                &local,
18017                &mismatched,
18018                "approved retention change",
18019            ),
18020            Err(LinkError::InvalidPack { .. })
18021        ));
18022    }
18023
18024    #[test]
18025    fn checkout_pseudonym_is_random_then_stable_from_private_baseline() {
18026        let first = v2_checkout_id(None).unwrap();
18027        assert_eq!(first, v2_checkout_id(Some(&first)).unwrap());
18028        assert_ne!(first, v2_checkout_id(None).unwrap());
18029        assert!(is_sha256(&first));
18030    }
18031
18032    #[test]
18033    fn scoped_projection_edit_and_scope_transition_fail_closed() {
18034        let directory = tempfile::tempdir().unwrap();
18035        std::fs::write(
18036            directory.path().join("DB.md"),
18037            b"---\ntype: db-md\nscope: company\nowner: someone\n---\n",
18038        )
18039        .unwrap();
18040        let store = Store::open_strict(directory.path()).unwrap();
18041        let head = scoped_test_head(&"a".repeat(64));
18042        let baseline = scoped_test_baseline(&"a".repeat(64));
18043        let mut view = v2_local_files(&store).unwrap();
18044        assert!(matches!(
18045            remove_scoped_projection(&head, Some(&baseline), &mut view),
18046            Err(LinkError::ScopedProjectionModified)
18047        ));
18048
18049        let changed = scoped_test_head(&"b".repeat(64));
18050        assert!(matches!(
18051            ensure_v2_view_compatible(&changed, Some(&baseline)),
18052            Err(LinkError::ScopedViewChanged)
18053        ));
18054
18055        let mut same_view_new_control = head.clone();
18056        same_view_new_control.control_revision = "c".repeat(64);
18057        assert!(ensure_v2_view_compatible(&same_view_new_control, Some(&baseline)).is_ok());
18058        assert!(!same_v2_head(&head, &same_view_new_control));
18059    }
18060
18061    #[test]
18062    fn established_checkout_never_becomes_an_empty_clone_when_db_md_is_invalid() {
18063        let scoped = scoped_test_head(&"a".repeat(64));
18064        let scoped_baseline = scoped_test_baseline(&"a".repeat(64));
18065        assert!(matches!(
18066            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), false),
18067            Err(LinkError::ScopedProjectionModified)
18068        ));
18069
18070        let mut full = scoped.clone();
18071        full.view_kind = "full".to_string();
18072        let mut full_baseline = scoped_baseline.clone();
18073        full_baseline.view_kind = Some("full".to_string());
18074        full_baseline.projection_sha256 = None;
18075        assert!(matches!(
18076            ensure_established_v2_checkout_opened(&full, Some(&full_baseline), false),
18077            Err(LinkError::InvalidPack { .. })
18078        ));
18079
18080        assert!(ensure_established_v2_checkout_opened(&scoped, None, false).is_ok());
18081        assert!(
18082            ensure_established_v2_checkout_opened(&scoped, Some(&scoped_baseline), true).is_ok()
18083        );
18084    }
18085
18086    #[test]
18087    fn scoped_view_metadata_is_explicitly_non_authoritative() {
18088        let head = scoped_test_head(&"a".repeat(64));
18089        let value: Value =
18090            serde_json::from_slice(&scoped_view_metadata(&head, 7).unwrap()).unwrap();
18091        assert_eq!(value["kind"], "link.md-scoped-view");
18092        assert_eq!(value["authoritative"], false);
18093        assert_eq!(value["visible_files"], 7);
18094        assert_eq!(value["brain"], TEST_BRAIN_ID);
18095    }
18096
18097    #[test]
18098    fn local_scoped_marker_requires_the_exact_generated_projection() {
18099        let directory = tempfile::tempdir().unwrap();
18100        std::fs::create_dir(directory.path().join(".dbmd")).unwrap();
18101        std::fs::write(
18102            directory.path().join("DB.md"),
18103            scoped_projection_bytes(TEST_BRAIN_ID),
18104        )
18105        .unwrap();
18106        let head = scoped_test_head(&"a".repeat(64));
18107        std::fs::write(
18108            directory.path().join(".dbmd/view.json"),
18109            scoped_view_metadata(&head, 0).unwrap(),
18110        )
18111        .unwrap();
18112        let store = Store::open_strict(directory.path()).unwrap();
18113        assert!(has_verified_local_scoped_view(&store));
18114
18115        std::fs::write(
18116            directory.path().join("DB.md"),
18117            b"---\ntype: db-md\nscope: company\nowner: altered\n---\n",
18118        )
18119        .unwrap();
18120        let altered = Store::open_strict(directory.path()).unwrap();
18121        assert!(!has_verified_local_scoped_view(&altered));
18122    }
18123
18124    fn signed_proposal_fixture() -> (V2VerifiedHead, String, Value) {
18125        use ring::signature::KeyPair as _;
18126
18127        let proposal_id = "01k2r7bm9w5x6e8nq3tjhv4cya".to_string();
18128        let rng = ring::rand::SystemRandom::new();
18129        let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
18130        let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap();
18131        let public_der = [ED25519_SPKI_PREFIX.as_slice(), pair.public_key().as_ref()].concat();
18132        let public_key = URL_SAFE_NO_PAD.encode(&public_der);
18133        let fingerprint = format!("{:x}", Sha256::digest(&public_der));
18134        let blob = b"new";
18135        let blob_hash = content_sha256(blob);
18136        let changes = json!({
18137            "mutation_id": "sync:proposal-fixture",
18138            "operations": [{
18139                "blob": blob_hash,
18140                "bytes": blob.len(),
18141                "expected": null,
18142                "op": "put",
18143                "path": "records/new.md",
18144            }],
18145            "reason": "fixture",
18146            "v": 2,
18147        });
18148        let changes_bytes = crate::linkmd_v2::canonical_bytes(&changes).unwrap();
18149        let changes_base64 = STANDARD.encode(&changes_bytes);
18150        let descriptor = json!({
18151            "base": null,
18152            "blobs": [{ "bytes": blob.len(), "sha256": blob_hash }],
18153            "changes_base64": changes_base64,
18154            "rebase": "strict",
18155            "v": 2,
18156        });
18157        let clear_hash = content_sha256(&crate::linkmd_v2::canonical_bytes(&descriptor).unwrap());
18158        let payload_hash = "b".repeat(64);
18159        let submitted_at = "2026-08-19T12:00:00.000Z";
18160        let claim = json!({
18161            "actor_root": {
18162                "actor_class": "foreign_key",
18163                "credential": "ed25519:fixture",
18164                "grants": ["01k2r7bm9w5x6e8nq3tjhv4cyb"],
18165                "organization": "01k2r7bm9w5x6e8nq3tjhv4cyc",
18166                "principal": "key:fixture",
18167                "role": null,
18168            },
18169            "brain": TEST_BRAIN_ID,
18170            "clear_sha256": clear_hash,
18171            "control_revision": "c".repeat(64),
18172            "mutation_id": "sync:proposal-fixture",
18173            "payload_sha256": payload_hash,
18174            "proposal_id": proposal_id,
18175            "submitted_at": submitted_at,
18176            "v": 2,
18177        });
18178        let claim_bytes = crate::linkmd_v2::canonical_bytes(&claim).unwrap();
18179        let envelope = json!({
18180            "claim": claim,
18181            "fingerprint": fingerprint,
18182            "public_key": public_key,
18183            "sig": URL_SAFE_NO_PAD.encode(pair.sign(&claim_bytes).as_ref()),
18184        });
18185        let envelope_bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18186        let submission_hash =
18187            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &envelope_bytes).unwrap();
18188        let mut head = scoped_test_head(&"c".repeat(64));
18189        head.view_kind = "full".to_string();
18190        head.trust.hub_signer = Some(format!("{fingerprint}:{public_key}"));
18191        let value = json!({
18192            "proposal": {
18193                "base": null,
18194                "blobs": [{
18195                    "bytes": blob.len(),
18196                    "endpoint": format!(
18197                        "/api/hub/brains/{TEST_BRAIN_ID}/v2/proposals/{proposal_id}/blob?sha256={blob_hash}"
18198                    ),
18199                    "sha256": blob_hash,
18200                }],
18201                "changes_base64": changes_base64,
18202                "clear_sha256": clear_hash,
18203                "expires_at": "2026-08-26T12:00:00.000Z",
18204                "id": proposal_id,
18205                "payload_sha256": payload_hash,
18206                "proposer": { "class": "foreign_key" },
18207                "rebase": "strict",
18208                "state": "pending",
18209                "submission_claim_base64": STANDARD.encode(envelope_bytes),
18210                "submission_claim_sha256": submission_hash,
18211                "submitted_at": submitted_at,
18212            },
18213            "v": 2,
18214        });
18215        (head, proposal_id, value)
18216    }
18217
18218    #[test]
18219    fn v2_proposal_verifier_accepts_exact_signed_payload() {
18220        let (head, proposal_id, value) = signed_proposal_fixture();
18221        let verified = verify_v2_proposal_value(&head, &proposal_id, value).unwrap();
18222        assert_eq!(verified.blobs.len(), 1);
18223        assert_eq!(verified.changes["operations"][0]["path"], "records/new.md");
18224    }
18225
18226    #[test]
18227    fn v2_proposal_verifier_refuses_payload_endpoint_and_signature_tampering() {
18228        let (head, proposal_id, value) = signed_proposal_fixture();
18229
18230        let mut changed = value.clone();
18231        changed["proposal"]["changes_base64"] = Value::String(STANDARD.encode(b"{}"));
18232        assert!(verify_v2_proposal_value(&head, &proposal_id, changed).is_err());
18233
18234        let mut redirected = value.clone();
18235        redirected["proposal"]["blobs"][0]["endpoint"] =
18236            Value::String("https://attacker.example/blob".to_string());
18237        assert!(verify_v2_proposal_value(&head, &proposal_id, redirected).is_err());
18238
18239        let mut forged = value;
18240        let encoded = forged["proposal"]["submission_claim_base64"]
18241            .as_str()
18242            .unwrap();
18243        let mut envelope: Value =
18244            serde_json::from_slice(&STANDARD.decode(encoded).unwrap()).unwrap();
18245        envelope["sig"] = Value::String(URL_SAFE_NO_PAD.encode([0_u8; 64]));
18246        let bytes = crate::linkmd_v2::canonical_bytes(&envelope).unwrap();
18247        forged["proposal"]["submission_claim_base64"] = Value::String(STANDARD.encode(&bytes));
18248        forged["proposal"]["submission_claim_sha256"] = Value::String(
18249            crate::linkmd_v2::domain_hash_bytes("v2/proposal-claim", &bytes).unwrap(),
18250        );
18251        assert!(verify_v2_proposal_value(&head, &proposal_id, forged).is_err());
18252    }
18253
18254    #[cfg(unix)]
18255    #[test]
18256    fn v2_atomic_install_materializes_only_local_catalogs_before_swap() {
18257        let sandbox = tempfile::tempdir().unwrap();
18258        let destination = sandbox.path().join("brain");
18259        let entries = vec![
18260            (
18261                "DB.md".to_string(),
18262                scoped_projection_bytes(TEST_BRAIN_ID),
18263            ),
18264            (
18265                "records/contacts/a.md".to_string(),
18266                b"---\ntype: contact\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Visible contact\n---\n\n# A\n"
18267                    .to_vec(),
18268            ),
18269        ];
18270        install_pulled_delta(&destination, &entries, &[], true).unwrap();
18271        assert!(destination.join("index.md").is_file());
18272        assert!(destination.join("records/index.md").is_file());
18273        assert!(destination.join("records/contacts/index.md").is_file());
18274        assert!(destination.join("records/contacts/index.jsonl").is_file());
18275    }
18276
18277    #[cfg(unix)]
18278    #[test]
18279    fn v2_bounded_parallel_source_install_is_exact_and_atomic() {
18280        let sandbox = tempfile::tempdir().unwrap();
18281        let destination = sandbox.path().join("brain");
18282        let cache = sandbox.path().join("cache");
18283        std::fs::create_dir(&cache).unwrap();
18284        let db = scoped_projection_bytes(TEST_BRAIN_ID);
18285        let shared = b"---\ntype: note\ncreated: 2026-08-19T00:00:00Z\nupdated: 2026-08-19T00:00:00Z\nsummary: Shared\n---\n\n# Shared\n";
18286        let db_source = cache.join("db");
18287        let shared_source = cache.join("shared");
18288        crate::fsx::write_atomic(&db_source, &db).unwrap();
18289        crate::fsx::write_atomic(&shared_source, shared).unwrap();
18290        let mut entries = vec![V2StagedFile {
18291            path: "DB.md".to_string(),
18292            source: db_source,
18293            sha256: content_sha256(&db),
18294            bytes: db.len() as u64,
18295        }];
18296        for index in 0..512 {
18297            entries.push(V2StagedFile {
18298                path: format!("records/items/{index:05}.md"),
18299                source: shared_source.clone(),
18300                sha256: content_sha256(shared),
18301                bytes: shared.len() as u64,
18302            });
18303        }
18304        install_pulled_delta_sources(
18305            &destination,
18306            &entries,
18307            &[],
18308            false,
18309            None,
18310            &scoped_test_head(&"c".repeat(64)),
18311        )
18312        .unwrap();
18313        assert_eq!(std::fs::read(destination.join("DB.md")).unwrap(), db);
18314        for index in 0..512 {
18315            assert_eq!(
18316                std::fs::read(destination.join(format!("records/items/{index:05}.md"))).unwrap(),
18317                shared
18318            );
18319        }
18320        assert!(
18321            std::fs::read_dir(sandbox.path())
18322                .unwrap()
18323                .all(|entry| !entry
18324                    .unwrap()
18325                    .file_name()
18326                    .to_string_lossy()
18327                    .contains("pull-stage")),
18328            "the private stage must be atomically installed or removed"
18329        );
18330    }
18331
18332    #[cfg(unix)]
18333    #[test]
18334    fn v2_established_pull_touches_only_delta_and_rolls_back_exactly() {
18335        use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _};
18336
18337        let sandbox = tempfile::tempdir().unwrap();
18338        let root = sandbox.path().join("brain");
18339        std::fs::create_dir_all(root.join("records/items")).unwrap();
18340        let db = scoped_projection_bytes(TEST_BRAIN_ID);
18341        let old = b"---\ntype: note\n---\n\nold\n";
18342        let new = b"---\ntype: note\n---\n\nnew\n";
18343        let removed = b"---\ntype: note\n---\n\nremove me\n";
18344        std::fs::write(root.join("DB.md"), &db).unwrap();
18345        std::fs::write(root.join("records/items/change.md"), old).unwrap();
18346        std::fs::write(root.join("records/items/delete.md"), removed).unwrap();
18347        for index in 0..512 {
18348            std::fs::write(
18349                root.join(format!("records/items/untouched-{index:04}.md")),
18350                old,
18351            )
18352            .unwrap();
18353        }
18354        let untouched = root.join("records/items/untouched-0256.md");
18355        let untouched_inode = std::fs::metadata(&untouched).unwrap().ino();
18356        let source = sandbox.path().join("changed-source");
18357        crate::fsx::write_atomic(&source, new).unwrap();
18358        let same_source = sandbox.path().join("unchanged-source");
18359        crate::fsx::write_atomic(&same_source, old).unwrap();
18360        let same_entry = V2StagedFile {
18361            path: "records/items/change.md".to_string(),
18362            source: same_source,
18363            sha256: content_sha256(old),
18364            bytes: old.len() as u64,
18365        };
18366        let entry = V2StagedFile {
18367            path: "records/items/change.md".to_string(),
18368            source,
18369            sha256: content_sha256(new),
18370            bytes: new.len() as u64,
18371        };
18372        let head = scoped_test_head(&"c".repeat(64));
18373
18374        // A no-op established pull neither clones the checkout nor arms a
18375        // journal. Inode identity is a direct regression guard against the
18376        // former brain-wide private-copy implementation.
18377        install_established_v2_delta(
18378            Store::open_strict(&root).unwrap(),
18379            &[same_entry],
18380            &["records/items/already-absent.md".to_string()],
18381            true,
18382            None,
18383            &head,
18384        )
18385        .unwrap();
18386        assert_eq!(
18387            std::fs::metadata(&untouched).unwrap().ino(),
18388            untouched_inode
18389        );
18390        assert!(!root.join(V2_PULL_JOURNAL).exists());
18391
18392        install_established_v2_delta(
18393            Store::open_strict(&root).unwrap(),
18394            &[entry],
18395            &["records/items/delete.md".to_string()],
18396            false,
18397            None,
18398            &head,
18399        )
18400        .unwrap();
18401        assert_eq!(
18402            std::fs::read(root.join("records/items/change.md")).unwrap(),
18403            new
18404        );
18405        assert!(!root.join("records/items/delete.md").exists());
18406        assert_eq!(
18407            std::fs::metadata(&untouched).unwrap().ino(),
18408            untouched_inode
18409        );
18410        assert!(root.join(V2_PULL_JOURNAL).is_file());
18411        assert_eq!(
18412            std::fs::metadata(root.join(V2_PULL_JOURNAL))
18413                .unwrap()
18414                .permissions()
18415                .mode()
18416                & 0o777,
18417            0o600
18418        );
18419        let journal = load_v2_pull_journal(&Store::open_strict(&root).unwrap())
18420            .unwrap()
18421            .unwrap();
18422        assert_eq!(
18423            std::fs::metadata(root.join(&journal.backup_dir))
18424                .unwrap()
18425                .permissions()
18426                .mode()
18427                & 0o777,
18428            0o700
18429        );
18430        for entry in &journal.entries {
18431            if let Some(backup) = &entry.backup {
18432                assert_eq!(
18433                    std::fs::metadata(root.join(&journal.backup_dir).join(backup))
18434                        .unwrap()
18435                        .permissions()
18436                        .mode()
18437                        & 0o777,
18438                    0o600
18439                );
18440            }
18441        }
18442
18443        let cfg = test_hub_config(
18444            "https://example.test".to_string(),
18445            sandbox.path().join("state"),
18446        );
18447        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18448        assert_eq!(
18449            std::fs::read(root.join("records/items/change.md")).unwrap(),
18450            old
18451        );
18452        assert_eq!(
18453            std::fs::read(root.join("records/items/delete.md")).unwrap(),
18454            removed
18455        );
18456        assert_eq!(
18457            std::fs::metadata(&untouched).unwrap().ino(),
18458            untouched_inode
18459        );
18460        assert!(!root.join(V2_PULL_JOURNAL).exists());
18461    }
18462
18463    #[test]
18464    fn v2_bulk_stream_is_exact_ordered_and_tamper_evident() {
18465        let body = b"bounded bytes";
18466        let path = "records/example.md".to_string();
18467        let file = V2BaselineFile {
18468            sha256: content_sha256(body),
18469            bytes: body.len() as u64,
18470            proof: None,
18471        };
18472        let header = serde_json::to_vec(&json!({
18473            "bytes": body.len(),
18474            "path": path,
18475            "sha256": file.sha256,
18476            "v": 2,
18477        }))
18478        .unwrap();
18479        let mut stream = V2_BULK_STREAM_MAGIC.to_vec();
18480        stream.extend_from_slice(&(header.len() as u32).to_be_bytes());
18481        stream.extend_from_slice(&header);
18482        stream.extend_from_slice(body);
18483        stream.extend_from_slice(&0_u32.to_be_bytes());
18484        let parsed = parse_v2_bulk_stream(&stream, &[(&path, &file)]).unwrap();
18485        assert_eq!(parsed, vec![(path.clone(), body.to_vec())]);
18486
18487        let mut tampered = stream.clone();
18488        let body_offset = V2_BULK_STREAM_MAGIC.len() + 4 + header.len();
18489        tampered[body_offset] ^= 1;
18490        assert!(parse_v2_bulk_stream(&tampered, &[(&path, &file)]).is_err());
18491
18492        let mut trailing = stream;
18493        trailing.push(0);
18494        assert!(parse_v2_bulk_stream(&trailing, &[(&path, &file)]).is_err());
18495    }
18496
18497    #[test]
18498    fn conflict_cache_prunes_only_expired_or_incomplete_state_by_default() {
18499        let sandbox = tempfile::TempDir::new().unwrap();
18500        let root = sandbox.path().join("brain");
18501        std::fs::create_dir_all(&root).unwrap();
18502        std::fs::write(
18503            root.join("DB.md"),
18504            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18505        )
18506        .unwrap();
18507        let store = Store::open_strict(&root).unwrap();
18508        let incomplete = crate::ulid::mint();
18509        store
18510            .create_dir_all(&v2_conflict_relative(&incomplete, "files"))
18511            .unwrap();
18512        let expired = crate::ulid::mint();
18513        store
18514            .create_dir_all(&v2_conflict_relative(&expired, "files"))
18515            .unwrap();
18516        let plan = V2ConflictPlan {
18517            v: 2,
18518            class: "content_resolution_required".to_string(),
18519            bundle: expired.clone(),
18520            brain: TEST_BRAIN_ID.to_string(),
18521            origin: "https://example.test".to_string(),
18522            created_unix: 0,
18523            expires_unix: 0,
18524            base_seq: None,
18525            base_commit: None,
18526            remote_seq: 0,
18527            remote_commit: None,
18528            remote_content_root: None,
18529            view_kind: "full".to_string(),
18530            view_revision: "a".repeat(64),
18531            files: vec![V2ConflictFile {
18532                path: "records/value.md".to_string(),
18533                base: V2ConflictCoordinate {
18534                    sha256: None,
18535                    bytes: None,
18536                    file: None,
18537                },
18538                local: V2ConflictCoordinate {
18539                    sha256: None,
18540                    bytes: None,
18541                    file: None,
18542                },
18543                remote: V2ConflictCoordinate {
18544                    sha256: None,
18545                    bytes: None,
18546                    file: None,
18547                },
18548            }],
18549        };
18550        let mut bytes = serde_json::to_vec(&plan).unwrap();
18551        bytes.push(b'\n');
18552        store
18553            .write_atomic_new(&v2_conflict_relative(&expired, "plan.json"), &bytes)
18554            .unwrap();
18555
18556        let listed = sync_conflicts(&root, false, false).unwrap();
18557        assert_eq!(listed["bundles"], 2);
18558        assert_eq!(listed["pruned"], 0);
18559        let pruned = sync_conflicts(&root, true, false).unwrap();
18560        assert_eq!(pruned["bundles"], 0);
18561        assert_eq!(pruned["pruned"], 2);
18562        assert!(!root.join(".dbmd/conflicts").join(incomplete).exists());
18563        assert!(!root.join(".dbmd/conflicts").join(expired).exists());
18564    }
18565
18566    #[test]
18567    fn corrupt_completed_conflict_state_requires_explicit_discard_all() {
18568        let sandbox = tempfile::TempDir::new().unwrap();
18569        let root = sandbox.path().join("brain");
18570        std::fs::create_dir_all(&root).unwrap();
18571        std::fs::write(
18572            root.join("DB.md"),
18573            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18574        )
18575        .unwrap();
18576        let store = Store::open_strict(&root).unwrap();
18577        let bundle = crate::ulid::mint();
18578        store
18579            .create_dir_all(&v2_conflict_relative(&bundle, "files"))
18580            .unwrap();
18581        store
18582            .write_atomic_new(&v2_conflict_relative(&bundle, "plan.json"), b"not-json\n")
18583            .unwrap();
18584
18585        assert!(sync_conflicts(&root, true, false).is_err());
18586        assert!(sync_conflicts(&root, false, true).is_err());
18587        let pruned = sync_conflicts(&root, true, true).unwrap();
18588        assert_eq!(pruned["pruned"], 1);
18589        assert!(!root.join(".dbmd/conflicts").join(bundle).exists());
18590    }
18591
18592    #[test]
18593    fn ready_pull_journal_rolls_back_exact_preimages() {
18594        let sandbox = tempfile::TempDir::new().unwrap();
18595        let root = sandbox.path().join("brain");
18596        std::fs::create_dir_all(root.join("records")).unwrap();
18597        std::fs::write(
18598            root.join("DB.md"),
18599            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18600        )
18601        .unwrap();
18602        let path = "records/value.md";
18603        let old = b"---\ntype: note\n---\n\nold\n";
18604        let new = b"---\ntype: note\n---\n\nnew\n";
18605        std::fs::write(root.join(path), old).unwrap();
18606        let store = Store::open_strict(&root).unwrap();
18607        let bundle = crate::ulid::mint();
18608        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18609        store
18610            .create_private_dir_all(Path::new(&backup_dir))
18611            .unwrap();
18612        store
18613            .write_private_atomic_new(&Path::new(&backup_dir).join("00000000"), old)
18614            .unwrap();
18615        let journal = V2PullJournal {
18616            v: 1,
18617            phase: V2PullPhase::Ready,
18618            brain: TEST_BRAIN_ID.to_string(),
18619            previous: V2PullCoordinate {
18620                head_seq: None,
18621                commit_hash: None,
18622                view_kind: None,
18623                view_revision: None,
18624            },
18625            next: V2PullCoordinate {
18626                head_seq: Some(2),
18627                commit_hash: Some("c".repeat(64)),
18628                view_kind: Some("full".to_string()),
18629                view_revision: Some("d".repeat(64)),
18630            },
18631            backup_dir: backup_dir.clone(),
18632            entries: vec![V2PullJournalEntry {
18633                path: path.to_string(),
18634                old: Some(V2PullFileCoordinate {
18635                    sha256: content_sha256(old),
18636                    bytes: old.len() as u64,
18637                }),
18638                new: Some(V2PullFileCoordinate {
18639                    sha256: content_sha256(new),
18640                    bytes: new.len() as u64,
18641                }),
18642                backup: Some("00000000".to_string()),
18643            }],
18644        };
18645        validate_v2_pull_journal(&journal).unwrap();
18646        store
18647            .write_private_atomic_new(
18648                Path::new(V2_PULL_JOURNAL),
18649                &v2_pull_journal_bytes(&journal).unwrap(),
18650            )
18651            .unwrap();
18652        store.write_atomic(Path::new(path), new).unwrap();
18653
18654        let cfg = test_hub_config(
18655            "https://example.test".to_string(),
18656            sandbox.path().join("state"),
18657        );
18658        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18659        assert_eq!(std::fs::read(root.join(path)).unwrap(), old);
18660        assert!(!root.join(V2_PULL_JOURNAL).exists());
18661        assert!(!root.join(backup_dir).exists());
18662    }
18663
18664    #[test]
18665    fn preparing_pull_journal_discards_only_private_staging() {
18666        let sandbox = tempfile::TempDir::new().unwrap();
18667        let root = sandbox.path().join("brain");
18668        std::fs::create_dir_all(root.join(".dbmd")).unwrap();
18669        std::fs::write(
18670            root.join("DB.md"),
18671            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18672        )
18673        .unwrap();
18674        let store = Store::open_strict(&root).unwrap();
18675        let bundle = crate::ulid::mint();
18676        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18677        store
18678            .create_private_dir_all(Path::new(&backup_dir))
18679            .unwrap();
18680        let journal = V2PullJournal {
18681            v: 1,
18682            phase: V2PullPhase::Preparing,
18683            brain: TEST_BRAIN_ID.to_string(),
18684            previous: V2PullCoordinate {
18685                head_seq: None,
18686                commit_hash: None,
18687                view_kind: None,
18688                view_revision: None,
18689            },
18690            next: V2PullCoordinate {
18691                head_seq: Some(1),
18692                commit_hash: Some("a".repeat(64)),
18693                view_kind: Some("full".to_string()),
18694                view_revision: Some("b".repeat(64)),
18695            },
18696            backup_dir: backup_dir.clone(),
18697            entries: vec![V2PullJournalEntry {
18698                path: "records/new.md".to_string(),
18699                old: None,
18700                new: Some(V2PullFileCoordinate {
18701                    sha256: "c".repeat(64),
18702                    bytes: 1,
18703                }),
18704                backup: None,
18705            }],
18706        };
18707        store
18708            .write_private_atomic_new(
18709                Path::new(V2_PULL_JOURNAL),
18710                &v2_pull_journal_bytes(&journal).unwrap(),
18711            )
18712            .unwrap();
18713        let cfg = test_hub_config(
18714            "https://example.test".to_string(),
18715            sandbox.path().join("state"),
18716        );
18717
18718        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18719
18720        assert!(root.join("DB.md").is_file());
18721        assert!(!root.join(V2_PULL_JOURNAL).exists());
18722        assert!(!root.join(backup_dir).exists());
18723    }
18724
18725    #[test]
18726    fn committed_baseline_keeps_installed_bytes_and_prunes_orphans() {
18727        let sandbox = tempfile::TempDir::new().unwrap();
18728        let root = sandbox.path().join("brain");
18729        std::fs::create_dir_all(root.join("records")).unwrap();
18730        std::fs::write(
18731            root.join("DB.md"),
18732            b"---\ntype: db-md\nscope: company\n---\n\n# Test\n",
18733        )
18734        .unwrap();
18735        let new = b"---\ntype: note\n---\n\nnew\n";
18736        std::fs::write(root.join("records/value.md"), new).unwrap();
18737        let store = Store::open_strict(&root).unwrap();
18738        let bundle = crate::ulid::mint();
18739        let backup_dir = format!(".dbmd/pull-backup-{bundle}");
18740        store
18741            .create_private_dir_all(Path::new(&backup_dir))
18742            .unwrap();
18743        let orphan = format!(".dbmd/pull-backup-{}", crate::ulid::mint());
18744        store.create_private_dir_all(Path::new(&orphan)).unwrap();
18745        let next = V2PullCoordinate {
18746            head_seq: Some(2),
18747            commit_hash: Some("c".repeat(64)),
18748            view_kind: Some("full".to_string()),
18749            view_revision: Some("d".repeat(64)),
18750        };
18751        let journal = V2PullJournal {
18752            v: 1,
18753            phase: V2PullPhase::Ready,
18754            brain: TEST_BRAIN_ID.to_string(),
18755            previous: V2PullCoordinate {
18756                head_seq: Some(1),
18757                commit_hash: Some("a".repeat(64)),
18758                view_kind: Some("full".to_string()),
18759                view_revision: Some("b".repeat(64)),
18760            },
18761            next: next.clone(),
18762            backup_dir: backup_dir.clone(),
18763            entries: vec![V2PullJournalEntry {
18764                path: "records/value.md".to_string(),
18765                old: Some(V2PullFileCoordinate {
18766                    sha256: "e".repeat(64),
18767                    bytes: new.len() as u64,
18768                }),
18769                new: Some(V2PullFileCoordinate {
18770                    sha256: content_sha256(new),
18771                    bytes: new.len() as u64,
18772                }),
18773                backup: Some("00000000".to_string()),
18774            }],
18775        };
18776        store
18777            .write_private_atomic_new(
18778                Path::new(V2_PULL_JOURNAL),
18779                &v2_pull_journal_bytes(&journal).unwrap(),
18780            )
18781            .unwrap();
18782        let cfg = test_hub_config(
18783            "https://example.test".to_string(),
18784            sandbox.path().join("state"),
18785        );
18786        save_v2_baseline(
18787            &cfg,
18788            TEST_BRAIN_ID,
18789            &root,
18790            &V2SyncBaseline {
18791                v: 2,
18792                origin: "https://example.test".to_string(),
18793                brain: TEST_BRAIN_ID.to_string(),
18794                checkout_id: Some("c".repeat(64)),
18795                head_seq: next.head_seq,
18796                commit_hash: next.commit_hash.clone(),
18797                content_root: Some("f".repeat(64)),
18798                asset_root: None,
18799                assets: Default::default(),
18800                view_kind: next.view_kind.clone(),
18801                view_revision: next.view_revision.clone(),
18802                projection_sha256: None,
18803                files: Default::default(),
18804                local_policy_digest: None,
18805                local_eligibility: Default::default(),
18806                remote_copy_remains: Default::default(),
18807            },
18808        )
18809        .unwrap();
18810
18811        recover_v2_pull(&cfg, TEST_BRAIN_ID, &root).unwrap();
18812
18813        assert_eq!(std::fs::read(root.join("records/value.md")).unwrap(), new);
18814        assert!(!root.join(V2_PULL_JOURNAL).exists());
18815        assert!(!root.join(backup_dir).exists());
18816        assert!(!root.join(orphan).exists());
18817    }
18818}